diff --git a/.claude/skills/pipeline-rules/SKILL.md b/.claude/skills/pipeline-rules/SKILL.md new file mode 100644 index 0000000..27823ab --- /dev/null +++ b/.claude/skills/pipeline-rules/SKILL.md @@ -0,0 +1,27 @@ +--- +name: pipeline-rules +description: Backend (.NET) format, build, and test checks that mirror what CI enforces on this repo — dotnet format, a clean dotnet build, and dotnet test. Use whenever C# files under server/ are added, edited, or reviewed, before considering the work done. +metadata: + type: workflow +when_to_use: "finishing a backend change, editing a .cs file, before saying a server-side task is done, reviewing a C# diff, formatting check, dotnet build, dotnet test, dotnet format" +--- + +# Backend pipeline rules + +Before treating any task that touches files under `server/` (not `server/.claude/worktrees/`) as finished, run the same checks CI would run — in this order, from the `server/` directory: + +1. **Format** — `dotnet format` + Auto-fixes indentation, line length (120 cols), and other style rules from `.editorconfig`. Scope it to what you touched (e.g. `dotnet format --include `) rather than running it unscoped over the whole solution — an unscoped run reformats every file with pending changes anywhere in the repo, including other in-progress work that has nothing to do with your change. Use `dotnet format --verify-no-changes` instead if you only want to check without rewriting files. + + Do not use `dotnet csharpier format`/`dotnet csharpier check` — this repo's CI enforces `dotnet format`/`.editorconfig`, not CSharpier. + +2. **Build** — `dotnet build StackDuel.slnx` + Must succeed with no new errors. Don't introduce new warnings either, unless they're pre-existing and unrelated to the change. + +3. **Test** — run whatever covers the change: + - Targeted: `dotnet test --filter "FullyQualifiedName~"` for a fast pass scoped to what changed. + - Full: `dotnet test StackDuel.slnx` when the change is broad, touches shared/domain code, or the blast radius is unclear. + +Format first, then build, then test — a format pass can shift line numbers, so verify build/test against the formatted code, not before it. + +Run this for every backend change, not only ones that look formatting-related. Code that compiles and passes tests but doesn't match `dotnet format`/`.editorconfig` conventions still fails CI. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6e2e244 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index dbad403..5a0d5e4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,2 @@ +# Auto detect text files and perform LF normalization * text=auto eol=lf - -*.cs text eol=lf -*.csproj text eol=lf -*.slnx text eol=lf -*.json text eol=lf -*.yml text eol=lf -*.yaml text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8ff88db --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 + +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: UTC + open-pull-requests-limit: 10 + labels: + - dependencies + - dotnet + groups: + dotnet-patch: + update-types: + - patch + dotnet-minor: + update-types: + - minor + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: UTC + open-pull-requests-limit: 10 + labels: + - dependencies + - github-actions diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..631e4c9 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,39 @@ +# Labels applied based on which paths changed in the PR. + +area/infrastructure: + - changed-files: + - any-glob-to-any-file: 'StackDuel.Infrastructure/**' + +area/domain: + - changed-files: + - any-glob-to-any-file: 'StackDuel.Domain/**' + +area/api: + - changed-files: + - any-glob-to-any-file: 'StackDuel.Api/**' + +area/application: + - changed-files: + - any-glob-to-any-file: 'StackDuel.Application/**' + +area/tests: + - changed-files: + - any-glob-to-any-file: '**/*.Tests/**' + - any-glob-to-any-file: '**/*.Test/**' + +area/seeder: + - changed-files: + - any-glob-to-any-file: 'StackDuel.Seeder/**' + +ci/cd: + - changed-files: + - any-glob-to-any-file: '.github/**' + - any-glob-to-any-file: '**/Dockerfile*' + - any-glob-to-any-file: '**/*.yml' + +configuration: + - changed-files: + - any-glob-to-any-file: '**/*.json' + - any-glob-to-any-file: '**/*.config' + - any-glob-to-any-file: '.editorconfig' + - any-glob-to-any-file: '.gitattributes' diff --git a/.github/workflows/auto-tag-release.yml b/.github/workflows/auto-tag-release.yml new file mode 100644 index 0000000..853ec5d --- /dev/null +++ b/.github/workflows/auto-tag-release.yml @@ -0,0 +1,77 @@ +name: Auto Tag Release Branch + +on: + create + +permissions: + contents: write + +concurrency: + group: auto-tag-release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + tag: + if: github.ref_type == 'branch' && startsWith(github.ref, 'refs/heads/release/v') + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: Production + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Extract and validate version from branch name + id: validate + shell: bash + env: + BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + + if ! [[ "${BRANCH}" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Branch name does not match release/vX.Y.Z: ${BRANCH}" + exit 1 + fi + + TAG="${BRANCH#release/}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Fail if tag already exists + shell: bash + env: + TAG: ${{ steps.validate.outputs.tag }} + run: | + set -euo pipefail + + git fetch --tags + if git rev-parse "${TAG}" >/dev/null 2>&1; then + echo "Tag already exists: ${TAG}" + exit 1 + fi + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + shell: bash + env: + TAG: ${{ steps.validate.outputs.tag }} + run: | + set -euo pipefail + + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + + - name: Publish GitHub Release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b + with: + tag_name: ${{ steps.validate.outputs.tag }} + target_commitish: ${{ github.sha }} + generate_release_notes: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..355ece8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: "0 6 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + language: + - csharp + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: manual + + - name: Restore dependencies + run: dotnet restore StackDuel.slnx + + - name: Build solution + run: dotnet build StackDuel.slnx -c Release --no-restore + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..335eca6 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,158 @@ +name: Deploy Production + +on: + push: + branches: + - release/** + workflow_dispatch: + +env: + AZURE_WEBAPP_PACKAGE_PATH: "./publish" + +jobs: + build: + uses: ./.github/workflows/build.yml + with: + upload-artifact: true + verify: false + + migrate: + runs-on: ubuntu-latest + needs: build + environment: production + permissions: + id-token: write + contents: read + + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Download migrations bundle artifact + uses: actions/download-artifact@v4 + with: + name: migrations-bundle + path: ./migrations-bundle + + - name: Azure login + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Open DB firewall for this runner + env: + AZURE_POSTGRES_RESOURCE_GROUP: ${{ secrets.AZURE_POSTGRES_RESOURCE_GROUP }} + AZURE_POSTGRES_SERVER_NAME: ${{ secrets.AZURE_POSTGRES_SERVER_NAME }} + run: | + RUNNER_IP=$(curl -s https://api.ipify.org) + echo "RUNNER_IP=$RUNNER_IP" >> "$GITHUB_ENV" + az postgres flexible-server firewall-rule create \ + --resource-group "$AZURE_POSTGRES_RESOURCE_GROUP" \ + --server-name "$AZURE_POSTGRES_SERVER_NAME" \ + --name "gh-migrate-${{ github.run_id }}" \ + --start-ip-address "$RUNNER_IP" \ + --end-ip-address "$RUNNER_IP" + + - name: Run migrations + run: | + chmod +x ./migrations-bundle/efbundle + ./migrations-bundle/efbundle + env: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: ${{ secrets.DB_CONNECTION_STRING }} + + - name: Close DB firewall for this runner + if: always() + env: + AZURE_POSTGRES_RESOURCE_GROUP: ${{ secrets.AZURE_POSTGRES_RESOURCE_GROUP }} + AZURE_POSTGRES_SERVER_NAME: ${{ secrets.AZURE_POSTGRES_SERVER_NAME }} + run: | + az postgres flexible-server firewall-rule delete \ + --resource-group "$AZURE_POSTGRES_RESOURCE_GROUP" \ + --server-name "$AZURE_POSTGRES_SERVER_NAME" \ + --name "gh-migrate-${{ github.run_id }}" \ + --yes + + seed: + runs-on: ubuntu-latest + needs: migrate + environment: production + permissions: + id-token: write + contents: read + + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Download seeder artifact + uses: actions/download-artifact@v4 + with: + name: seeder + path: ./seeder-publish + + - name: Azure login + uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Open DB firewall for this runner + env: + AZURE_POSTGRES_RESOURCE_GROUP: ${{ secrets.AZURE_POSTGRES_RESOURCE_GROUP }} + AZURE_POSTGRES_SERVER_NAME: ${{ secrets.AZURE_POSTGRES_SERVER_NAME }} + run: | + RUNNER_IP=$(curl -s https://api.ipify.org) + echo "RUNNER_IP=$RUNNER_IP" >> "$GITHUB_ENV" + az postgres flexible-server firewall-rule create \ + --resource-group "$AZURE_POSTGRES_RESOURCE_GROUP" \ + --server-name "$AZURE_POSTGRES_SERVER_NAME" \ + --name "gh-seed-${{ github.run_id }}" \ + --start-ip-address "$RUNNER_IP" \ + --end-ip-address "$RUNNER_IP" + + - name: Run static seed + run: dotnet ./seeder-publish/StackDuel.Seeder.dll -- --static + env: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: ${{ secrets.DB_CONNECTION_STRING }} + + - name: Close DB firewall for this runner + if: always() + env: + AZURE_POSTGRES_RESOURCE_GROUP: ${{ secrets.AZURE_POSTGRES_RESOURCE_GROUP }} + AZURE_POSTGRES_SERVER_NAME: ${{ secrets.AZURE_POSTGRES_SERVER_NAME }} + run: | + az postgres flexible-server firewall-rule delete \ + --resource-group "$AZURE_POSTGRES_RESOURCE_GROUP" \ + --server-name "$AZURE_POSTGRES_SERVER_NAME" \ + --name "gh-seed-${{ github.run_id }}" \ + --yes + + deploy: + runs-on: ubuntu-latest + needs: seed + if: ${{ always() && needs.seed.result == 'success' }} + environment: + name: production + + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: webapp + path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} + + - name: Deploy to Azure App Service + uses: azure/webapps-deploy@02a81bead70021f5284939794bcec79c271ab383 # v3 + with: + app-name: ${{ secrets.AZURE_WEBAPP_NAME }} + publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} + package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} diff --git a/.github/workflows/deploy-scrum.yml b/.github/workflows/deploy-scrum.yml new file mode 100644 index 0000000..7a25288 --- /dev/null +++ b/.github/workflows/deploy-scrum.yml @@ -0,0 +1,88 @@ +name: Deploy Scrum + +on: + push: + branches: + - master + workflow_dispatch: + +env: + AZURE_WEBAPP_NAME: "app-sdapi-scrum-centralus-03" + AZURE_WEBAPP_PACKAGE_PATH: "./publish" + +jobs: + build: + uses: ./.github/workflows/build.yml + with: + upload-artifact: true + + migrate: + runs-on: ubuntu-latest + needs: build + environment: Scrum + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Install EF Core tools + run: dotnet tool install --global dotnet-ef + + - name: Run migrations + run: dotnet ef database update --context StackDuelDbContext --project StackDuel.Infrastructure --startup-project StackDuel.Api + env: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: ${{ secrets.DB_CONNECTION_STRING }} + + seed: + runs-on: ubuntu-latest + needs: migrate + environment: Scrum + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Run static seed + run: dotnet run --project StackDuel.Seeder -- --static + env: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: ${{ secrets.DB_CONNECTION_STRING }} + + deploy: + runs-on: ubuntu-latest + needs: seed + if: ${{ always() && needs.seed.result == 'success' }} + environment: + name: Scrum + url: https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net + + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: webapp + path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} + + - name: Deploy to Azure App Service + uses: azure/webapps-deploy@02a81bead70021f5284939794bcec79c271ab383 # v3 + with: + app-name: ${{ env.AZURE_WEBAPP_NAME }} + publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} + package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..64e4ee0 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,47 @@ +name: Security Audit + +on: + pull_request: + branches: + - master + schedule: + - cron: "0 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + nuget-audit: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore solution dependencies + run: | + dotnet restore StackDuel.slnx + dotnet restore StackDuel.Seeder/StackDuel.Seeder.csproj + + - name: Audit solution dependencies + shell: pwsh + run: | + $solutionOutput = dotnet list StackDuel.slnx package --vulnerable --include-transitive + + $solutionOutput | Write-Host + + if (($solutionOutput -join "`n") -match 'has the following vulnerable packages') { + throw 'Vulnerable packages found.' + } diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 57bd4c7..583e80a 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -42,6 +42,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} shell: powershell run: | - ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"Stack-Duel_server" /o:"stack-duel" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + ${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"Stack-Duel_server" /o:"stack-duel" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" dotnet build + dotnet test --collect:"XPlat Code Coverage" --results-directory coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover ${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/.gitignore b/.gitignore index 4978cf6..019cb28 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,6 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore -# Local secrets (keep .env.example tracked) -.env -!.env.example - # User-specific files *.suo *.user @@ -240,8 +236,8 @@ FakesAssemblies/ # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ -!Algowars.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/ -!Algowars.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/** +!StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/ +!StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/** # Typescript v1 declaration files typings/ diff --git a/Directory.Packages.props b/Directory.Packages.props index ad89873..9d16dc2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,10 +4,50 @@ - + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE index 3367c33..4d77878 100644 --- a/LICENSE +++ b/LICENSE @@ -1,106 +1,11 @@ -Business Source License 1.1 +All Rights Reserved -Parameters +Copyright (c) 2026 StackDuel -Licensor: StackDuel -Licensed Work: StackDuel - The Licensed Work is (c) 2026 StackDuel -Additional Use Grant: You may make production use of the Licensed Work for - any purpose other than a Commercial Purpose. A - "Commercial Purpose" means use of the Licensed Work, - or any modification or derivative of it, to provide - a product or service to third parties for a fee, - subscription, or other compensation, or any other - use intended for or directed toward commercial - advantage or monetary compensation. -Change Date: 2030-10-03 -Change License: Apache License, Version 2.0 +This source code is made available for viewing purposes only. No license, +express or implied, is granted to any person to copy, modify, merge, +publish, distribute, sublicense, sell, or otherwise use this software, in +whole or in part, for any purpose, without the prior written permission of +the copyright holder. -For information about alternative licensing arrangements for the Licensed -Work, please contact algowarsdev@gmail.com. - -Notice - -The Business Source License (this document, or the "License") is not an -Open Source license. However, the Licensed Work will eventually be made -available under an Open Source License, as stated in this License. - -License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. -"Business Source License" is a trademark of MariaDB Corporation Ab. - ------------------------------------------------------------------------------ - -Business Source License 1.1 - -Terms - -The Licensor hereby grants you the right to copy, modify, create derivative -works, redistribute, and make non-production use of the Licensed Work. The -Licensor may make an Additional Use Grant, above, permitting limited -production use. - -Effective on the Change Date, or the fourth anniversary of the first publicly -available distribution of a specific version of the Licensed Work under this -License, whichever comes first, the Licensor hereby grants you rights under -the terms of the Change License, and the rights granted in the paragraph -above terminate. - -If your use of the Licensed Work does not comply with the requirements -currently in effect as described in this License, you must purchase a -commercial license from the Licensor, its affiliated entities, or authorized -resellers, or you must refrain from using the Licensed Work. - -All copies of the original and modified Licensed Work, and derivative works -of the Licensed Work, are subject to this License. This License applies -separately for each version of the Licensed Work and the Change Date may vary -for each version of the Licensed Work released by Licensor. - -You must conspicuously display this License on each original or modified -copy of the Licensed Work. If you receive the Licensed Work in original or -modified form from a third party, the terms and conditions set forth in this -License apply to your use of that work. - -Any use of the Licensed Work in violation of this License will automatically -terminate your rights under this License for the current and all other -versions of the Licensed Work. - -This License does not grant you any right in any trademark or logo of -Licensor or its affiliates (provided that you may use a trademark or logo of -Licensor as expressly required by this License). - -TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON -AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, -EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. - -MariaDB hereby grants you permission to use this License's text to license -your works, and to refer to it using the trademark "Business Source License", -as long as you comply with the Covenants of Licensor below. - -Covenants of Licensor - -In consideration of the right to use this License's text and the "Business -Source License" name and trademark, Licensor covenants to MariaDB, and to all -other recipients of the licensed work to be provided by Licensor: - -1. To specify as the Change License the GPL Version 2.0 or any later version, - or a license that is compatible with GPL Version 2.0 or a later version, - where "compatible" means that software provided under the Change License can - be included in a program with software provided under GPL Version 2.0 or a - later version. Licensor may specify additional Change Licenses without - limitation. - -2. To either: (a) specify an additional grant of rights to use that does not - impose any additional restriction on the right granted in this License, as - the Additional Use Grant; or (b) insert the text "None". - -3. To specify a Change Date. - -4. Not to modify this License in any other way. - -Notice - -The Business Source License (this document, or the "License") is not an Open -Source license. However, the Licensed Work will eventually be made available -under an Open Source License, as stated in this License. +All rights not expressly granted herein are reserved. diff --git a/README.md b/README.md index d3da9e2..d8e4900 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,298 @@ # StackDuel Server -[![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Security issues](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=software_quality_security_issues)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Maintainability issues](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=software_quality_maintainability_issues)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Reliability issues](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=software_quality_reliability_issues)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=Stack-Duel_server&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=Stack-Duel_server) +[![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=coverage)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Security issues](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=software_quality_security_issues)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Maintainability issues](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=software_quality_maintainability_issues)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Reliability issues](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=software_quality_reliability_issues)](https://sonarcloud.io/summary/new_code?id=stackduel_server) [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=stackduel_server&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=stackduel_server) -Backend Server for the StackDuel product. +Backend for StackDuel, a competitive programming platform. Built with .NET. -### Getting Started +## Requirements -#### Bruno +- .NET 10 SDK +- docker +- dotnet-ef CLI -This project was built with bruno. It is an alternative to Postman. To get started with this project you need to create a `.env` file in the bruno folder. You can do this by running: +``` +dotnet tool install --global dotnet-ef +``` + +### Getting started + +To get started with the project. You need a postgresql server and a message broker. You can use the docker compose file to spin this up. To do so run the command: + +``` +docker-compose up +``` + +### Configuration (user secrets) + +The API reads its config from `dotnet user-secrets` locally — never put real credentials in `appsettings*.json`. Run the setup script for your platform and answer the prompts (press Enter to accept the shown default, or type `skip` to leave a key untouched): + +``` +./scripts/setup-user-secrets.sh # macOS/Linux/Git Bash +./scripts/setup-user-secrets.ps1 # Windows PowerShell +``` + +Where each value comes from: + +- **Auth0 Domain / Audience** — create an API in your Auth0 tenant (Applications → APIs); the API's Identifier is the Audience, and your tenant domain (e.g. `your-tenant.us.auth0.com`) is the Domain. This needs to match whatever `stackduel/web`'s `AUTH0_AUDIENCE` / `AUTH0_DOMAIN` are pointed at — this API validates tokens issued for that Audience. +- **Connection string** — defaults to the docker-compose Postgres above; only change it if you're pointing at a different database. +- **Judge0 BaseUrl / Host / ApiKey** — code execution runs through the Judge0 CE API via RapidAPI. Subscribe at [RapidAPI's Judge0 CE listing](https://rapidapi.com/judge0-official/api/judge0-ce), then use your RapidAPI key as `ApiKey`. The default `BaseUrl`/`Host` already match Judge0 CE's RapidAPI endpoint — you shouldn't need to change them. +- **MessageBus** — defaults to the docker-compose RabbitMQ above; only switch to `AzureServiceBus` if you're deliberately pointing at a real Service Bus namespace. + +Java language-server support is separate and optional — `scripts/download-jdtls.sh` (or `.ps1`) downloads the language server and prints the additional `LanguageServer:*` user-secrets commands to run. + +### Migrations + +Migrations are managed in `StackDuel.Infrastructure`. To add a new migration: + +``` +dotnet ef migrations add --project StackDuel.Infrastructure --context StackDuelDbContext --startup-project StackDuel.Api +``` + +To apply migrations against the local database (requires the docker-compose Postgres to be running): + +``` +dotnet ef database update --project StackDuel.Infrastructure --context StackDuelDbContext --startup-project StackDuel.Api +``` + +To apply migrations against an external database: -```bash -cp .env.example .env ``` +dotnet ef database update --project StackDuel.Infrastructure --context StackDuelDbContext --connection "" +``` + +### Seeding + +Seed everything (languages + demo problems with tags): + +``` +dotnet run --project StackDuel.Seeder -- --all +``` + +Seed only static data (languages): + +``` +dotnet run --project StackDuel.Seeder -- --static +``` + +Seed only demo data (problems, test suites, tags): + +``` +dotnet run --project StackDuel.Seeder -- --demo +``` + +### Testing + +Two kinds of test project, covering different layers: + +- **Unit tests** — `StackDuel.Application.Tests`, `StackDuel.Domain.Tests` +- **Integration tests** — `StackDuel.IntegrationTests` + +#### Unit tests + +``` +dotnet test StackDuel.Application.Tests +dotnet test StackDuel.Domain.Tests +``` + +#### Integration tests + +Requires Docker running — nothing else. `StackDuel.IntegrationTests` spins up a disposable Postgres container via [Testcontainers](https://testcontainers.com/), runs the app's own EF Core migrations against it, and tears it down when the run finishes. You don't need `docker-compose up` first, and there's no manual migration or seeding step. + +``` +dotnet test StackDuel.IntegrationTests +``` + +Authentication is swapped for a test scheme that trusts an `X-Test-Sub` header instead of a real Auth0 token, so tests can authenticate as any user (created through the real `PUT /api/v1/user` endpoint, or seeded directly for RBAC fixtures like roles/permissions/groups that have no HTTP surface) without minting JWTs. One real external dependency is stubbed out: the RabbitMQ-backed message consumer, which would otherwise try to open a connection to a broker at startup — everything else (Quartz, SignalR, Judge0's HTTP client, Azure Blob storage) starts up as it would in production. + +#### Running everything + +``` +dotnet test StackDuel.slnx +``` + +runs both unit and integration tests together — Docker must be running for the integration project to pass. + +### Feature flags + +Feature flags live in `StackDuel.Domain/FeatureFlags` (evaluation logic in `StackDuel.Application/FeatureFlags`), as their own bounded context separate from RBAC — they're operational toggles, not permanent access grants, though group targeting reuses the existing `Group` entity. `dotnet run --project StackDuel.Seeder -- --static` (or `--all`) creates the seeded `leaderboards` flag; without it, the flag row doesn't exist and resolves as disabled everywhere (evaluation fails closed on an unknown key). + +There's no admin UI yet — flags are managed entirely through the API below. + +**Check what's resolved for the current caller** (public, no auth — this is what the frontend reads): + +``` +GET /api/v1/feature-flag +→ { "leaderboards": true } +``` + +**Admin endpoints** — all require `[RequireUser]` + the `feature-flag:manage:admin` permission (already granted to the seeded `admin` group/role): + +| Method | Route | Body | Does | +| ------ | -------------------------------------------------------- | ---------------------------- | ---------------------------------- | +| GET | `/api/v1/feature-flag/admin` | — | List all flags with their overrides | +| GET | `/api/v1/feature-flag/admin/{key}` | — | Get one flag by key | +| POST | `/api/v1/feature-flag/admin` | `{ key, name, description, defaultEnabled }` | Create a new flag | +| PUT | `/api/v1/feature-flag/admin/{id}/default` | `{ defaultEnabled }` | **Global kill-switch** — on/off for everyone | +| PUT | `/api/v1/feature-flag/admin/{id}/rollout` | `{ rolloutPercentage }` (0-100) | **Canary rollout** to a deterministic % of users | +| PUT | `/api/v1/feature-flag/admin/{id}/user-overrides/{userId}` | `{ effect: "Allow" \| "Deny" }` | **Per-user override** | +| DELETE | `/api/v1/feature-flag/admin/{id}/user-overrides/{userId}` | — | Remove a per-user override | +| PUT | `/api/v1/feature-flag/admin/{id}/group-overrides/{groupId}` | `{ effect: "Allow" \| "Deny" }` | **Per-group override** | +| DELETE | `/api/v1/feature-flag/admin/{id}/group-overrides/{groupId}` | — | Remove a per-group override | + +**Evaluation precedence** (first match wins; `Deny` beats `Allow` within a layer): + +1. Explicit user override +2. Explicit group override (find group ids via `GET /api/v1/group`) +3. Percentage rollout bucket — deterministic hash of `flagKey:userId`, so a user stays in/out consistently across requests without storing a per-user assignment. Anonymous callers never participate in rollout. +4. The flag's global default (`defaultEnabled`) + +Group targeting reuses the existing RBAC groups. Only `default-user` and `admin` are seeded today — there's no "create group" endpoint yet, so targeting a new cohort (e.g. `beta-testers`) means adding it to `WellKnownAuthorization` and seeding it in `AuthorizationSeeder`, then assigning users to it via `PUT /api/v1/user/{userId}/groups` (which replaces a user's group list, not additive). + +**Adding a new flag for a new feature:** + +1. Add a key constant to `WellKnownFeatureFlags` (`StackDuel.Domain/FeatureFlags`) and re-export it in `StackDuel.Api/Authorization/WellKnownFeatures.cs`. +2. Create the row via `POST /api/v1/feature-flag/admin`, or add it to `FeatureFlagSeeder` if it should exist automatically after every deploy. +3. Guard the endpoint with `[RequireFeature(WellKnownFeatures.YourFlag)]`. +4. Guard the frontend with `useFeatureFlag(FeatureFlags.YOUR_FLAG)` (`web/src/domains/feature-flags`). + +No code changes are needed to flip an existing flag, change its rollout percentage, or add/remove a user or group override — only adding a brand-new flag touches code, and only in the two spots above. + +### Language server (Java code intelligence) + +Live diagnostics, hover, and completion in the Java editor are powered by a self-hosted [Eclipse JDT Language Server](https://github.com/eclipse-jdtls/eclipse.jdt.ls) (jdtls), bridged to the browser over a raw WebSocket at `/hubs/language-server`. **Currently disabled everywhere** — locally and in production (`LanguageServer:Enabled = false` by default). The feature degrades gracefully when disabled or misconfigured: the Java editor still works, it just has syntax highlighting only, no live diagnostics. + +It needs **two separate JDKs**, easy to get backwards: + +- **JDK 21+** to run jdtls itself — this build of jdtls refuses to start on JDK 17 (`UnsupportedClassVersionError`). +- **JDK 17** for jdtls to *analyze* the user's code with — should match whatever JDK Judge0 actually compiles with (`JavaCodeTemplateStrategy` pins `/usr/local/jdk17/bin/javac`), so live diagnostics agree with what happens at submit time. + +#### Local setup + +1. Download jdtls: + ``` + ./scripts/download-jdtls.ps1 # or download-jdtls.sh + ``` + Extracts jdtls into a gitignored `.tools/jdtls/` and prints the config values you need below. + +2. Install a JDK 21+ and a JDK 17 locally (any distribution, e.g. [Eclipse Temurin](https://adoptium.net/)). + +3. Set user secrets on `StackDuel.Api`: + ``` + dotnet user-secrets set --project StackDuel.Api LanguageServer:Enabled true + dotnet user-secrets set --project StackDuel.Api LanguageServer:Java:JdtlsRuntimeJavaHome "" + dotnet user-secrets set --project StackDuel.Api LanguageServer:Java:JavaHome "" + dotnet user-secrets set --project StackDuel.Api LanguageServer:Java:JdtlsLauncherJarPath "" + dotnet user-secrets set --project StackDuel.Api LanguageServer:Java:JdtlsConfigDirectory "" + ``` + +4. Restart `StackDuel.Api`. Open the Java editor for any problem — typing something with an undefined symbol should show a live diagnostic without hitting Run. + +Other `LanguageServer` options: `MaxConcurrentSessions` (default 10 — each jdtls process is ~200-400MB, size to available memory), `IdleTimeoutMinutes` (default 10 — idle sessions get reaped and their jdtls process killed). + +#### Azure App Service (Windows) + +App Service deploys are a Kudu zip-deploy of a framework-dependent build (see `scripts/deploy-production.sh`), not a container — there's no package manager to install a JDK with, so the JDKs and jdtls have to be extracted onto the instance's disk directly. + +`D:\home` is persistent **and shared across every scaled-out instance** of the plan, so this only needs doing once, not per-instance or per-deploy. `D:\local` is fast local disk but per-instance and ephemeral — that's what `Path.GetTempPath()` already resolves to for per-session jdtls workspaces, which is correct as-is and needs no configuration. + +1. Open the Kudu console: `https://.scm.azurewebsites.net/DebugConsole` → PowerShell tab. +2. Run: + ```powershell + cd D:\home + New-Item -ItemType Directory -Force -Path tools | Out-Null + cd tools + + Invoke-WebRequest -Uri "https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse" -OutFile jdk21.zip + Expand-Archive jdk21.zip -DestinationPath jdk21-raw + Move-Item (Get-ChildItem jdk21-raw)[0].FullName jdk21 + Remove-Item jdk21.zip, jdk21-raw -Recurse -Force + + Invoke-WebRequest -Uri "https://api.adoptium.net/v3/binary/latest/17/ga/windows/x64/jdk/hotspot/normal/eclipse" -OutFile jdk17.zip + Expand-Archive jdk17.zip -DestinationPath jdk17-raw + Move-Item (Get-ChildItem jdk17-raw)[0].FullName jdk17 + Remove-Item jdk17.zip, jdk17-raw -Recurse -Force + + New-Item -ItemType Directory -Force -Path jdtls | Out-Null + Invoke-WebRequest -Uri "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz" -OutFile jdtls.tar.gz + tar -xzf jdtls.tar.gz -C jdtls + Remove-Item jdtls.tar.gz + + (Get-ChildItem "jdtls\plugins\org.eclipse.equinox.launcher_*.jar")[0].FullName + ``` + The last line prints the launcher jar path (filename has a version suffix that changes per jdtls release). +3. Set these as **Application settings** (Portal → App Service → Configuration), not user secrets — Azure uses `__` instead of `:` for nested keys: + + | Name | Value | + | --- | --- | + | `LanguageServer__Enabled` | `true` | + | `LanguageServer__Java__JdtlsRuntimeJavaHome` | `D:\home\tools\jdk21` | + | `LanguageServer__Java__JavaHome` | `D:\home\tools\jdk17` | + | `LanguageServer__Java__JdtlsConfigDirectory` | `D:\home\tools\jdtls\config_win` | + | `LanguageServer__Java__JdtlsLauncherJarPath` | (path printed in step 2) | + +4. **Enable WebSockets** on the App Service (Configuration → General settings → Web sockets) — off by default, and unlike the SignalR hubs this endpoint has no long-polling fallback if it's off. +5. Restart the App Service. + +Known limitation: a restart, deploy, or scale event kills every active jdtls process along with the rest of the sandbox — sessions just die and reconnect on next use (degrades gracefully, no crash), there's no cross-restart session persistence. + +### Deployment environments + +The deployment workflows use GitHub Environments named `Scrum` and `Production`. +Each environment should define the same settings, with values appropriate for that target. + +Environment variables: + +- `APP_URL`: Public URL for the deployed API. Used for the GitHub deployment environment link. +- `AZURE_WEBAPP_NAME`: Name of the Azure App Service to deploy to. +- `CORS_ALLOWED_ORIGINS`: Comma-separated list of allowed frontend origins. +- `DEPLOY_SCRIPT_PATH`: Repo-relative path to the deployment script to execute, for example `scripts/deploy-scrum.sh`. +- `MESSAGEBUS_TRANSPORT`: `RabbitMQ` or `AzureServiceBus`. +- `QUARTZ_SUBMISSIONCLEANUPJOB_CRONEXPRESSION`: Cron expression for the submission cleanup job. -After this you will need to get these values and put it in your `.env` file: +Environment secrets: -| Variable | What it needs | How to get it | -| --------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------- | -| `CLERK_AUTHORIZATION_URL` | The OAuth Application's authorize URL | Generated when you create the OAuth Application below (copy from the create response/Dashboard) | -| `CLERK_TOKEN_URL` | The OAuth Application's token URL | Same place as above | -| `CLERK_CLIENT_ID` | The OAuth Application's client ID | Same place as above | -| `CLERK_SCOPE` | Scopes to request | `profile email` (default), or whatever scopes you configured on the application | -| `STAGING_BASE_URL` | Hostname of the deployed staging API | From your staging deployment once it exists | -| `PRODUCTION_BASE_URL` | Hostname of the deployed production API | From your production deployment once it exists | +- `AUTH0_AUDIENCE`: Auth0 API audience. +- `AUTH0_DOMAIN`: Auth0 domain. +- `AZURE_SERVICEBUS_CONNECTION_STRING`: Required when `MESSAGEBUS_TRANSPORT` is `AzureServiceBus`. +- `AZURE_WEBAPP_PUBLISH_PROFILE`: Publish profile XML for the Azure App Service (same content used by the `azure/webapps-deploy` action), used to authenticate the Kudu zip deploy. +- `DB_CONNECTION_STRING`: Database connection string used for EF Core migrations and app runtime configuration. +- `RABBITMQ_HOST`: Required when `MESSAGEBUS_TRANSPORT` is `RabbitMQ`. +- `RABBITMQ_PASSWORD`: Required when `MESSAGEBUS_TRANSPORT` is `RabbitMQ`. +- `RABBITMQ_USERNAME`: Required when `MESSAGEBUS_TRANSPORT` is `RabbitMQ`. +- `RABBITMQ_VIRTUALHOST`: Required when `MESSAGEBUS_TRANSPORT` is `RabbitMQ`. +- `APPLICATIONINSIGHTS_CONNECTION_STRING`: Optional, but recommended for production telemetry. -Auth happens via Clerk's **OAuth Applications** feature (Clerk acting as its own OAuth2/OIDC provider) using the Authorization Code flow with PKCE. Bruno opens Clerk's real hosted login in a popup, you log in there, and it hands the token back. No stored credentials, no pre-picked test user. +The deploy workflows publish the API, generate an EF migrations bundle, run the migrations against `DB_CONNECTION_STRING`, and then execute `DEPLOY_SCRIPT_PATH`. +The deployment script receives the following environment variables: -**One-time Clerk setup:** +- `APP_URL` +- `ASPNETCORE_ENVIRONMENT` +- `AUTH0_AUDIENCE` +- `AUTH0_DOMAIN` +- `AZURE_SERVICEBUS_CONNECTION_STRING` +- `AZURE_WEBAPP_NAME` +- `AZURE_WEBAPP_PUBLISH_PROFILE` +- `CORS_ALLOWED_ORIGINS` +- `DB_CONNECTION_STRING` +- `MESSAGEBUS_TRANSPORT` +- `MIGRATION_BUNDLE_PATH` +- `PUBLISH_DIR` +- `QUARTZ_SUBMISSIONCLEANUPJOB_CRONEXPRESSION` +- `RABBITMQ_HOST` +- `RABBITMQ_PASSWORD` +- `RABBITMQ_USERNAME` +- `RABBITMQ_VIRTUALHOST` +- `APPLICATIONINSIGHTS_CONNECTION_STRING` -1. In your Clerk Dashboard, create a new **OAuth Application** (Configure → OAuth Applications, confirm it's available on your plan first). -2. Set it as a **public client** with **PKCE required**. This removes the need for a client secret, same reasoning as Auth0's Native app type. -3. Add `https://oauth.usebruno.com/callback` to **Redirect URIs**. This is Bruno's built-in callback relay, no local server needed. -4. Copy the resulting `authorize_url`, `token_fetch_url`, and `client_id` into `.env`. +### Project structure -Note: OAuth Applications is designed for third-party "Sign in with StackDuel" use cases (other apps authenticating against your Clerk instance), not first-party testing. Using it here for Bruno is a repurposing of the same mechanism, not its primary intended use. +| Project | Description | +| -------------------------- | ----------------------------------- | +| `StackDuel.Api` | HTTP API | +| `StackDuel.Application` | Application logic and handlers | +| `StackDuel.Domain` | Domain models | +| `StackDuel.Infrastructure` | EF Core, repositories, persistence | +| `StackDuel.Application.Tests` | Unit tests — handlers, mocked repositories | +| `StackDuel.Domain.Tests` | Unit tests — domain models | +| `StackDuel.IntegrationTests` | Integration tests — real API + Postgres | +| `StackDuel.Seeder` | CLI for seeding static/demo data | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..034e848 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/StackDuel.Api.Tests/StackDuel.Api.Tests.csproj b/StackDuel.Api.Tests/StackDuel.Api.Tests.csproj index edb299f..569ea1c 100644 --- a/StackDuel.Api.Tests/StackDuel.Api.Tests.csproj +++ b/StackDuel.Api.Tests/StackDuel.Api.Tests.csproj @@ -18,4 +18,8 @@ + + + + \ No newline at end of file diff --git a/StackDuel.Api.Tests/xunit.runner.json b/StackDuel.Api.Tests/xunit.runner.json new file mode 100644 index 0000000..a8a4636 --- /dev/null +++ b/StackDuel.Api.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": true, + "parallelizeTestCollections": true +} diff --git a/StackDuel.Api/ApiServiceRegistration.cs b/StackDuel.Api/ApiServiceRegistration.cs new file mode 100644 index 0000000..e7e3fa9 --- /dev/null +++ b/StackDuel.Api/ApiServiceRegistration.cs @@ -0,0 +1,96 @@ +using Asp.Versioning; +using Scalar.AspNetCore; +using StackDuel.Api.Authorization; +using StackDuel.Api.Extensions; +using StackDuel.Api.Hubs; +using StackDuel.Api.LanguageServer; +using StackDuel.Api.Middleware; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Settings; +using StackDuel.Application.Notifications; +using StackDuel.Infrastructure; +using System.Text.Json.Serialization; + +namespace StackDuel.Api; + +public static class ApiServiceRegistration +{ + private const string CorsPolicyName = "AllowedOrigins"; + + public static IServiceCollection AddApi(this IServiceCollection services, IConfiguration configuration) + { + services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1); + options.AssumeDefaultVersionWhenUnspecified = true; + }); + services.AddOpenApi(); + + var corsOptions = + configuration.GetSection(CorsOptions.SectionName).Get() + ?? throw new InvalidOperationException( + $"Configuration section '{CorsOptions.SectionName}' is missing or invalid." + ); + + services.AddSingleton(corsOptions); + services.AddCors(options => + { + options.AddPolicy( + CorsPolicyName, + policy => + { + policy.WithOrigins(corsOptions.AllowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); + } + ); + }); + + services.AddAuth0(configuration); + services.AddAppInsights(configuration); + services.AddScoped(); + services.AddAttributeRateLimiting(); + + services + .AddSignalR() + .AddJsonProtocol(options => + { + options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + // Applied before UseGlobalExceptionHandler in Program.cs: the exception handler + // clears the response (including headers) when it catches an unhandled exception, + // so CORS must already be registered upstream or error responses come back with + // no Access-Control-Allow-Origin header and the browser blocks them outright. + public static IApplicationBuilder UseApiCors(this IApplicationBuilder app) => app.UseCors(CorsPolicyName); + + public static async Task UseApi(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(); + await app.Services.MigrateAsync(); + } + + app.UseHttpsRedirection(); + app.UseWebSockets(); + app.UseRateLimiter(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseMiddleware(); + app.MapControllers(); + app.MapHub("/hubs/game"); + app.MapHub("/hubs/submission"); + app.Map("/hubs/language-server", LanguageServerBridgeEndpoint.HandleAsync).RequireAuthorization(); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Attributes/RequireFeatureAttribute.cs b/StackDuel.Api/Attributes/RequireFeatureAttribute.cs new file mode 100644 index 0000000..7c18a91 --- /dev/null +++ b/StackDuel.Api/Attributes/RequireFeatureAttribute.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using StackDuel.Application; + +namespace StackDuel.Api.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +public sealed class RequireFeatureAttribute : TypeFilterAttribute +{ + public RequireFeatureAttribute(string featureKey) + : base(typeof(RequireFeatureFilter)) + { + if (string.IsNullOrWhiteSpace(featureKey)) + throw new ArgumentException("Feature key is required.", nameof(featureKey)); + + Arguments = [featureKey]; + } +} + +public sealed class RequireFeatureFilter(UserContext userContext, string featureKey) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!userContext.HasFeature(featureKey)) + { + context.Result = new NotFoundResult(); + return; + } + + await next(); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Attributes/RequirePermissionAttribute.cs b/StackDuel.Api/Attributes/RequirePermissionAttribute.cs new file mode 100644 index 0000000..efdb498 --- /dev/null +++ b/StackDuel.Api/Attributes/RequirePermissionAttribute.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using StackDuel.Application; + +namespace StackDuel.Api.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +public sealed class RequirePermissionAttribute : TypeFilterAttribute +{ + public RequirePermissionAttribute(string permission) + : base(typeof(RequirePermissionFilter)) + { + if (string.IsNullOrWhiteSpace(permission)) + throw new ArgumentException("Permission is required.", nameof(permission)); + + Arguments = [permission]; + } +} + +public sealed class RequirePermissionFilter(UserContext userContext, string permission) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (userContext.User is null) + { + context.Result = new UnauthorizedResult(); + return; + } + + if (!userContext.HasPermission(permission)) + { + context.Result = new ForbidResult(); + return; + } + + await next(); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Attributes/RequireUserAttribute.cs b/StackDuel.Api/Attributes/RequireUserAttribute.cs new file mode 100644 index 0000000..be67b61 --- /dev/null +++ b/StackDuel.Api/Attributes/RequireUserAttribute.cs @@ -0,0 +1,4 @@ +namespace StackDuel.Api.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +public sealed class RequireUserAttribute : Attribute { } \ No newline at end of file diff --git a/StackDuel.Api/Authorization/RbacHandler.cs b/StackDuel.Api/Authorization/RbacHandler.cs new file mode 100644 index 0000000..51d3771 --- /dev/null +++ b/StackDuel.Api/Authorization/RbacHandler.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; + +namespace StackDuel.Api.Authorization; + +public sealed class RbacHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RbacRequirement requirement) + { + if (!context.User.HasClaim(c => c.Type == "permissions")) + { + return Task.CompletedTask; + } + + var permission = context.User.FindFirst(c => c.Type == "permissions" && c.Value == requirement.Permission); + + if (permission == null) + { + return Task.CompletedTask; + } + + context.Succeed(requirement); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/StackDuel.Api/Authorization/RbacRequirement.cs b/StackDuel.Api/Authorization/RbacRequirement.cs new file mode 100644 index 0000000..c634a70 --- /dev/null +++ b/StackDuel.Api/Authorization/RbacRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization; + +namespace StackDuel.Api.Authorization; + +public sealed class RbacRequirement(string permission) : IAuthorizationRequirement +{ + public string Permission { get; } = permission ?? throw new ArgumentNullException(nameof(permission)); +} \ No newline at end of file diff --git a/StackDuel.Api/Authorization/WellKnownFeatures.cs b/StackDuel.Api/Authorization/WellKnownFeatures.cs new file mode 100644 index 0000000..18f1534 --- /dev/null +++ b/StackDuel.Api/Authorization/WellKnownFeatures.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.FeatureFlags; + +namespace StackDuel.Api.Authorization; + +public static class WellKnownFeatures +{ + public const string Leaderboards = WellKnownFeatureFlags.Leaderboards; + public const string Campaigns = WellKnownFeatureFlags.Campaigns; +} \ No newline at end of file diff --git a/StackDuel.Api/Authorization/WellKnownPermissions.cs b/StackDuel.Api/Authorization/WellKnownPermissions.cs new file mode 100644 index 0000000..e1f3b3c --- /dev/null +++ b/StackDuel.Api/Authorization/WellKnownPermissions.cs @@ -0,0 +1,30 @@ +using StackDuel.Domain.Authorization.Rbac; + +namespace StackDuel.Api.Authorization; + +public static class WellKnownPermissions +{ + public const string CreateSubmission = WellKnownAuthorization.CreateSubmissionPermission; + public const string ViewSubmission = WellKnownAuthorization.ViewSubmissionPermission; + public const string PlaySoloRush = WellKnownAuthorization.PlaySoloRushPermission; + public const string PlayDuel = WellKnownAuthorization.PlayDuelPermission; + public const string PlayFfa = WellKnownAuthorization.PlayFfaPermission; + public const string ReadAdminProblems = WellKnownAuthorization.ReadAdminProblemsPermission; + public const string UpdateAdminProblems = WellKnownAuthorization.UpdateAdminProblemsPermission; + public const string ReadAdminUsers = WellKnownAuthorization.ReadAdminUsersPermission; + public const string UpdateAdminUserGroups = WellKnownAuthorization.UpdateAdminUserGroupsPermission; + public const string ReadAdminSubmissions = WellKnownAuthorization.ReadAdminSubmissionsPermission; + public const string CreateFeedback = WellKnownAuthorization.CreateFeedbackPermission; + public const string ReadAdminFeedback = WellKnownAuthorization.ReadAdminFeedbackPermission; + public const string UpdateAdminFeedback = WellKnownAuthorization.UpdateAdminFeedbackPermission; + public const string ReadAdminDashboard = WellKnownAuthorization.ReadAdminDashboardPermission; + public const string ReadAdminGames = WellKnownAuthorization.ReadAdminGamesPermission; + public const string ManageFeatureFlags = WellKnownAuthorization.ManageFeatureFlagsPermission; + public const string ReadAdminCampaigns = WellKnownAuthorization.ReadAdminCampaignsPermission; + public const string ManageAdminCampaigns = WellKnownAuthorization.ManageAdminCampaignsPermission; + public const string ReadAdminAuditLog = WellKnownAuthorization.ReadAdminAuditLogPermission; + public const string CreateAdminProblems = WellKnownAuthorization.CreateAdminProblemsPermission; + public const string SubmitAdminProblems = WellKnownAuthorization.SubmitAdminProblemsPermission; + public const string ManageRequiredProblemLanguages = + WellKnownAuthorization.ManageRequiredProblemLanguagesPermission; +} \ No newline at end of file diff --git a/StackDuel.Api/Configuration/Auth0Options.cs b/StackDuel.Api/Configuration/Auth0Options.cs new file mode 100644 index 0000000..b618547 --- /dev/null +++ b/StackDuel.Api/Configuration/Auth0Options.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Settings; + +namespace StackDuel.Api.Settings; + +public sealed class Auth0Options : IOption +{ + public static string SectionName => "Auth0"; + + public required string Domain { get; init; } + public required string Audience { get; init; } +} \ No newline at end of file diff --git a/StackDuel.Api/Configuration/CorsOptions.cs b/StackDuel.Api/Configuration/CorsOptions.cs new file mode 100644 index 0000000..245a8d5 --- /dev/null +++ b/StackDuel.Api/Configuration/CorsOptions.cs @@ -0,0 +1,10 @@ +using StackDuel.Application.Settings; + +namespace StackDuel.Api.Settings; + +public sealed class CorsOptions : IOption +{ + public static string SectionName => "Cors"; + + public string[] AllowedOrigins { get; init; } = []; +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/AuditLogController.cs b/StackDuel.Api/Controllers/AuditLogController.cs new file mode 100644 index 0000000..9b6322d --- /dev/null +++ b/StackDuel.Api/Controllers/AuditLogController.cs @@ -0,0 +1,41 @@ +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Audit; +using StackDuel.Application.Audit.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Audit; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/audit-log")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class AuditLogController(IAuditLogService auditLogService) : ControllerBase +{ + [HttpGet] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminAuditLog)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAuditLog( + [FromQuery] GetAuditLogPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await auditLogService.GetPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + cancellationToken + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/CampaignController.cs b/StackDuel.Api/Controllers/CampaignController.cs new file mode 100644 index 0000000..d214388 --- /dev/null +++ b/StackDuel.Api/Controllers/CampaignController.cs @@ -0,0 +1,388 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Campaign; +using StackDuel.Application; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Campaigns; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +[RequireFeature(WellKnownFeatures.Campaigns)] +public sealed class CampaignController(ICampaignService campaignService, UserContext userContext) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetCampaignPath( + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await campaignService.GetCampaignPathAsync(cancellationToken)); + } + + [HttpGet("{slug}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCampaignBySlug(string slug, CancellationToken cancellationToken) + { + return this.ToActionResult(await campaignService.GetCampaignBySlugAsync(slug, cancellationToken)); + } + + [HttpGet("progress")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetMyEnrollments( + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await campaignService.GetMyEnrollmentsAsync(userContext.User.Id, cancellationToken)); + } + + [HttpGet("stats")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> GetMyLearningStats(CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await campaignService.GetMyLearningStatsAsync(userContext.User.Id, cancellationToken) + ); + } + + [HttpGet("{campaignId:guid}/progress")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMyCampaignProgress( + Guid campaignId, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await campaignService.GetMyCampaignProgressAsync(campaignId, userContext.User.Id, cancellationToken) + ); + } + + [HttpPost("{campaignId:guid}/enroll")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> EnrollInCampaign(Guid campaignId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await campaignService.EnrollInCampaignAsync(campaignId, userContext.User.Id, cancellationToken) + ); + } + + [HttpPost("units/{unitId:guid}/complete")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CompleteUnit(Guid unitId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await campaignService.CompleteUnitAsync(unitId, userContext.User.Id, cancellationToken) + ); + } + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAdminCampaigns( + [FromQuery] GetAdminCampaignsPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.GetAdminCampaignsPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + query.Search, + cancellationToken + ) + ); + } + + [HttpGet("admin/{campaignId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminCampaignDetail( + Guid campaignId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await campaignService.GetAdminCampaignDetailAsync(campaignId, cancellationToken)); + } + + [HttpPost("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateCampaign( + [FromBody] CreateCampaignRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.CreateCampaignAsync( + request.Title, + request.Description, + request.Difficulty, + cancellationToken + ) + ); + } + + [HttpPut("admin/{campaignId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateCampaignDetails( + Guid campaignId, + [FromBody] UpdateCampaignDetailsRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.UpdateCampaignDetailsAsync( + campaignId, + request.Title, + request.Description, + request.Difficulty, + request.IconKey, + cancellationToken + ) + ); + } + + [HttpPost("admin/{campaignId:guid}/publish")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PublishCampaign(Guid campaignId, CancellationToken cancellationToken) + { + return this.ToActionResult(await campaignService.PublishCampaignAsync(campaignId, cancellationToken)); + } + + [HttpPost("admin/{campaignId:guid}/archive")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ArchiveCampaign(Guid campaignId, CancellationToken cancellationToken) + { + return this.ToActionResult(await campaignService.ArchiveCampaignAsync(campaignId, cancellationToken)); + } + + [HttpPut("admin/{campaignId:guid}/prerequisites")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SetCampaignPrerequisites( + Guid campaignId, + [FromBody] SetCampaignPrerequisitesRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.SetCampaignPrerequisitesAsync( + campaignId, + request.RequiredCampaignIds, + cancellationToken + ) + ); + } + + [HttpPost("admin/{campaignId:guid}/modules")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AddCampaignModule( + Guid campaignId, + [FromBody] AddCampaignModuleRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.AddCampaignModuleAsync( + campaignId, + request.Title, + request.Description, + cancellationToken + ) + ); + } + + [HttpPut("admin/{campaignId:guid}/modules/{moduleId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateCampaignModule( + Guid campaignId, + Guid moduleId, + [FromBody] UpdateCampaignModuleRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.UpdateCampaignModuleAsync( + campaignId, + moduleId, + request.Title, + request.Description, + cancellationToken + ) + ); + } + + [HttpPost("admin/{campaignId:guid}/modules/{moduleId:guid}/units")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AddCampaignUnit( + Guid campaignId, + Guid moduleId, + [FromBody] AddCampaignUnitRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.AddCampaignUnitAsync( + campaignId, + moduleId, + request.Title, + request.Content, + request.UnitType, + request.EstimatedMinutes, + cancellationToken + ) + ); + } + + [HttpPut("admin/{campaignId:guid}/modules/{moduleId:guid}/units/{unitId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateCampaignUnit( + Guid campaignId, + Guid moduleId, + Guid unitId, + [FromBody] UpdateCampaignUnitRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.UpdateCampaignUnitAsync( + campaignId, + moduleId, + unitId, + request.Title, + request.Content, + request.UnitType, + request.EstimatedMinutes, + cancellationToken + ) + ); + } + + [HttpPut("admin/{campaignId:guid}/modules/{moduleId:guid}/units/{unitId:guid}/problems")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageAdminCampaigns)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SetUnitProblems( + Guid campaignId, + Guid moduleId, + Guid unitId, + [FromBody] SetUnitProblemsRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await campaignService.SetUnitProblemsAsync( + campaignId, + moduleId, + unitId, + request.ProblemIds, + cancellationToken + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/DailyChallengeController.cs b/StackDuel.Api/Controllers/DailyChallengeController.cs new file mode 100644 index 0000000..ed86407 --- /dev/null +++ b/StackDuel.Api/Controllers/DailyChallengeController.cs @@ -0,0 +1,68 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.DailyChallenge; +using StackDuel.Application; +using StackDuel.Application.DailyChallenges.Dtos; +using StackDuel.Application.Services.DailyChallenges; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class DailyChallengeController(IDailyChallengeService dailyChallengeService, UserContext userContext) + : ControllerBase +{ + [HttpGet("today")] + [RequireUser] + [ProducesResponseType(typeof(TodaysDailyChallengeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetTodaysChallenge(CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await dailyChallengeService.GetTodaysChallengeAsync(userContext.User.Id, cancellationToken) + ); + } + + [HttpGet("admin/upcoming")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetUpcomingChallenges( + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await dailyChallengeService.GetUpcomingChallengesAsync(cancellationToken)); + } + + [HttpPut("admin/{date}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateChallenge( + DateOnly date, + [FromBody] UpdateDailyChallengeRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await dailyChallengeService.UpdateChallengeAsync(date, request.ProblemId, cancellationToken) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/DashboardController.cs b/StackDuel.Api/Controllers/DashboardController.cs new file mode 100644 index 0000000..2472ccb --- /dev/null +++ b/StackDuel.Api/Controllers/DashboardController.cs @@ -0,0 +1,28 @@ +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Application.Dashboard.Dtos; +using StackDuel.Application.Services.Dashboard; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class DashboardController(IDashboardService dashboardService) : ControllerBase +{ + private const int NewUsersDays = 30; + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminDashboard)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> GetAdminDashboardStats(CancellationToken cancellationToken) + { + return this.ToActionResult(await dashboardService.GetAdminDashboardStatsAsync(NewUsersDays, cancellationToken)); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/FeatureFlagController.cs b/StackDuel.Api/Controllers/FeatureFlagController.cs new file mode 100644 index 0000000..4bbb107 --- /dev/null +++ b/StackDuel.Api/Controllers/FeatureFlagController.cs @@ -0,0 +1,136 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.FeatureFlags; +using StackDuel.Application; +using StackDuel.Application.FeatureFlags.Dtos; +using StackDuel.Application.Services.FeatureFlags; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/feature-flag")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class FeatureFlagController(UserContext userContext, IFeatureFlagAdminService adminService) + : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetFlags() => Ok(userContext.Flags); + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAdminFlags( + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.GetAllAsync(cancellationToken)); + + [HttpGet("admin/{key}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminFlag( + string key, + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.GetByKeyAsync(key, cancellationToken)); + + [HttpPost("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> CreateFlag( + [FromBody] CreateFeatureFlagRequest request, + CancellationToken cancellationToken + ) => + this.ToActionResult( + await adminService.CreateAsync( + request.Key, + request.Name, + request.Description, + request.DefaultEnabled, + cancellationToken + ) + ); + + [HttpPut("admin/{id:guid}/default")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateDefault( + Guid id, + [FromBody] UpdateFeatureFlagDefaultRequest request, + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.UpdateDefaultAsync(id, request.DefaultEnabled, cancellationToken)); + + [HttpPut("admin/{id:guid}/rollout")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateRollout( + Guid id, + [FromBody] UpdateFeatureFlagRolloutRequest request, + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.UpdateRolloutAsync(id, request.RolloutPercentage, cancellationToken)); + + [HttpPut("admin/{id:guid}/user-overrides/{userId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetUserOverride( + Guid id, + Guid userId, + [FromBody] SetFeatureFlagOverrideRequest request, + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.SetUserOverrideAsync(id, userId, request.Effect, cancellationToken)); + + [HttpDelete("admin/{id:guid}/user-overrides/{userId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RemoveUserOverride(Guid id, Guid userId, CancellationToken cancellationToken) => + this.ToActionResult(await adminService.RemoveUserOverrideAsync(id, userId, cancellationToken)); + + [HttpPut("admin/{id:guid}/group-overrides/{groupId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetGroupOverride( + Guid id, + Guid groupId, + [FromBody] SetFeatureFlagOverrideRequest request, + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.SetGroupOverrideAsync(id, groupId, request.Effect, cancellationToken)); + + [HttpDelete("admin/{id:guid}/group-overrides/{groupId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageFeatureFlags)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RemoveGroupOverride(Guid id, Guid groupId, CancellationToken cancellationToken) => + this.ToActionResult(await adminService.RemoveGroupOverrideAsync(id, groupId, cancellationToken)); +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/FeedbackController.cs b/StackDuel.Api/Controllers/FeedbackController.cs new file mode 100644 index 0000000..53f2e83 --- /dev/null +++ b/StackDuel.Api/Controllers/FeedbackController.cs @@ -0,0 +1,115 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Feedback; +using StackDuel.Application; +using StackDuel.Application.Feedback.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Feedback; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class FeedbackController(IFeedbackService feedbackService, UserContext userContext) : ControllerBase +{ + [HttpPost] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateFeedback)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> SubmitFeedback( + [FromBody] SubmitFeedbackRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + string? pageUrl = Request.Headers.Referer.Count > 0 ? Request.Headers.Referer.ToString() : null; + string? userAgent = Request.Headers.UserAgent.Count > 0 ? Request.Headers.UserAgent.ToString() : null; + + return this.ToActionResult( + await feedbackService.SubmitFeedbackAsync( + new SubmitFeedbackDto( + userContext.User.Id, + request.Type, + request.Message, + request.Rating, + request.ContextType, + request.ContextEntityId, + pageUrl, + userAgent + ), + cancellationToken + ) + ); + } + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminFeedback)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAdminFeedback( + [FromQuery] GetAdminFeedbackPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await feedbackService.GetAdminFeedbackPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + query.Type, + query.Status, + cancellationToken + ) + ); + } + + [HttpGet("admin/{feedbackId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminFeedback)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminFeedbackDetail( + Guid feedbackId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await feedbackService.GetAdminFeedbackDetailAsync(feedbackId, cancellationToken)); + } + + [HttpPatch("admin/{feedbackId:guid}/status")] + [RequireUser] + [RequirePermission(WellKnownPermissions.UpdateAdminFeedback)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateFeedbackStatus( + Guid feedbackId, + [FromBody] UpdateFeedbackStatusRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await feedbackService.UpdateFeedbackStatusAsync( + feedbackId, + request.Status, + request.AdminNote, + cancellationToken + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/GameController.cs b/StackDuel.Api/Controllers/GameController.cs new file mode 100644 index 0000000..117a922 --- /dev/null +++ b/StackDuel.Api/Controllers/GameController.cs @@ -0,0 +1,378 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Game; +using StackDuel.Application; +using StackDuel.Application.Commands.Games.CompleteProblem; +using StackDuel.Application.Commands.Games.SkipProblem; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Games; +using StackDuel.Application.Tracks.Dtos; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class GameController(IGameService gameService, UserContext userContext) : ControllerBase +{ + [HttpPost] + [RequireUser] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CreateGame( + [FromBody] CreateGameRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + IReadOnlyList trackSelections = + [ + .. request.TrackSelections.Select(s => new TrackLanguageSelection(s.TrackKey, s.LanguageIds)), + ]; + + Result result = await gameService.CreateGameAsync( + request.GameModeKey, + trackSelections, + request.TimeLimitInSeconds, + userContext.User.Id, + request.SkipsEnabled, + cancellationToken + ); + + if (!result.IsSuccess) + return this.ToActionResult(result); + + return CreatedAtAction(nameof(CreateGame), new { gameId = result.Value }, result.Value); + } + + [HttpPost("{gameId:guid}/start")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task StartGame(Guid gameId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await gameService.StartGameAsync(gameId, userContext.User.Id, cancellationToken)); + } + + [HttpPost("{gameId:guid}/forfeit")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ForfeitGame(Guid gameId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await gameService.ForfeitGameAsync(gameId, userContext.User.Id, cancellationToken)); + } + + [HttpPost("{gameId:guid}/join")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task JoinGame(Guid gameId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await gameService.JoinGameAsync(gameId, userContext.User.Id, cancellationToken)); + } + + [HttpPost("join-by-code")] + [RequireUser] + [ProducesResponseType(typeof(Guid), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> JoinGameByCode( + [FromBody] JoinGameByCodeRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await gameService.JoinGameByCodeAsync(request.JoinCode, userContext.User.Id, cancellationToken) + ); + } + + [HttpPost("{gameId:guid}/leave")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task LeaveGame(Guid gameId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await gameService.LeaveGameAsync(gameId, userContext.User.Id, cancellationToken)); + } + + [HttpPost("{gameId:guid}/close")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task CloseLobby(Guid gameId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await gameService.CloseLobbyAsync(gameId, userContext.User.Id, cancellationToken)); + } + + [HttpPost("{gameId:guid}/problems/{problemId:guid}/submit")] + [RequireUser] + [EnableRateLimiting(WellKnownPolicies.Submissions)] + [ProducesResponseType(typeof(Guid), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SubmitGameProblem( + Guid gameId, + Guid problemId, + [FromBody] SubmitGameProblemRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await gameService.SubmitGameProblemAsync( + gameId, + problemId, + request.ProblemSetupId, + request.Code, + userContext.User.Id, + cancellationToken + ) + ); + } + + [HttpPost("{gameId:guid}/problems/{problemId:guid}/complete")] + [RequireUser] + [ProducesResponseType(typeof(CompleteProblemResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CompleteProblem( + Guid gameId, + Guid problemId, + [FromBody] CompleteProblemRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + var result = await gameService.CompleteProblemAsync( + gameId, + problemId, + request.SubmissionId, + userContext.User.Id, + cancellationToken + ); + + return this.ToActionResult(result); + } + + [HttpPost("{gameId:guid}/problems/{problemId:guid}/skip")] + [RequireUser] + [ProducesResponseType(typeof(SkipProblemResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SkipProblem( + Guid gameId, + Guid problemId, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + var result = await gameService.SkipProblemAsync(gameId, problemId, userContext.User.Id, cancellationToken); + + return this.ToActionResult(result); + } + + [HttpGet("{gameId:guid}")] + [RequireUser] + [ProducesResponseType(typeof(GameStateDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetGameState(Guid gameId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult(await gameService.GetGameStateAsync(gameId, userContext.User.Id, cancellationToken)); + } + + [HttpGet("{gameId:guid}/problems")] + [RequireUser] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetGameProblems( + Guid gameId, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result>.Unauthorized()); + + var result = await gameService.GetGameProblemsAsync(gameId, userContext.User.Id, cancellationToken); + return this.ToActionResult(result); + } + + [HttpGet("duel-record/{opponentId:guid}")] + [RequireUser] + [ProducesResponseType(typeof(DuelHeadToHeadRecordDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetDuelHeadToHeadRecord( + Guid opponentId, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await gameService.GetDuelHeadToHeadRecordAsync(userContext.User.Id, opponentId, cancellationToken) + ); + } + + [HttpGet("modes")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetGameModes(CancellationToken cancellationToken) + { + return this.ToActionResult(await gameService.GetGameModesAsync(cancellationToken)); + } + + [HttpGet("tracks")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> GetTracks(CancellationToken cancellationToken) + { + return this.ToActionResult(await gameService.GetTracksAsync(cancellationToken)); + } + + [HttpGet] + [RequireUser] + [ProducesResponseType(typeof(PageResult), StatusCodes.Status200OK)] + public async Task>> GetOpenGames( + [FromQuery] GetOpenGamesRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await gameService.GetOpenGamesAsync( + query.GameModeKey, + new PaginationRequest { Page = query.Page, Size = query.Size }, + userContext.User?.Id, + cancellationToken + ) + ); + } + + [HttpGet("mine")] + [RequireUser] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetMyActiveGames( + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result>.Unauthorized()); + + return this.ToActionResult(await gameService.GetMyActiveGamesAsync(userContext.User.Id, cancellationToken)); + } + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminGames)] + [ProducesResponseType(typeof(PageResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAdminGames( + [FromQuery] GetAdminGamesPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await gameService.GetAdminGamesPageableAsync( + query.Status, + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + cancellationToken + ) + ); + } + + [HttpGet("admin/{gameId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminGames)] + [ProducesResponseType(typeof(GameStateDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminGameDetail(Guid gameId, CancellationToken cancellationToken) + { + return this.ToActionResult(await gameService.GetAdminGameDetailAsync(gameId, cancellationToken)); + } + + [HttpGet("admin/{gameId:guid}/history")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminGames)] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetAdminGamePlayerHistory( + Guid gameId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await gameService.GetAdminGamePlayerHistoryAsync(gameId, cancellationToken)); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/GroupController.cs b/StackDuel.Api/Controllers/GroupController.cs new file mode 100644 index 0000000..b75ecd6 --- /dev/null +++ b/StackDuel.Api/Controllers/GroupController.cs @@ -0,0 +1,26 @@ +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Application.Groups.Dtos; +using StackDuel.Application.Services.Groups; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class GroupController(IGroupService groupService) : ControllerBase +{ + [HttpGet] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminUsers)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetGroups(CancellationToken cancellationToken) + { + return this.ToActionResult(await groupService.GetAllAsync(cancellationToken)); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/Judge0CallbackController.cs b/StackDuel.Api/Controllers/Judge0CallbackController.cs new file mode 100644 index 0000000..72b3835 --- /dev/null +++ b/StackDuel.Api/Controllers/Judge0CallbackController.cs @@ -0,0 +1,25 @@ +using Ardalis.Result.AspNetCore; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.RateLimiting; +using StackDuel.Application.Services.Submissions; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/judge0")] +[AllowAnonymous] +[EnableRateLimiting(WellKnownPolicies.Judge0Callback)] +public sealed class Judge0CallbackController(ISubmissionService submissionService) : ControllerBase +{ + [HttpPut("callback")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> ReceiveCallback( + [FromQuery(Name = "key")] string? key, + [FromQuery] Guid submissionId, + CancellationToken cancellationToken + ) => this.ToActionResult(await submissionService.ReceiveJudge0CallbackAsync(key, submissionId, cancellationToken)); +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/LanguageController.cs b/StackDuel.Api/Controllers/LanguageController.cs new file mode 100644 index 0000000..8c2ffeb --- /dev/null +++ b/StackDuel.Api/Controllers/LanguageController.cs @@ -0,0 +1,24 @@ +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.RateLimiting; +using StackDuel.Application.Languages.Dtos; +using StackDuel.Application.Services.Languages; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class LanguageController(ILanguageService languageService) : ControllerBase +{ + [HttpGet] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetLanguages(CancellationToken cancellationToken) + { + return this.ToActionResult(await languageService.GetAllAsync(cancellationToken)); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/LanguageServerController.cs b/StackDuel.Api/Controllers/LanguageServerController.cs new file mode 100644 index 0000000..31e90ae --- /dev/null +++ b/StackDuel.Api/Controllers/LanguageServerController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.LanguageServer; +using StackDuel.Api.Responses.LanguageServer; +using StackDuel.Application; +using StackDuel.Application.LanguageServer; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/language-servers")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class LanguageServerController(ILanguageServerSessionManager sessionManager, UserContext userContext) + : ControllerBase +{ + [HttpPost("sessions")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> StartSession( + [FromBody] StartLanguageServerSessionRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return Unauthorized(); + + LanguageServerSessionStartResult result = await sessionManager.StartSessionAsync( + userContext.User.Id, + request.Language, + cancellationToken + ); + + if (result.Error is not null) + { + return result.Error switch + { + LanguageServerSessionStartError.UnsupportedLanguage => BadRequest( + $"No language server is available for '{request.Language}'." + ), + LanguageServerSessionStartError.Disabled => StatusCode( + StatusCodes.Status503ServiceUnavailable, + "Language server support is disabled." + ), + LanguageServerSessionStartError.AtCapacity => StatusCode( + StatusCodes.Status503ServiceUnavailable, + "Language server capacity reached, try again shortly." + ), + _ => StatusCode(StatusCodes.Status503ServiceUnavailable), + }; + } + + return Ok(LanguageServerSessionResponse.FromSession(result.Session!)); + } + + [HttpDelete("sessions/{sessionId:guid}")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task EndSession(Guid sessionId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return Unauthorized(); + + if (sessionManager.TryGetSession(sessionId, userContext.User.Id) is not null) + await sessionManager.EndSessionAsync(sessionId, cancellationToken); + + return NoContent(); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/LeaderboardController.cs b/StackDuel.Api/Controllers/LeaderboardController.cs new file mode 100644 index 0000000..95f6aa4 --- /dev/null +++ b/StackDuel.Api/Controllers/LeaderboardController.cs @@ -0,0 +1,65 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Leaderboard; +using StackDuel.Application; +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Leaderboards; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class LeaderboardController(ILeaderboardService leaderboardService, UserContext userContext) + : ControllerBase +{ + [HttpGet] + [RequireFeature(WellKnownFeatures.Leaderboards)] + [ProducesResponseType(typeof(PageResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetLeaderboard( + [FromQuery] GetLeaderboardRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await leaderboardService.GetLeaderboardAsync( + query.GameModeKey, + query.TimeLimitInSeconds, + new PaginationRequest { Page = query.Page, Size = query.Size }, + userContext.User?.Id, + cancellationToken + ) + ); + } + + [HttpGet("me")] + [RequireUser] + [RequireFeature(WellKnownFeatures.Leaderboards)] + [ProducesResponseType(typeof(MyLeaderboardEntryDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMyLeaderboardEntry( + [FromQuery] GetMyLeaderboardEntryRequest query, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await leaderboardService.GetMyEntryAsync( + query.GameModeKey, + query.TimeLimitInSeconds, + userContext.User.Id, + cancellationToken + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/ProblemController.cs b/StackDuel.Api/Controllers/ProblemController.cs new file mode 100644 index 0000000..ad98f32 --- /dev/null +++ b/StackDuel.Api/Controllers/ProblemController.cs @@ -0,0 +1,377 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Problem; +using StackDuel.Application; +using StackDuel.Application.Commands.Problems.SetProblemSampleTestCases; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Services.Problems; +using StackDuel.Application.Services.Submissions; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class ProblemController( + IProblemService problemService, + ISubmissionService submissionService, + UserContext userContext +) : ControllerBase +{ + [HttpGet("{slug}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProblemBySlug( + string slug, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await problemService.GetProblemWithSetupsBySlug(slug, cancellationToken)); + } + + [HttpGet("by-id/{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProblemById(Guid id, CancellationToken cancellationToken) + { + return this.ToActionResult(await problemService.GetProblemWithSetupsById(id, cancellationToken)); + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetProblems( + [FromQuery] GetProblemsPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.GetProblemsPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + query.Search, + cancellationToken + ) + ); + } + + [HttpGet("{slug}/setup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProblemSetup( + string slug, + [FromQuery] Guid languageVersionId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.GetProblemSetupAsync(slug, languageVersionId, cancellationToken) + ); + } + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAdminProblems( + [FromQuery] GetAdminProblemsPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.GetAdminProblemsPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + query.Search, + cancellationToken + ) + ); + } + + [HttpGet("admin/{problemId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminProblemDetail( + Guid problemId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await problemService.GetAdminProblemDetailAsync(problemId, cancellationToken)); + } + + [HttpPut("admin/{problemId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateProblem( + Guid problemId, + [FromBody] UpdateProblemRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.UpdateProblemAsync( + problemId, + request.Title, + request.Question, + request.Difficulty, + request.TimeLimitMs, + request.MemoryLimitMb, + request.Tags, + request.Status, + cancellationToken + ) + ); + } + + [HttpPost("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateProblemDraft( + [FromBody] CreateProblemDraftRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.CreateProblemDraftAsync( + new CreateProblemDraftInput( + request.Title, + request.Question, + request.Difficulty, + request.TimeLimitMs, + request.MemoryLimitMb, + request.Tags, + request.TrackId + ), + cancellationToken + ) + ); + } + + [HttpPost("admin/{problemId:guid}/setups")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AddSetup( + Guid problemId, + [FromBody] AddProblemSetupRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.AddProblemSetupAsync(problemId, request.LanguageVersionId, cancellationToken) + ); + } + + [HttpPut("admin/{problemId:guid}/setups/reference-solution")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpsertSetupReferenceSolution( + Guid problemId, + [FromBody] UpsertProblemSetupReferenceSolutionRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.UpsertProblemSetupReferenceSolutionAsync( + problemId, + request.LanguageVersionId, + request.InitialCode, + request.FunctionName, + request.ReferenceSolutionCode, + cancellationToken + ) + ); + } + + [HttpPut("admin/{problemId:guid}/generation-parameters")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetGenerationParameters( + Guid problemId, + [FromBody] SetProblemGenerationParametersRequest request, + CancellationToken cancellationToken + ) + { + IReadOnlyList parameters = + [ + .. request.Parameters.Select(p => new GenerationParameterSpec( + p.Name, + p.ValueType, + p.Min, + p.Max, + p.LengthMin, + p.LengthMax, + p.Charset + )), + ]; + + return this.ToActionResult( + await problemService.SetProblemGenerationParametersAsync( + problemId, + parameters, + request.OutputValueType, + request.TargetCaseCount, + request.Seed, + cancellationToken + ) + ); + } + + [HttpPut("admin/{problemId:guid}/sample-test-cases")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetSampleTestCases( + Guid problemId, + [FromBody] SetProblemSampleTestCasesRequest request, + CancellationToken cancellationToken + ) + { + IReadOnlyList testCases = + [ + .. request.TestCases.Select(tc => new SampleTestCaseDto( + tc.Name, + [.. tc.Inputs.Select(i => new SampleTestCaseInputDto(i.Value, i.ValueType))], + tc.ExpectedOutputValue, + tc.ExpectedOutputValueType + )), + ]; + + return this.ToActionResult( + await problemService.SetProblemSampleTestCasesAsync(problemId, testCases, cancellationToken) + ); + } + + [HttpPost("admin/{problemId:guid}/submit-for-validation")] + [RequireUser] + [RequirePermission(WellKnownPermissions.SubmitAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SubmitForValidation(Guid problemId, CancellationToken cancellationToken) + { + return this.ToActionResult(await problemService.SubmitProblemForValidationAsync(problemId, cancellationToken)); + } + + [HttpGet("{problemId:guid}/reaction")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProblemReaction( + Guid problemId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemService.GetProblemReactionSummaryAsync(problemId, userContext.User?.Id, cancellationToken) + ); + } + + [HttpPost("{problemId:guid}/reaction")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SetProblemReaction( + Guid problemId, + [FromBody] SetProblemReactionRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await problemService.SetProblemReactionAsync( + problemId, + userContext.User.Id, + request.ReactionTypeKey, + cancellationToken + ) + ); + } + + [HttpGet("{slug}/submissions")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ViewSubmission)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetSubmissions( + string slug, + [FromQuery] GetProblemSubmissionsRequest query, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await submissionService.GetSubmissionsByProblemSlugAsync( + slug, + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + userContext.User.Id, + query.Type, + query.SortBy, + cancellationToken + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/ProblemPoolController.cs b/StackDuel.Api/Controllers/ProblemPoolController.cs new file mode 100644 index 0000000..32c6b27 --- /dev/null +++ b/StackDuel.Api/Controllers/ProblemPoolController.cs @@ -0,0 +1,182 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Problem; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Services.Problems; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +[RequireUser] +[RequirePermission(WellKnownPermissions.ReadAdminProblems)] +public sealed class ProblemPoolController(IProblemPoolService problemPoolService) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetPools(CancellationToken cancellationToken) + { + return this.ToActionResult(await problemPoolService.GetProblemPoolsAsync(cancellationToken)); + } + + [HttpGet("{poolKey}/problems")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetPoolMembers( + string poolKey, + [FromQuery] GetProblemPoolMembersPagedRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.GetProblemPoolMembersPagedAsync( + poolKey, + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + cancellationToken + ) + ); + } + + [HttpGet("{poolKey}/problems/ids")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetPoolMemberIds( + string poolKey, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await problemPoolService.GetProblemPoolMemberIdsAsync(poolKey, cancellationToken)); + } + + [HttpGet("{poolKey}/problems/ordered")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetOrderedPoolMembers( + string poolKey, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.GetOrderedProblemPoolMembersAsync(poolKey, cancellationToken) + ); + } + + [HttpPost] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreatePool( + [FromBody] CreateProblemPoolRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.CreateProblemPoolAsync( + request.Key, + request.Name, + request.Description, + cancellationToken + ) + ); + } + + [HttpPut("{poolKey}/problems/{problemId:guid}")] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AddProblem( + string poolKey, + Guid problemId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.AddProblemToPoolAsync(poolKey, problemId, cancellationToken) + ); + } + + [HttpPost("{poolKey}/problems/bulk")] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> AddProblems( + string poolKey, + [FromBody] AddProblemsToPoolRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.AddProblemsToPoolAsync( + poolKey, + request.ProblemIds, + request.SelectAllMatching, + request.Search, + request.ExcludedProblemIds, + cancellationToken + ) + ); + } + + [HttpPut("{poolKey}/problems/reorder")] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ReorderPool( + string poolKey, + [FromBody] ReorderProblemPoolRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.ReorderProblemPoolAsync(poolKey, request.ProblemIds, cancellationToken) + ); + } + + [HttpDelete("{poolKey}/problems/{problemId:guid}")] + [RequirePermission(WellKnownPermissions.UpdateAdminProblems)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> RemoveProblem( + string poolKey, + Guid problemId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await problemPoolService.RemoveProblemFromPoolAsync(poolKey, problemId, cancellationToken) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/RequiredProblemLanguageController.cs b/StackDuel.Api/Controllers/RequiredProblemLanguageController.cs new file mode 100644 index 0000000..ca1f239 --- /dev/null +++ b/StackDuel.Api/Controllers/RequiredProblemLanguageController.cs @@ -0,0 +1,48 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Problem.RequiredLanguages; +using StackDuel.Application.Problems.RequiredLanguages.Dtos; +using StackDuel.Application.Services.Problems; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/problem-required-language")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class RequiredProblemLanguageController(IRequiredProblemLanguageAdminService adminService) + : ControllerBase +{ + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageRequiredProblemLanguages)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAll( + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.GetAllAsync(cancellationToken)); + + [HttpPost("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageRequiredProblemLanguages)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Add( + [FromBody] AddRequiredProblemLanguageRequest request, + CancellationToken cancellationToken + ) => this.ToActionResult(await adminService.AddAsync(request.LanguageVersionId, cancellationToken)); + + [HttpDelete("admin/{id:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ManageRequiredProblemLanguages)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Remove(Guid id, CancellationToken cancellationToken) => + this.ToActionResult(await adminService.RemoveAsync(id, cancellationToken)); +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/SubmissionController.cs b/StackDuel.Api/Controllers/SubmissionController.cs new file mode 100644 index 0000000..404d2e3 --- /dev/null +++ b/StackDuel.Api/Controllers/SubmissionController.cs @@ -0,0 +1,139 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.Submission; +using StackDuel.Application; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Submissions; +using StackDuel.Application.Submissions.Dtos; +using System.Linq; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.Submissions)] +public sealed class SubmissionController(ISubmissionService submissionService, UserContext userContext) : ControllerBase +{ + [HttpPost("run")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateSubmission)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> CreateRunSubmission( + [FromBody] CreateRunSubmissionRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await submissionService.CreateSubmissionAsync( + new CreateSubmissionDto( + request.ProblemSetupId, + Domain.Submissions.Enums.SubmissionType.Run, + request.Code, + userContext.User.Id, + request.CustomTestCases?.Select(tc => new CreateSubmissionCustomTestCaseDto(tc.Inputs)).ToArray(), + request.AdditionalFiles?.Select(f => new CreateSubmissionFileDto(f.Path, f.Content)).ToArray() + ), + cancellationToken + ) + ); + } + + [HttpPost("grade")] + [RequireUser] + [RequirePermission(WellKnownPermissions.CreateSubmission)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> CreateGradeSubmission( + [FromBody] CreateGradeSubmissionRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await submissionService.CreateSubmissionAsync( + new CreateSubmissionDto( + request.ProblemSetupId, + Domain.Submissions.Enums.SubmissionType.Submit, + request.Code, + userContext.User.Id, + null, + request.AdditionalFiles?.Select(f => new CreateSubmissionFileDto(f.Path, f.Content)).ToArray() + ), + cancellationToken + ) + ); + } + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminSubmissions)] + [EnableRateLimiting(WellKnownPolicies.General)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAdminSubmissions( + [FromQuery] GetAdminSubmissionsPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await submissionService.GetAdminSubmissionsPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + query.Id, + cancellationToken + ) + ); + } + + [HttpGet("admin/{submissionId:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminSubmissions)] + [EnableRateLimiting(WellKnownPolicies.General)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminSubmissionDetail( + Guid submissionId, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await submissionService.GetAdminSubmissionDetailAsync(submissionId, cancellationToken) + ); + } + + [HttpGet("{submissionId:guid}")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSubmissionStatus( + Guid submissionId, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await submissionService.GetSubmissionStatusAsync(submissionId, userContext.User.Id, cancellationToken) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Controllers/UserController.cs b/StackDuel.Api/Controllers/UserController.cs new file mode 100644 index 0000000..84f5d87 --- /dev/null +++ b/StackDuel.Api/Controllers/UserController.cs @@ -0,0 +1,210 @@ +using Ardalis.Result; +using Ardalis.Result.AspNetCore; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Attributes; +using StackDuel.Api.Authorization; +using StackDuel.Api.RateLimiting; +using StackDuel.Api.Requests.User; +using StackDuel.Api.Responses.User; +using StackDuel.Application; +using StackDuel.Application.Pagination; +using StackDuel.Application.Services.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Application.Users.Dtos.Admin; +using System.Security.Claims; + +namespace StackDuel.Api.Controllers; + +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[EnableRateLimiting(WellKnownPolicies.General)] +public sealed class UserController(IUserService userService, UserContext userContext) : ControllerBase +{ + [HttpGet] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetAccount() + { + if (userContext.User is null) + return NotFound(); + + return Ok(UserResponse.FromDto(userContext.User, userContext.Permissions, userContext.Roles)); + } + + [HttpPut] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpsertAccount( + [FromBody] UpsertUserRequest request, + CancellationToken cancellationToken + ) + { + string? sub = GetSub(); + + if (string.IsNullOrEmpty(sub)) + { + return this.ToActionResult(Result.Invalid(new ValidationError("sub", "User sub is missing"))); + } + + return this.ToActionResult( + await userService.UpsertAccountAsync( + sub, + new UpsertUserDto( + request.Username, + request.Picture, + request.Bio, + request.LanguageIds, + request.TenantId + ), + cancellationToken + ) + ); + } + + [HttpGet("profile/{username}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetProfile(string username, CancellationToken cancellationToken) + { + return this.ToActionResult( + await userService.GetProfileByUsernameAsync(username, userContext.User?.Id, cancellationToken) + ); + } + + [HttpGet("profile/{username}/stats")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetGameStats( + string username, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await userService.GetGameStatsByUsernameAsync(username, userContext.User?.Id, cancellationToken) + ); + } + + [HttpPut("profile-privacy")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task UpdateProfilePrivacy( + [FromBody] UpdateProfilePrivacyRequest request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await userService.UpdateProfilePrivacyAsync(userContext.User.Id, request.IsPrivate, cancellationToken) + ); + } + + [HttpPost("avatar")] + [RequireUser] + [Consumes("multipart/form-data")] + [RequestSizeLimit(5_000_000)] + [EnableRateLimiting(WellKnownPolicies.AvatarUpload)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> UploadAvatar(IFormFile file, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + await using var stream = new MemoryStream(); + await file.CopyToAsync(stream, cancellationToken); + + return this.ToActionResult( + await userService.UploadAvatarAsync(userContext.User.Id, stream.ToArray(), cancellationToken) + ); + } + + [HttpGet("avatar-history")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAvatarHistory(CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result>.Unauthorized()); + + return this.ToActionResult(await userService.GetAvatarHistoryAsync(userContext.User.Id, cancellationToken)); + } + + [HttpPut("avatar/{avatarId:guid}")] + [RequireUser] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SelectAvatar(Guid avatarId, CancellationToken cancellationToken) + { + if (userContext.User is null) + return this.ToActionResult(Result.Unauthorized()); + + return this.ToActionResult( + await userService.SelectAvatarAsync(userContext.User.Id, avatarId, cancellationToken) + ); + } + + [HttpGet("admin")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminUsers)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetAdminUsers( + [FromQuery] GetAdminUsersPageableRequest query, + CancellationToken cancellationToken + ) + { + return this.ToActionResult( + await userService.GetAdminUsersPageableAsync( + new PaginationRequest + { + Page = query.Page, + Size = query.Size, + Timestamp = query.Timestamp, + }, + query.Search, + cancellationToken + ) + ); + } + + [HttpGet("admin/{id:guid}")] + [RequireUser] + [RequirePermission(WellKnownPermissions.ReadAdminUsers)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAdminUserDetail(Guid id, CancellationToken cancellationToken) + { + return this.ToActionResult(await userService.GetAdminUserDetailAsync(id, cancellationToken)); + } + + [HttpPut("{id:guid}/groups")] + [RequireUser] + [RequirePermission(WellKnownPermissions.UpdateAdminUserGroups)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateUserGroups( + Guid id, + [FromBody] UpdateUserGroupsRequest request, + CancellationToken cancellationToken + ) + { + return this.ToActionResult(await userService.UpdateUserGroupsAsync(id, request.GroupIds, cancellationToken)); + } + + private string? GetSub() => User.FindFirst(ClaimTypes.NameIdentifier)?.Value; +} \ No newline at end of file diff --git a/StackDuel.Api/Core/TimeStringParser.cs b/StackDuel.Api/Core/TimeStringParser.cs new file mode 100644 index 0000000..e9357c2 --- /dev/null +++ b/StackDuel.Api/Core/TimeStringParser.cs @@ -0,0 +1,24 @@ +namespace StackDuel.Api.Core; + +public static class TimeStringParser +{ + /// Parses shorthand time strings into seconds and TimeSpan. + /// Supports: "30s", "5m", "2h" + public static int ToSeconds(string input) + { + input = input.Trim().ToLowerInvariant(); + + if (input.EndsWith('s') && int.TryParse(input[..^1], out int secs)) + return secs; + + if (input.EndsWith('m') && int.TryParse(input[..^1], out int mins)) + return mins * 60; + + if (input.EndsWith('h') && int.TryParse(input[..^1], out int hrs)) + return hrs * 3600; + + throw new ArgumentException($"Unrecognised time format '{input}'. Use '30s', '5m', or '2h'."); + } + + public static TimeSpan ToTimeSpan(string input) => TimeSpan.FromSeconds(ToSeconds(input)); +} \ No newline at end of file diff --git a/StackDuel.Api/Extensions/ApplicationInsightsExtensions.cs b/StackDuel.Api/Extensions/ApplicationInsightsExtensions.cs new file mode 100644 index 0000000..a58f90c --- /dev/null +++ b/StackDuel.Api/Extensions/ApplicationInsightsExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.ApplicationInsights.AspNetCore.Extensions; + +namespace StackDuel.Api.Extensions; + +public static class ApplicationInsightsExtensions +{ + public static IServiceCollection AddAppInsights(this IServiceCollection services, IConfiguration configuration) + { + string? connectionString = configuration["ApplicationInsights:ConnectionString"]; + if (string.IsNullOrWhiteSpace(connectionString)) + { + connectionString = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + } + + if (string.IsNullOrWhiteSpace(connectionString)) + { + return services; + } + + services.AddApplicationInsightsTelemetry( + new ApplicationInsightsServiceOptions { ConnectionString = connectionString } + ); + return services; + } +} \ No newline at end of file diff --git a/StackDuel.Api/Extensions/AuthenticationExtensions.cs b/StackDuel.Api/Extensions/AuthenticationExtensions.cs new file mode 100644 index 0000000..b522703 --- /dev/null +++ b/StackDuel.Api/Extensions/AuthenticationExtensions.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using StackDuel.Api.Settings; + +namespace StackDuel.Api.Extensions; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddAuth0(this IServiceCollection services, IConfiguration configuration) + { + var auth0Options = + configuration.GetSection(Auth0Options.SectionName).Get() + ?? throw new InvalidOperationException( + $"Configuration section '{Auth0Options.SectionName}' is missing or invalid." + ); + + services.AddSingleton(auth0Options); + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = $"https://{auth0Options.Domain}/"; + options.Audience = auth0Options.Audience; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = true, + ValidateIssuerSigningKey = true, + }; + + // Browsers can't set the Authorization header on the WebSocket/SSE requests + // SignalR uses, so the client passes the access token as a query string + // parameter instead for connections to the hub path. Everywhere else, normal + // Authorization-header validation is untouched. + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + StringValues accessToken = context.Request.Query["access_token"]; + PathString path = context.HttpContext.Request.Path; + + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + }, + }; + }); + + return services; + } +} \ No newline at end of file diff --git a/StackDuel.Api/Hubs/GameHub.cs b/StackDuel.Api/Hubs/GameHub.cs new file mode 100644 index 0000000..c5b0cde --- /dev/null +++ b/StackDuel.Api/Hubs/GameHub.cs @@ -0,0 +1,124 @@ +using Ardalis.Result; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Notifications; +using StackDuel.Application.Queries.Games.GetGameState; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Games; +using System.Security.Claims; + +namespace StackDuel.Api.Hubs; + +/// +/// Pushes game-lifecycle events (currently: completion, progress, quick reactions) to clients +/// watching a specific game. Clients join a per-game group via once +/// connected; the server-side broadcast () +/// targets that group whenever something happens, regardless of which code path triggered it +/// (message consumer, sweep-job backstop, forfeit, or — for reactions — this Hub directly). +/// +/// This is purely a latency optimization / ephemeral live signal — nothing about game correctness +/// depends on it, so failures here are logged, not thrown as fatal. +/// +[Authorize] +public sealed partial class GameHub( + IMediator mediator, + IUserService userService, + IGameNotificationService gameNotificationService, + ILogger logger +) : Hub +{ + public static string GroupNameFor(Guid gameId) => $"game:{gameId}"; + + /// + /// Subscribes the caller's connection to updates for the given game. Reuses the same + /// GetGameStateQuery the REST endpoint uses so group membership requires the same + /// participant/permission check — a client can't listen in on a game it couldn't otherwise + /// fetch. + /// + public async Task JoinGame(Guid gameId) + { + Guid? userId = await ResolveUserIdAsync(); + if (userId is null) + { + throw new HubException("Unable to resolve the caller's account."); + } + + Result result = await GetGameStateForCallerAsync(gameId, userId.Value); + + if (!result.IsSuccess) + { + LogJoinDenied(gameId, userId.Value, result.Status.ToString()); + throw new HubException("You don't have access to that game."); + } + + await Groups.AddToGroupAsync(Context.ConnectionId, GroupNameFor(gameId), Context.ConnectionAborted); + } + + public async Task LeaveGame(Guid gameId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupNameFor(gameId), Context.ConnectionAborted); + } + + /// + /// Sends a quick emoji reaction to whoever's watching the game. Ephemeral and player-initiated + /// rather than a byproduct of persisted state — there's nothing to store or query back — so + /// this is a direct Hub method with its own inline authorization check, same reasoning as + /// , rather than a MediatR command. + /// + public async Task SendReaction(Guid gameId, string emoji) + { + Guid? userId = await ResolveUserIdAsync(); + if (userId is null) + { + throw new HubException("Unable to resolve the caller's account."); + } + + if (!GameReactionEmojis.Allowed.Contains(emoji)) + { + throw new HubException("Unsupported reaction."); + } + + Result result = await GetGameStateForCallerAsync(gameId, userId.Value); + + if (!result.IsSuccess) + { + LogReactionDenied(gameId, userId.Value, result.Status.ToString()); + throw new HubException("You don't have access to that game."); + } + + await gameNotificationService.NotifyGameParticipantReactedAsync( + gameId, + userId.Value, + emoji, + DateTime.UtcNow, + Context.ConnectionAborted + ); + } + + private async Task> GetGameStateForCallerAsync(Guid gameId, Guid userId) => + await mediator.Send(new GetGameStateQuery(gameId, userId), Context.ConnectionAborted); + + private async Task ResolveUserIdAsync() + { + string? sub = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(sub)) + return null; + + var result = await userService.GetBySubAsync(sub, Context.ConnectionAborted); + return result.IsSuccess ? result.Value.Id : null; + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Connection denied join to game {GameId} group for user {UserId}: {Status}." + )] + private partial void LogJoinDenied(Guid gameId, Guid userId, string status); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Reaction denied for game {GameId} from user {UserId}: {Status}." + )] + private partial void LogReactionDenied(Guid gameId, Guid userId, string status); +} \ No newline at end of file diff --git a/StackDuel.Api/Hubs/SignalRGameNotificationService.cs b/StackDuel.Api/Hubs/SignalRGameNotificationService.cs new file mode 100644 index 0000000..8972c0e --- /dev/null +++ b/StackDuel.Api/Hubs/SignalRGameNotificationService.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.SignalR; +using StackDuel.Application.Notifications; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Api.Hubs; + +internal sealed class SignalRGameNotificationService(IHubContext hubContext) : IGameNotificationService +{ + public const string GameCompletedMethod = "GameCompleted"; + public const string GameLobbyUpdatedMethod = "GameLobbyUpdated"; + public const string GameProgressUpdatedMethod = "GameProgressUpdated"; + public const string GameParticipantAttemptedMethod = "GameParticipantAttempted"; + public const string GameParticipantReactedMethod = "GameParticipantReacted"; + + public Task NotifyGameCompletedAsync( + Guid gameId, + GameStatus status, + DateTime endedAt, + CancellationToken cancellationToken = default + ) + { + var payload = new GameCompletedNotification(gameId, status, endedAt); + + return hubContext + .Clients.Group(GameHub.GroupNameFor(gameId)) + .SendAsync(GameCompletedMethod, payload, cancellationToken); + } + + public Task NotifyGameLobbyUpdatedAsync(Guid gameId, CancellationToken cancellationToken = default) + { + var payload = new GameLobbyUpdatedNotification(gameId); + + return hubContext + .Clients.Group(GameHub.GroupNameFor(gameId)) + .SendAsync(GameLobbyUpdatedMethod, payload, cancellationToken); + } + + public Task NotifyGameProgressUpdatedAsync(Guid gameId, CancellationToken cancellationToken = default) + { + var payload = new GameProgressUpdatedNotification(gameId); + + return hubContext + .Clients.Group(GameHub.GroupNameFor(gameId)) + .SendAsync(GameProgressUpdatedMethod, payload, cancellationToken); + } + + public Task NotifyGameParticipantAttemptedAsync( + Guid gameId, + Guid userId, + SubmissionStatus status, + DateTime attemptedAt, + CancellationToken cancellationToken = default + ) + { + var payload = new GameParticipantAttemptedNotification(gameId, userId, status, attemptedAt); + + return hubContext + .Clients.Group(GameHub.GroupNameFor(gameId)) + .SendAsync(GameParticipantAttemptedMethod, payload, cancellationToken); + } + + public Task NotifyGameParticipantReactedAsync( + Guid gameId, + Guid userId, + string emoji, + DateTime sentAt, + CancellationToken cancellationToken = default + ) + { + var payload = new GameParticipantReactedNotification(gameId, userId, emoji, sentAt); + + return hubContext + .Clients.Group(GameHub.GroupNameFor(gameId)) + .SendAsync(GameParticipantReactedMethod, payload, cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Hubs/SignalRSubmissionNotificationService.cs b/StackDuel.Api/Hubs/SignalRSubmissionNotificationService.cs new file mode 100644 index 0000000..4ce818e --- /dev/null +++ b/StackDuel.Api/Hubs/SignalRSubmissionNotificationService.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.SignalR; +using StackDuel.Application.Notifications; + +namespace StackDuel.Api.Hubs; + +internal sealed class SignalRSubmissionNotificationService(IHubContext hubContext) + : ISubmissionNotificationService +{ + public const string SubmissionCompletedMethod = "SubmissionCompleted"; + + public Task NotifySubmissionCompletedAsync( + Guid userId, + Guid submissionId, + CancellationToken cancellationToken = default + ) + { + var payload = new { submissionId }; + return hubContext + .Clients.Group(SubmissionHub.UserGroupName(userId)) + .SendAsync(SubmissionCompletedMethod, payload, cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Api/Hubs/SubmissionHub.cs b/StackDuel.Api/Hubs/SubmissionHub.cs new file mode 100644 index 0000000..1336fd9 --- /dev/null +++ b/StackDuel.Api/Hubs/SubmissionHub.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using StackDuel.Application.Services.Users; +using System.Security.Claims; + +namespace StackDuel.Api.Hubs; + +/// +/// Pushes submission lifecycle events to the submitting user. +/// On connect each authenticated user is automatically added to a personal +/// user:{userId} group; the server-side broadcast +/// () targets that group +/// whenever any of the user's submissions reach a terminal state. +/// +/// Kept separate from so submission notifications work on any page +/// (standalone problem editor, game workspace, etc.) without coupling to game logic. +/// +[Authorize] +public sealed class SubmissionHub(IUserService userService) : Hub +{ + public static string UserGroupName(Guid userId) => $"user:{userId}"; + + public override async Task OnConnectedAsync() + { + Guid? userId = await ResolveUserIdAsync(); + if (userId.HasValue) + await Groups.AddToGroupAsync(Context.ConnectionId, UserGroupName(userId.Value), Context.ConnectionAborted); + + await base.OnConnectedAsync(); + } + + private async Task ResolveUserIdAsync() + { + string? sub = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(sub)) + return null; + + var result = await userService.GetBySubAsync(sub, Context.ConnectionAborted); + return result.IsSuccess ? result.Value.Id : null; + } +} \ No newline at end of file diff --git a/StackDuel.Api/LanguageServer/LanguageServerBridgeEndpoint.cs b/StackDuel.Api/LanguageServer/LanguageServerBridgeEndpoint.cs new file mode 100644 index 0000000..fe5df4c --- /dev/null +++ b/StackDuel.Api/LanguageServer/LanguageServerBridgeEndpoint.cs @@ -0,0 +1,163 @@ +using StackDuel.Application.LanguageServer; +using StackDuel.Application.Services.Users; +using System.Net.WebSockets; +using System.Security.Claims; +using System.Text; + +namespace StackDuel.Api.LanguageServer; + +internal static class LanguageServerBridgeEndpoint +{ + public static async Task HandleAsync( + HttpContext context, + ILanguageServerSessionManager sessionManager, + IUserService userService + ) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + if (!Guid.TryParse(context.Request.Query["sessionId"], out Guid sessionId)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + string? sub = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(sub)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var userResult = await userService.GetBySubAsync(sub, context.RequestAborted); + if (!userResult.IsSuccess) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + Guid userId = userResult.Value.Id; + + ILanguageServerProcess? process = sessionManager.TryAttach(sessionId, userId); + if (process is null) + { + context.Response.StatusCode = StatusCodes.Status409Conflict; + return; + } + + WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(); + + try + { + await PumpAsync(socket, process, sessionManager, sessionId, context.RequestAborted); + } + finally + { + sessionManager.Detach(sessionId); + socket.Dispose(); + } + } + + private static async Task PumpAsync( + WebSocket socket, + ILanguageServerProcess process, + ILanguageServerSessionManager sessionManager, + Guid sessionId, + CancellationToken requestAborted + ) + { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(requestAborted); + + Task stdioToWs = PumpStdioToWebSocketAsync(process, socket, sessionManager, sessionId, cts.Token); + Task wsToStdio = PumpWebSocketToStdioAsync(socket, process, sessionManager, sessionId, cts.Token); + + await Task.WhenAny(stdioToWs, wsToStdio, process.Completion); + await cts.CancelAsync(); + + if (socket.State == WebSocketState.Open) + { + try + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "closing", CancellationToken.None); + } + catch (WebSocketException) { } + } + } + + private static async Task PumpStdioToWebSocketAsync( + ILanguageServerProcess process, + WebSocket socket, + ILanguageServerSessionManager sessionManager, + Guid sessionId, + CancellationToken cancellationToken + ) + { + while (!cancellationToken.IsCancellationRequested) + { + string? message; + try + { + message = await LspFraming.ReadMessageAsync(process.Output, cancellationToken); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + return; + } + + if (message is null) + return; + + sessionManager.Touch(sessionId); + byte[] bytes = Encoding.UTF8.GetBytes(message); + + await socket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken + ); + } + } + + private static async Task PumpWebSocketToStdioAsync( + WebSocket socket, + ILanguageServerProcess process, + ILanguageServerSessionManager sessionManager, + Guid sessionId, + CancellationToken cancellationToken + ) + { + byte[] buffer = new byte[64 * 1024]; + + while (!cancellationToken.IsCancellationRequested) + { + using MemoryStream messageBuffer = new(); + WebSocketReceiveResult result; + + do + { + result = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + return; + + messageBuffer.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + string message = Encoding.UTF8.GetString(messageBuffer.ToArray()); + sessionManager.Touch(sessionId); + + try + { + await LspFraming.WriteMessageAsync(process.Input, message, cancellationToken); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + return; + } + } + } +} \ No newline at end of file diff --git a/StackDuel.Api/LoggingEventIds.cs b/StackDuel.Api/LoggingEventIds.cs new file mode 100644 index 0000000..ec379e7 --- /dev/null +++ b/StackDuel.Api/LoggingEventIds.cs @@ -0,0 +1,17 @@ +namespace StackDuel.Api; + +internal static class LoggingEventIds +{ + internal static class Accounts + { + public const int ContextMissingSub = 1000; + public const int ContextResolveFailed = 1001; + public const int ContextPublicEndpoint = 1002; + } + + internal static class Exceptions + { + public const int UnhandledException = 5000; + public const int UnhandledExceptionWithPath = 5001; + } +} \ No newline at end of file diff --git a/StackDuel.Api/Middleware/UseGlobalExceptionHandler.cs b/StackDuel.Api/Middleware/UseGlobalExceptionHandler.cs new file mode 100644 index 0000000..a430881 --- /dev/null +++ b/StackDuel.Api/Middleware/UseGlobalExceptionHandler.cs @@ -0,0 +1,73 @@ +using Microsoft.ApplicationInsights; +using Microsoft.AspNetCore.Diagnostics; +using System.Text.Json; + +namespace StackDuel.Api.Middleware; + +public static class ExceptionMiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandler(this IApplicationBuilder app) + { + app.UseExceptionHandler(builder => + { + builder.Run(async context => + { + var logger = context + .RequestServices.GetRequiredService() + .CreateLogger("GlobalExceptionHandler"); + + var telemetryClient = context.RequestServices.GetService(); + + var feature = context.Features.Get(); + var ex = feature?.Error; + + context.Response.ContentType = "application/json"; + + if (ex is FluentValidation.ValidationException) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + + AppLog.UnhandledExceptionWithPath(logger, context.Request.Method, context.Request.Path, ex); + telemetryClient?.TrackException(ex); + + await context.Response.WriteAsync(JsonSerializer.Serialize(new { message = "Validation failed" })); + return; + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + + var exceptionToLog = ex ?? new Exception("Unknown error"); + AppLog.UnhandledExceptionWithPath(logger, context.Request.Method, context.Request.Path, exceptionToLog); + telemetryClient?.TrackException(exceptionToLog); + + await context.Response.WriteAsync( + JsonSerializer.Serialize(new { message = "An unexpected error occurred." }) + ); + }); + }); + + return app; + } +} + +public static partial class AppLog +{ + [LoggerMessage( + EventId = LoggingEventIds.Exceptions.UnhandledException, + Level = LogLevel.Error, + Message = "Unhandled exception." + )] + public static partial void UnhandledException(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = LoggingEventIds.Exceptions.UnhandledExceptionWithPath, + Level = LogLevel.Error, + Message = "Unhandled exception for {Method} {Path}." + )] + public static partial void UnhandledExceptionWithPath( + ILogger logger, + string method, + string path, + Exception exception + ); +} \ No newline at end of file diff --git a/StackDuel.Api/Middleware/UserContextMiddleware.cs b/StackDuel.Api/Middleware/UserContextMiddleware.cs new file mode 100644 index 0000000..0407b9d --- /dev/null +++ b/StackDuel.Api/Middleware/UserContextMiddleware.cs @@ -0,0 +1,96 @@ +using Microsoft.ApplicationInsights.DataContracts; +using StackDuel.Api.Attributes; +using StackDuel.Application; +using StackDuel.Application.Services.FeatureFlags; +using StackDuel.Application.Services.Users; +using System.Security.Claims; + +namespace StackDuel.Api.Middleware; + +public partial class AccountContextMiddleware( + IUserService userService, + IFeatureFlagResolutionService featureFlagResolutionService, + UserContext userContext, + ILogger logger +) : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var endpoint = context.GetEndpoint(); + bool requiresUser = endpoint?.Metadata.GetMetadata() != null; + + bool isAuthenticated = context.User.Identity?.IsAuthenticated == true; + string? sub = isAuthenticated ? context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value : null; + + Guid? userId = null; + + if (requiresUser && !string.IsNullOrEmpty(sub)) + { + var result = await userService.GetAccessContextBySubAsync(sub, context.RequestAborted); + if (result.IsSuccess) + { + userContext.User = result.Value.User; + userContext.Permissions = result.Value.Permissions; + userContext.Roles = result.Value.Roles; + userId = result.Value.User.Id; + + var requestTelemetry = context.Features.Get(); + if (requestTelemetry is not null) + { + requestTelemetry.Properties.TryAdd("account.id", userContext.User.Id.ToString()); + requestTelemetry.Properties.TryAdd("account.username", userContext.User.Username); + requestTelemetry.Properties.TryAdd("account.roles", string.Join(", ", userContext.Roles)); + } + } + else + { + LogResolveFailed(sub, context.Request.Path, string.Join(", ", result.Errors)); + } + } + else if (requiresUser) + { + LogMissingSub(context.Request.Path); + } + else + { + LogPublicEndpoint(context.Request.Path); + + if (!string.IsNullOrEmpty(sub)) + { + var result = await userService.GetAccessContextBySubAsync(sub, context.RequestAborted); + if (result.IsSuccess) + { + userContext.User = result.Value.User; + userContext.Permissions = result.Value.Permissions; + userContext.Roles = result.Value.Roles; + userId = result.Value.User.Id; + } + } + } + + userContext.Flags = await featureFlagResolutionService.GetFlagsForViewerAsync(userId, context.RequestAborted); + + await next(context); + } + + [LoggerMessage( + EventId = LoggingEventIds.Accounts.ContextMissingSub, + Level = LogLevel.Warning, + Message = "Account context: missing sub claim on [RequiresUser] endpoint {path}" + )] + private partial void LogMissingSub(string path); + + [LoggerMessage( + EventId = LoggingEventIds.Accounts.ContextResolveFailed, + Level = LogLevel.Warning, + Message = "Account context: failed to resolve account for sub {sub} on {path}: {errors}" + )] + private partial void LogResolveFailed(string sub, string path, string errors); + + [LoggerMessage( + EventId = LoggingEventIds.Accounts.ContextPublicEndpoint, + Level = LogLevel.Debug, + Message = "Account context: public endpoint {path}, skipping user resolution" + )] + private partial void LogPublicEndpoint(string path); +} \ No newline at end of file diff --git a/StackDuel.Api/Program.cs b/StackDuel.Api/Program.cs index 7688653..5cd355e 100644 --- a/StackDuel.Api/Program.cs +++ b/StackDuel.Api/Program.cs @@ -1,23 +1,25 @@ -var builder = WebApplication.CreateBuilder(args); +using StackDuel.Api; +using StackDuel.Api.Middleware; +using StackDuel.Application; +using StackDuel.Infrastructure; -// Add services to the container. +var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +builder.Services.AddApi(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} +app.UseApiCors(); +app.UseGlobalExceptionHandler(); -app.UseHttpsRedirection(); +await app.UseApi(); -app.UseAuthorization(); +app.MapGet("/api/v1/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })) + .AllowAnonymous() + .ExcludeFromDescription(); -app.MapControllers(); +app.MapGet("/", () => Results.Ok()).AllowAnonymous().ExcludeFromDescription(); app.Run(); \ No newline at end of file diff --git a/StackDuel.Api/RateLimiting/RateLimitActionFilter.cs b/StackDuel.Api/RateLimiting/RateLimitActionFilter.cs new file mode 100644 index 0000000..e3a139c --- /dev/null +++ b/StackDuel.Api/RateLimiting/RateLimitActionFilter.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.RateLimiting; + +namespace StackDuel.Api.RateLimiting; + +/// Reads [RateLimit] off the action or controller and applies +/// the named policy by adding EnableRateLimitingAttribute to +/// the endpoint metadata — exactly what the middleware looks for. +public class RateLimitActionFilter : IAsyncActionFilter, IOrderedFilter +{ + public int Order => int.MinValue; // run before other filters + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // check action first, then controller (action wins) + var attr = context.ActionDescriptor.EndpointMetadata.OfType().FirstOrDefault(); + + if (attr is not null) + { + // inject the named policy into endpoint metadata + // so UseRateLimiter picks it up correctly + context.ActionDescriptor.EndpointMetadata.Add(new EnableRateLimitingAttribute(attr.PolicyName)); + } + + await next(); + } +} \ No newline at end of file diff --git a/StackDuel.Api/RateLimiting/RateLimitConvention.cs b/StackDuel.Api/RateLimiting/RateLimitConvention.cs new file mode 100644 index 0000000..503ea66 --- /dev/null +++ b/StackDuel.Api/RateLimiting/RateLimitConvention.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Core; + +namespace StackDuel.Api.RateLimiting; + +public enum RateLimitPolicy +{ + Fixed, + Sliding, +} + +/// Custom rate limit attribute. +/// Extend Attribute directly — EnableRateLimitingAttribute is sealed. +/// The policy name is registered at startup and referenced here by name, +/// which is exactly how [EnableRateLimiting("name")] works internally. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class RateLimitAttribute : Attribute +{ + public int Limit { get; } + public string Window { get; } + public RateLimitPolicy Policy { get; init; } = RateLimitPolicy.Fixed; + public int QueueLimit { get; init; } = 5; + + // the policy name this attribute maps to in the rate limiter registry + public string PolicyName { get; } + + public RateLimitAttribute(int limit, string window) + { + Limit = limit; + Window = window; + PolicyName = BuildPolicyName(limit, window, Policy); + } + + // deterministic name — same args always produce the same name + // e.g. [RateLimit(100, "1m")] → "rl:fixed:100:60" + // [RateLimit(5, "30s", Sliding)] → "rl:sliding:5:30" + internal static string BuildPolicyName(int limit, string window, RateLimitPolicy policy = RateLimitPolicy.Fixed) + { + int seconds = TimeStringParser.ToSeconds(window); + return $"rl:{policy.ToString().ToLower()}:{limit}:{seconds}"; + } +} + +/// Convention that automatically adds RateLimitActionFilter to all controllers. +/// This ensures [RateLimit] attributes are discovered and applied at startup. +public class RateLimitConvention : IActionModelConvention +{ + public void Apply(ActionModel action) + { + action.Filters.Add(new RateLimitActionFilter()); + } +} \ No newline at end of file diff --git a/StackDuel.Api/RateLimiting/RateLimitExtensions.cs b/StackDuel.Api/RateLimiting/RateLimitExtensions.cs new file mode 100644 index 0000000..c83d2dc --- /dev/null +++ b/StackDuel.Api/RateLimiting/RateLimitExtensions.cs @@ -0,0 +1,187 @@ +using Microsoft.AspNetCore.RateLimiting; +using StackDuel.Api.Core; +using System.Reflection; +using System.Threading.RateLimiting; + +namespace StackDuel.Api.RateLimiting; + +public static class RateLimiterExtensions +{ + public static IServiceCollection AddAttributeRateLimiting(this IServiceCollection services) + { + IEnumerable attributes = DiscoverAttributes(); + + services.AddRateLimiter(options => + { + options.GlobalLimiter = PartitionedRateLimiter.Create(ctx => + { + string key = + ctx.User?.FindFirst("sub")?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetFixedWindowLimiter( + key, + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 300, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 10, + } + ); + }); + + options.AddPolicy( + WellKnownPolicies.General, + ctx => + { + string key = + ctx.User?.FindFirst("sub")?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetFixedWindowLimiter( + key, + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 300, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 10, + } + ); + } + ); + + options.AddPolicy( + WellKnownPolicies.Submissions, + ctx => + { + string key = + ctx.User?.FindFirst("sub")?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetSlidingWindowLimiter( + key, + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 6, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 2, + } + ); + } + ); + + options.AddPolicy( + WellKnownPolicies.AvatarUpload, + ctx => + { + string key = + ctx.User?.FindFirst("sub")?.Value ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetSlidingWindowLimiter( + key, + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(10), + SegmentsPerWindow = 5, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 1, + } + ); + } + ); + + options.AddFixedWindowLimiter( + WellKnownPolicies.Judge0Callback, + opt => + { + opt.PermitLimit = 600; + opt.Window = TimeSpan.FromMinutes(1); + opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + opt.QueueLimit = 0; + } + ); + + foreach (RateLimitAttribute attr in attributes) + RegisterPolicy(options, attr); + + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (ctx, token) => + { + ctx.HttpContext.Response.StatusCode = 429; + ctx.HttpContext.Response.ContentType = "application/json"; + + if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter)) + ctx.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(); + + await ctx.HttpContext.Response.WriteAsync( + """{"error":"Too many requests. Please try again later."}""", + token + ); + }; + }); + + services.AddControllers(options => options.Conventions.Add(new RateLimitConvention())); + + return services; + } + + private static IEnumerable DiscoverAttributes() => + Assembly + .GetExecutingAssembly() + .GetTypes() + .SelectMany(type => + type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Select(m => m.GetCustomAttribute()) + .Append(type.GetCustomAttribute()) + .Where(a => a is not null) + .Select(a => a!) + ) + .DistinctBy(a => a.PolicyName) + .ToList(); + + private static void RegisterPolicy(RateLimiterOptions options, RateLimitAttribute attr) + { + TimeSpan window = TimeStringParser.ToTimeSpan(attr.Window); + + switch (attr.Policy) + { + case RateLimitPolicy.Fixed: + options.AddFixedWindowLimiter( + attr.PolicyName, + opt => + { + opt.PermitLimit = attr.Limit; + opt.Window = window; + opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + opt.QueueLimit = attr.QueueLimit; + } + ); + break; + + case RateLimitPolicy.Sliding: + options.AddSlidingWindowLimiter( + attr.PolicyName, + opt => + { + opt.PermitLimit = attr.Limit; + opt.Window = window; + opt.SegmentsPerWindow = CalculateSegments(window); + opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + opt.QueueLimit = attr.QueueLimit; + } + ); + break; + } + } + + private static int CalculateSegments(TimeSpan window) => + window.TotalSeconds switch + { + <= 60 => 6, + <= 300 => 5, + <= 3600 => 6, + _ => 4, + }; +} \ No newline at end of file diff --git a/StackDuel.Api/RateLimiting/WellKnownPolicies.cs b/StackDuel.Api/RateLimiting/WellKnownPolicies.cs new file mode 100644 index 0000000..4b9935e --- /dev/null +++ b/StackDuel.Api/RateLimiting/WellKnownPolicies.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Api.RateLimiting; + +public static class WellKnownPolicies +{ + public const string General = "rl:general"; + + public const string Submissions = "rl:submissions"; + + public const string AvatarUpload = "rl:avatar-upload"; + + public const string Judge0Callback = "rl:judge0-callback"; +} \ No newline at end of file diff --git a/StackDuel.Api/Requests/Audit/GetAuditLogPageableRequest.cs b/StackDuel.Api/Requests/Audit/GetAuditLogPageableRequest.cs new file mode 100644 index 0000000..f5caf16 --- /dev/null +++ b/StackDuel.Api/Requests/Audit/GetAuditLogPageableRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Audit; + +public sealed record GetAuditLogPageableRequest(int Page, int Size, DateTime Timestamp); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/AddCampaignModuleRequest.cs b/StackDuel.Api/Requests/Campaign/AddCampaignModuleRequest.cs new file mode 100644 index 0000000..3201d84 --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/AddCampaignModuleRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Campaign; + +public sealed record AddCampaignModuleRequest(string Title, string Description); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/AddCampaignUnitRequest.cs b/StackDuel.Api/Requests/Campaign/AddCampaignUnitRequest.cs new file mode 100644 index 0000000..0bf0ef1 --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/AddCampaignUnitRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Api.Requests.Campaign; + +public sealed record AddCampaignUnitRequest(string Title, string Content, UnitType UnitType, int EstimatedMinutes); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/CreateCampaignRequest.cs b/StackDuel.Api/Requests/Campaign/CreateCampaignRequest.cs new file mode 100644 index 0000000..460fbbb --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/CreateCampaignRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Api.Requests.Campaign; + +public sealed record CreateCampaignRequest(string Title, string Description, CampaignDifficulty Difficulty); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/GetAdminCampaignsPageableRequest.cs b/StackDuel.Api/Requests/Campaign/GetAdminCampaignsPageableRequest.cs new file mode 100644 index 0000000..3747083 --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/GetAdminCampaignsPageableRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Campaign; + +public sealed record GetAdminCampaignsPageableRequest(int Page, int Size, DateTime Timestamp, string? Search); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/SetCampaignPrerequisitesRequest.cs b/StackDuel.Api/Requests/Campaign/SetCampaignPrerequisitesRequest.cs new file mode 100644 index 0000000..695c4e0 --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/SetCampaignPrerequisitesRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Campaign; + +public sealed record SetCampaignPrerequisitesRequest(IReadOnlyList RequiredCampaignIds); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/SetUnitProblemsRequest.cs b/StackDuel.Api/Requests/Campaign/SetUnitProblemsRequest.cs new file mode 100644 index 0000000..0e7aa56 --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/SetUnitProblemsRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Campaign; + +public sealed record SetUnitProblemsRequest(IReadOnlyList ProblemIds); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/UpdateCampaignDetailsRequest.cs b/StackDuel.Api/Requests/Campaign/UpdateCampaignDetailsRequest.cs new file mode 100644 index 0000000..7d71657 --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/UpdateCampaignDetailsRequest.cs @@ -0,0 +1,10 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Api.Requests.Campaign; + +public sealed record UpdateCampaignDetailsRequest( + string Title, + string Description, + CampaignDifficulty Difficulty, + string? IconKey +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/UpdateCampaignModuleRequest.cs b/StackDuel.Api/Requests/Campaign/UpdateCampaignModuleRequest.cs new file mode 100644 index 0000000..0392e8f --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/UpdateCampaignModuleRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Campaign; + +public sealed record UpdateCampaignModuleRequest(string Title, string Description); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Campaign/UpdateCampaignUnitRequest.cs b/StackDuel.Api/Requests/Campaign/UpdateCampaignUnitRequest.cs new file mode 100644 index 0000000..ff310cb --- /dev/null +++ b/StackDuel.Api/Requests/Campaign/UpdateCampaignUnitRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Api.Requests.Campaign; + +public sealed record UpdateCampaignUnitRequest(string Title, string Content, UnitType UnitType, int EstimatedMinutes); \ No newline at end of file diff --git a/StackDuel.Api/Requests/DailyChallenge/UpdateDailyChallengeRequest.cs b/StackDuel.Api/Requests/DailyChallenge/UpdateDailyChallengeRequest.cs new file mode 100644 index 0000000..bc34915 --- /dev/null +++ b/StackDuel.Api/Requests/DailyChallenge/UpdateDailyChallengeRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.DailyChallenge; + +public sealed record UpdateDailyChallengeRequest(Guid ProblemId); \ No newline at end of file diff --git a/StackDuel.Api/Requests/FeatureFlags/CreateFeatureFlagRequest.cs b/StackDuel.Api/Requests/FeatureFlags/CreateFeatureFlagRequest.cs new file mode 100644 index 0000000..f84debb --- /dev/null +++ b/StackDuel.Api/Requests/FeatureFlags/CreateFeatureFlagRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.FeatureFlags; + +public sealed record CreateFeatureFlagRequest(string Key, string Name, string Description, bool DefaultEnabled); \ No newline at end of file diff --git a/StackDuel.Api/Requests/FeatureFlags/SetFeatureFlagOverrideRequest.cs b/StackDuel.Api/Requests/FeatureFlags/SetFeatureFlagOverrideRequest.cs new file mode 100644 index 0000000..7cea2d4 --- /dev/null +++ b/StackDuel.Api/Requests/FeatureFlags/SetFeatureFlagOverrideRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Api.Requests.FeatureFlags; + +public sealed record SetFeatureFlagOverrideRequest(DecisionEffect Effect); \ No newline at end of file diff --git a/StackDuel.Api/Requests/FeatureFlags/UpdateFeatureFlagDefaultRequest.cs b/StackDuel.Api/Requests/FeatureFlags/UpdateFeatureFlagDefaultRequest.cs new file mode 100644 index 0000000..3a03375 --- /dev/null +++ b/StackDuel.Api/Requests/FeatureFlags/UpdateFeatureFlagDefaultRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.FeatureFlags; + +public sealed record UpdateFeatureFlagDefaultRequest(bool DefaultEnabled); \ No newline at end of file diff --git a/StackDuel.Api/Requests/FeatureFlags/UpdateFeatureFlagRolloutRequest.cs b/StackDuel.Api/Requests/FeatureFlags/UpdateFeatureFlagRolloutRequest.cs new file mode 100644 index 0000000..28b201a --- /dev/null +++ b/StackDuel.Api/Requests/FeatureFlags/UpdateFeatureFlagRolloutRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.FeatureFlags; + +public sealed record UpdateFeatureFlagRolloutRequest(int RolloutPercentage); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Feedback/GetAdminFeedbackPageableRequest.cs b/StackDuel.Api/Requests/Feedback/GetAdminFeedbackPageableRequest.cs new file mode 100644 index 0000000..19ac444 --- /dev/null +++ b/StackDuel.Api/Requests/Feedback/GetAdminFeedbackPageableRequest.cs @@ -0,0 +1,11 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Api.Requests.Feedback; + +public sealed record GetAdminFeedbackPageableRequest( + int Page, + int Size, + DateTime Timestamp, + FeedbackType? Type, + FeedbackStatus? Status +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Feedback/SubmitFeedbackRequest.cs b/StackDuel.Api/Requests/Feedback/SubmitFeedbackRequest.cs new file mode 100644 index 0000000..55287aa --- /dev/null +++ b/StackDuel.Api/Requests/Feedback/SubmitFeedbackRequest.cs @@ -0,0 +1,11 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Api.Requests.Feedback; + +public sealed record SubmitFeedbackRequest( + FeedbackType Type, + string Message, + int? Rating, + FeedbackContextType ContextType, + Guid? ContextEntityId +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Feedback/UpdateFeedbackStatusRequest.cs b/StackDuel.Api/Requests/Feedback/UpdateFeedbackStatusRequest.cs new file mode 100644 index 0000000..4f77076 --- /dev/null +++ b/StackDuel.Api/Requests/Feedback/UpdateFeedbackStatusRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Api.Requests.Feedback; + +public sealed record UpdateFeedbackStatusRequest(FeedbackStatus Status, string? AdminNote); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Game/CompleteProblemRequest.cs b/StackDuel.Api/Requests/Game/CompleteProblemRequest.cs new file mode 100644 index 0000000..4a1fe99 --- /dev/null +++ b/StackDuel.Api/Requests/Game/CompleteProblemRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Game; + +public sealed record CompleteProblemRequest(Guid SubmissionId); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Game/CreateGameRequest.cs b/StackDuel.Api/Requests/Game/CreateGameRequest.cs new file mode 100644 index 0000000..75a0e9d --- /dev/null +++ b/StackDuel.Api/Requests/Game/CreateGameRequest.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Api.Requests.Game; + +public sealed record TrackSelectionRequest(string TrackKey, IReadOnlyList LanguageIds); + +public sealed record CreateGameRequest( + string GameModeKey, + IReadOnlyList TrackSelections, + int TimeLimitInSeconds, + bool SkipsEnabled = true +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Game/GetAdminGamesPageableRequest.cs b/StackDuel.Api/Requests/Game/GetAdminGamesPageableRequest.cs new file mode 100644 index 0000000..a8dea5f --- /dev/null +++ b/StackDuel.Api/Requests/Game/GetAdminGamesPageableRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Api.Requests.Game; + +public sealed record GetAdminGamesPageableRequest(int Page, int Size, DateTime Timestamp, GameStatus? Status); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Game/GetOpenGamesRequest.cs b/StackDuel.Api/Requests/Game/GetOpenGamesRequest.cs new file mode 100644 index 0000000..5a1444b --- /dev/null +++ b/StackDuel.Api/Requests/Game/GetOpenGamesRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Game; + +public sealed record GetOpenGamesRequest(string? GameModeKey, int Page, int Size); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Game/JoinGameByCodeRequest.cs b/StackDuel.Api/Requests/Game/JoinGameByCodeRequest.cs new file mode 100644 index 0000000..f874d40 --- /dev/null +++ b/StackDuel.Api/Requests/Game/JoinGameByCodeRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Game; + +public sealed record JoinGameByCodeRequest(string JoinCode); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Game/SubmitGameProblemRequest.cs b/StackDuel.Api/Requests/Game/SubmitGameProblemRequest.cs new file mode 100644 index 0000000..f482ffe --- /dev/null +++ b/StackDuel.Api/Requests/Game/SubmitGameProblemRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Game; + +public sealed record SubmitGameProblemRequest(Guid ProblemSetupId, string Code); \ No newline at end of file diff --git a/StackDuel.Api/Requests/LanguageServer/StartLanguageServerSessionRequest.cs b/StackDuel.Api/Requests/LanguageServer/StartLanguageServerSessionRequest.cs new file mode 100644 index 0000000..5abb9ba --- /dev/null +++ b/StackDuel.Api/Requests/LanguageServer/StartLanguageServerSessionRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.LanguageServer; + +public sealed record StartLanguageServerSessionRequest(string Language); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Leaderboard/GetLeaderboardRequest.cs b/StackDuel.Api/Requests/Leaderboard/GetLeaderboardRequest.cs new file mode 100644 index 0000000..56b630e --- /dev/null +++ b/StackDuel.Api/Requests/Leaderboard/GetLeaderboardRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Leaderboard; + +public sealed record GetLeaderboardRequest(string GameModeKey, int TimeLimitInSeconds, int Page, int Size); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Leaderboard/GetMyLeaderboardEntryRequest.cs b/StackDuel.Api/Requests/Leaderboard/GetMyLeaderboardEntryRequest.cs new file mode 100644 index 0000000..e8487f7 --- /dev/null +++ b/StackDuel.Api/Requests/Leaderboard/GetMyLeaderboardEntryRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Leaderboard; + +public sealed record GetMyLeaderboardEntryRequest(string GameModeKey, int TimeLimitInSeconds); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/AddProblemSetupRequest.cs b/StackDuel.Api/Requests/Problem/AddProblemSetupRequest.cs new file mode 100644 index 0000000..424a5a4 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/AddProblemSetupRequest.cs @@ -0,0 +1,5 @@ +using System.Text.Json.Serialization; + +namespace StackDuel.Api.Requests.Problem; + +public sealed record AddProblemSetupRequest([property: JsonRequired] Guid LanguageVersionId); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/AddProblemsToPoolRequest.cs b/StackDuel.Api/Requests/Problem/AddProblemsToPoolRequest.cs new file mode 100644 index 0000000..fccc2f6 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/AddProblemsToPoolRequest.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record AddProblemsToPoolRequest( + IReadOnlyList ProblemIds, + bool SelectAllMatching, + string? Search, + IReadOnlyList ExcludedProblemIds +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/CreateProblemDraftRequest.cs b/StackDuel.Api/Requests/Problem/CreateProblemDraftRequest.cs new file mode 100644 index 0000000..77e4949 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/CreateProblemDraftRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace StackDuel.Api.Requests.Problem; + +public sealed record CreateProblemDraftRequest( + string Title, + string Question, + [property: JsonRequired] int Difficulty, + [property: JsonRequired] int TimeLimitMs, + [property: JsonRequired] int MemoryLimitMb, + IReadOnlyCollection Tags, + [property: JsonRequired] Guid TrackId +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/CreateProblemPoolRequest.cs b/StackDuel.Api/Requests/Problem/CreateProblemPoolRequest.cs new file mode 100644 index 0000000..fbb1c8d --- /dev/null +++ b/StackDuel.Api/Requests/Problem/CreateProblemPoolRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record CreateProblemPoolRequest(string Key, string Name, string? Description); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/GetAdminProblemsPageableRequest.cs b/StackDuel.Api/Requests/Problem/GetAdminProblemsPageableRequest.cs new file mode 100644 index 0000000..2677ecc --- /dev/null +++ b/StackDuel.Api/Requests/Problem/GetAdminProblemsPageableRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record GetAdminProblemsPageableRequest(int Page, int Size, DateTime Timestamp, string? Search); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/GetProblemPoolMembersPagedRequest.cs b/StackDuel.Api/Requests/Problem/GetProblemPoolMembersPagedRequest.cs new file mode 100644 index 0000000..83d6e86 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/GetProblemPoolMembersPagedRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record GetProblemPoolMembersPagedRequest(int Page, int Size, DateTime Timestamp); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/GetProblemSubmissionsRequest.cs b/StackDuel.Api/Requests/Problem/GetProblemSubmissionsRequest.cs new file mode 100644 index 0000000..15f13e1 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/GetProblemSubmissionsRequest.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Submissions; + +namespace StackDuel.Api.Requests.Problem; + +public sealed record GetProblemSubmissionsRequest( + int Page, + int Size, + DateTime Timestamp, + SubmissionFilter Type = SubmissionFilter.MySubmissions, + SubmissionSortOrder SortBy = SubmissionSortOrder.Newest +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/GetProblemsPageableRequest.cs b/StackDuel.Api/Requests/Problem/GetProblemsPageableRequest.cs new file mode 100644 index 0000000..d164646 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/GetProblemsPageableRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record GetProblemsPageableRequest(int Page, int Size, DateTime Timestamp, string? Search); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/ReorderProblemPoolRequest.cs b/StackDuel.Api/Requests/Problem/ReorderProblemPoolRequest.cs new file mode 100644 index 0000000..2ac308c --- /dev/null +++ b/StackDuel.Api/Requests/Problem/ReorderProblemPoolRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record ReorderProblemPoolRequest(IReadOnlyList ProblemIds); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/RequiredLanguages/AddRequiredProblemLanguageRequest.cs b/StackDuel.Api/Requests/Problem/RequiredLanguages/AddRequiredProblemLanguageRequest.cs new file mode 100644 index 0000000..c6e6e71 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/RequiredLanguages/AddRequiredProblemLanguageRequest.cs @@ -0,0 +1,5 @@ +using System.Text.Json.Serialization; + +namespace StackDuel.Api.Requests.Problem.RequiredLanguages; + +public sealed record AddRequiredProblemLanguageRequest([property: JsonRequired] Guid LanguageVersionId); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/SetProblemGenerationParametersRequest.cs b/StackDuel.Api/Requests/Problem/SetProblemGenerationParametersRequest.cs new file mode 100644 index 0000000..010c644 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/SetProblemGenerationParametersRequest.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace StackDuel.Api.Requests.Problem; + +public sealed record GenerationParameterRequestItem( + string Name, + string ValueType, + double? Min, + double? Max, + int? LengthMin, + int? LengthMax, + string? Charset +); + +public sealed record SetProblemGenerationParametersRequest( + IReadOnlyList Parameters, + string OutputValueType, + [property: JsonRequired] int TargetCaseCount, + [property: JsonRequired] int Seed +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/SetProblemReactionRequest.cs b/StackDuel.Api/Requests/Problem/SetProblemReactionRequest.cs new file mode 100644 index 0000000..19d583e --- /dev/null +++ b/StackDuel.Api/Requests/Problem/SetProblemReactionRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record SetProblemReactionRequest(string ReactionTypeKey); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/SetProblemSampleTestCasesRequest.cs b/StackDuel.Api/Requests/Problem/SetProblemSampleTestCasesRequest.cs new file mode 100644 index 0000000..1269e9f --- /dev/null +++ b/StackDuel.Api/Requests/Problem/SetProblemSampleTestCasesRequest.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Api.Requests.Problem; + +public sealed record SampleTestCaseInputRequestItem(string Value, string ValueType); + +public sealed record SampleTestCaseRequestItem( + string? Name, + IReadOnlyList Inputs, + string ExpectedOutputValue, + string ExpectedOutputValueType +); + +public sealed record SetProblemSampleTestCasesRequest(IReadOnlyList TestCases); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/SubmissionFilterType.cs b/StackDuel.Api/Requests/Problem/SubmissionFilterType.cs new file mode 100644 index 0000000..f260185 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/SubmissionFilterType.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Api.Requests.Problem; + +public enum SubmissionFilterType +{ + My, + All, +} \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/UpdateProblemRequest.cs b/StackDuel.Api/Requests/Problem/UpdateProblemRequest.cs new file mode 100644 index 0000000..9aa2df6 --- /dev/null +++ b/StackDuel.Api/Requests/Problem/UpdateProblemRequest.cs @@ -0,0 +1,13 @@ +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Api.Requests.Problem; + +public sealed record UpdateProblemRequest( + string Title, + string Question, + int Difficulty, + int TimeLimitMs, + int MemoryLimitMb, + IReadOnlyCollection Tags, + ProblemStatus? Status +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Problem/UpsertProblemSetupReferenceSolutionRequest.cs b/StackDuel.Api/Requests/Problem/UpsertProblemSetupReferenceSolutionRequest.cs new file mode 100644 index 0000000..9549b2a --- /dev/null +++ b/StackDuel.Api/Requests/Problem/UpsertProblemSetupReferenceSolutionRequest.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace StackDuel.Api.Requests.Problem; + +public sealed record UpsertProblemSetupReferenceSolutionRequest( + [property: JsonRequired] Guid LanguageVersionId, + string InitialCode, + string? FunctionName, + string ReferenceSolutionCode +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Submission/CreateGradeSubmissionRequest.cs b/StackDuel.Api/Requests/Submission/CreateGradeSubmissionRequest.cs new file mode 100644 index 0000000..dcd9cd5 --- /dev/null +++ b/StackDuel.Api/Requests/Submission/CreateGradeSubmissionRequest.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Api.Requests.Submission; + +public sealed record CreateGradeSubmissionRequest( + Guid ProblemSetupId, + string Code, + IReadOnlyCollection? AdditionalFiles = null +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Submission/CreateRunSubmissionRequest.cs b/StackDuel.Api/Requests/Submission/CreateRunSubmissionRequest.cs new file mode 100644 index 0000000..826cef8 --- /dev/null +++ b/StackDuel.Api/Requests/Submission/CreateRunSubmissionRequest.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Api.Requests.Submission; + +public sealed record RunTestCaseInputRequest(IReadOnlyCollection Inputs); + +public sealed record SubmissionFileRequest(string Path, string Content); + +public sealed record CreateRunSubmissionRequest( + Guid ProblemSetupId, + string Code, + IReadOnlyCollection? CustomTestCases, + IReadOnlyCollection? AdditionalFiles = null +); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Submission/CreateSubmissionRequest.cs b/StackDuel.Api/Requests/Submission/CreateSubmissionRequest.cs new file mode 100644 index 0000000..bcb5004 --- /dev/null +++ b/StackDuel.Api/Requests/Submission/CreateSubmissionRequest.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Api.Requests.Submission; + +public sealed record CreateSubmissionRequest(Guid ProblemSetupId, SubmissionType Type, string Code); \ No newline at end of file diff --git a/StackDuel.Api/Requests/Submission/GetAdminSubmissionsPageableRequest.cs b/StackDuel.Api/Requests/Submission/GetAdminSubmissionsPageableRequest.cs new file mode 100644 index 0000000..b7b0fca --- /dev/null +++ b/StackDuel.Api/Requests/Submission/GetAdminSubmissionsPageableRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.Submission; + +public sealed record GetAdminSubmissionsPageableRequest(int Page, int Size, DateTime Timestamp, Guid? Id); \ No newline at end of file diff --git a/StackDuel.Api/Requests/User/GetAdminUsersPageableRequest.cs b/StackDuel.Api/Requests/User/GetAdminUsersPageableRequest.cs new file mode 100644 index 0000000..29c02b9 --- /dev/null +++ b/StackDuel.Api/Requests/User/GetAdminUsersPageableRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.User; + +public sealed record GetAdminUsersPageableRequest(int Page, int Size, DateTime Timestamp, string? Search); \ No newline at end of file diff --git a/StackDuel.Api/Requests/User/UpdateProfilePrivacyRequest.cs b/StackDuel.Api/Requests/User/UpdateProfilePrivacyRequest.cs new file mode 100644 index 0000000..1b833ae --- /dev/null +++ b/StackDuel.Api/Requests/User/UpdateProfilePrivacyRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.User; + +public sealed record UpdateProfilePrivacyRequest(bool IsPrivate); \ No newline at end of file diff --git a/StackDuel.Api/Requests/User/UpdateUserGroupsRequest.cs b/StackDuel.Api/Requests/User/UpdateUserGroupsRequest.cs new file mode 100644 index 0000000..64ea1ae --- /dev/null +++ b/StackDuel.Api/Requests/User/UpdateUserGroupsRequest.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Api.Requests.User; + +public sealed record UpdateUserGroupsRequest(IReadOnlyList GroupIds); \ No newline at end of file diff --git a/StackDuel.Api/Requests/User/UpsertUserRequest.cs b/StackDuel.Api/Requests/User/UpsertUserRequest.cs new file mode 100644 index 0000000..c3c53e4 --- /dev/null +++ b/StackDuel.Api/Requests/User/UpsertUserRequest.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Api.Requests.User; + +public sealed record UpsertUserRequest( + string? Username, + string? Picture, + string? Bio, + IReadOnlyList? LanguageIds = null, + string? TenantId = null +); \ No newline at end of file diff --git a/StackDuel.Api/Responses/LanguageServer/LanguageServerSessionResponse.cs b/StackDuel.Api/Responses/LanguageServer/LanguageServerSessionResponse.cs new file mode 100644 index 0000000..32bee6e --- /dev/null +++ b/StackDuel.Api/Responses/LanguageServer/LanguageServerSessionResponse.cs @@ -0,0 +1,15 @@ +using StackDuel.Application.LanguageServer; + +namespace StackDuel.Api.Responses.LanguageServer; + +public sealed record LanguageServerSessionResponse( + Guid SessionId, + string RootUri, + string DocumentUri, + string LanguageId, + object? InitializationOptions +) +{ + public static LanguageServerSessionResponse FromSession(LanguageServerSession session) => + new(session.Id, session.RootUri, session.DocumentUri, session.LanguageSlug, session.InitializationOptions); +} \ No newline at end of file diff --git a/StackDuel.Api/Responses/User/UserResponse.cs b/StackDuel.Api/Responses/User/UserResponse.cs new file mode 100644 index 0000000..ac4b92e --- /dev/null +++ b/StackDuel.Api/Responses/User/UserResponse.cs @@ -0,0 +1,33 @@ +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Api.Responses.User; + +public sealed record UserResponse( + Guid Id, + string Username, + string? ImageUrl, + string? Bio, + bool IsPrivate, + DateTime? UsernameLastChangedAt, + DateTime CreatedAt, + DateTime? SetupCompletedAt, + IReadOnlyList Permissions, + IReadOnlyList Roles, + IReadOnlyList LanguagePreferenceIds +) +{ + public static UserResponse FromDto(UserDto dto, IReadOnlyList permissions, IReadOnlyList roles) => + new( + dto.Id, + dto.Username, + dto.ImageUrl, + dto.Bio, + dto.IsPrivate, + dto.UsernameLastChangedAt, + dto.CreatedAt, + dto.SetupCompletedAt, + permissions, + roles, + dto.LanguagePreferenceIds + ); +} \ No newline at end of file diff --git a/StackDuel.Api/StackDuel.Api.csproj b/StackDuel.Api/StackDuel.Api.csproj index 0057157..91a4171 100644 --- a/StackDuel.Api/StackDuel.Api.csproj +++ b/StackDuel.Api/StackDuel.Api.csproj @@ -1,13 +1,38 @@ - - + net10.0 enable enable + f1204ecb-b2c7-4ab2-a772-a27e6871af93 + + + + + + + all + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + PreserveNewest + PreserveNewest + + diff --git a/StackDuel.Api/StackDuel.Api.http b/StackDuel.Api/StackDuel.Api.http index 47ac1ce..eea7e62 100644 --- a/StackDuel.Api/StackDuel.Api.http +++ b/StackDuel.Api/StackDuel.Api.http @@ -1,4 +1,4 @@ -@StackDuel.Api_HostAddress = http://localhost:5197 +@StackDuel.Api_HostAddress = http://localhost:5041 GET {{StackDuel.Api_HostAddress}}/weatherforecast/ Accept: application/json diff --git a/StackDuel.Api/Tools/esbuild.exe b/StackDuel.Api/Tools/esbuild.exe new file mode 100644 index 0000000..d87697b Binary files /dev/null and b/StackDuel.Api/Tools/esbuild.exe differ diff --git a/StackDuel.Api/appsettings.Development.json b/StackDuel.Api/appsettings.Development.json index 0c208ae..f71dbba 100644 --- a/StackDuel.Api/appsettings.Development.json +++ b/StackDuel.Api/appsettings.Development.json @@ -1,8 +1,58 @@ { + "Auth0": { + "Domain": "", + "Audience": "" + }, + "AzureStorage": { + "ConnectionString": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;", + "AvatarContainerName": "avatars", + "AutoCreateContainer": true + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=stackduel;Username=myuser;Password=mypassword" + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:3000" ] + }, + "ExecutionEngines": { + "Judge0": { + "ApiKey": "", + "BaseUrl": "https://judge0-ce.p.rapidapi.com", + "CallbackSecret": "", + "DefaultTimeoutInSeconds": 10, + "Enabled": true, + "Host": "", + "IsEncoded": true, + "PublicBaseUrl": "", + "RunWorker": true, + "ShouldWait": false, + "UseCallback": true + } + }, "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Debug", + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "MessageBus": { + "Transport": "RabbitMQ", + "AzureServiceBus": { + "ConnectionString": "" + }, + "RabbitMQ": { + "Host": "localhost", + "VirtualHost": "/", + "Username": "guest", + "Password": "guest", + "MaxConcurrentCalls": 8 + } + }, + "Quartz": { + "SubmissionCleanupJob": { + "CronExpression": "0 0 * * * ?" } } } diff --git a/StackDuel.Api/appsettings.json b/StackDuel.Api/appsettings.json index 10f68b8..908701d 100644 --- a/StackDuel.Api/appsettings.json +++ b/StackDuel.Api/appsettings.json @@ -1,9 +1,65 @@ { + "AllowedHosts": "*", + "ApplicationInsights": { + "ConnectionString": "" + }, + "AzureStorage": { + "ConnectionString": "", + "AvatarContainerName": "avatars", + "AutoCreateContainer": false + }, + "Auth0": { + "Domain": "", + "Audience": "" + }, + "Cors": { + "AllowedOrigins": [] + }, + "ExecutionEngines": { + "Judge0": { + "Enabled": true, + "RunWorker": true, + "BaseUrl": "", + "ApiKey": "", + "Host": "", + "ShouldWait": false, + "IsEncoded": true, + "DefaultTimeoutInSeconds": 10, + "UseCallback": true, + "CallbackSecret": "", + "PublicBaseUrl": "" + } + }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" + }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning" + } + } + }, + "MessageBus": { + "Transport": "AzureServiceBus", + "AzureServiceBus": { + "ConnectionString": "" } }, - "AllowedHosts": "*" + "Quartz": { + "GameExpirySweepJob": { + "CronExpression": "0 */10 * * * ?" + }, + "SubmissionCleanupJob": { + "CronExpression": "0 0 * * * ?" + }, + "SubmissionJobProcessorJob": { + "CronExpression": "0 */2 * * * ?" + }, + "DailyChallengeAssignmentJob": { + "CronExpression": "0 0 0 * * ?" + } + } } diff --git a/StackDuel.Application.Tests/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeHandlerTests.cs b/StackDuel.Application.Tests/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeHandlerTests.cs new file mode 100644 index 0000000..ef48b36 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeHandlerTests.cs @@ -0,0 +1,123 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.Problems; +using StackDuel.Domain.DailyChallenges.Entities; +using UpdateDailyChallengeCommand = StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge.UpdateDailyChallengeCommand; +using UpdateDailyChallengeHandler = StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge.UpdateDailyChallengeHandler; +using UpdateDailyChallengeValidator = StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge.UpdateDailyChallengeValidator; + +namespace StackDuel.Application.Tests.Commands.DailyChallenges.UpdateDailyChallenge; + +public class UpdateDailyChallengeHandlerTests +{ + private Mock _dailyChallengeRepository = null!; + private Mock _problemReadRepository = null!; + private UpdateDailyChallengeHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _dailyChallengeRepository = new Mock(); + _problemReadRepository = new Mock(); + _handler = new UpdateDailyChallengeHandler( + _dailyChallengeRepository.Object, + _problemReadRepository.Object, + new UpdateDailyChallengeValidator() + ); + } + + private static DateOnly Tomorrow() => DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1); + + [Test] + public async Task Handle_DateIsToday_ReturnsInvalid() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + Result result = await _handler.Handle( + new UpdateDailyChallengeCommand(today, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_DateIsInThePast_ReturnsInvalid() + { + var yesterday = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1); + + Result result = await _handler.Handle( + new UpdateDailyChallengeCommand(yesterday, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_NoChallengeScheduledForDate_ReturnsNotFound() + { + var date = Tomorrow(); + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(date, It.IsAny())) + .ReturnsAsync((DailyChallenge?)null); + + Result result = await _handler.Handle( + new UpdateDailyChallengeCommand(date, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ProblemDoesNotExist_ReturnsInvalid() + { + var date = Tomorrow(); + var challenge = new DailyChallenge(date, Guid.NewGuid()); + var newProblemId = Guid.NewGuid(); + + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(date, It.IsAny())) + .ReturnsAsync(challenge); + _problemReadRepository + .Setup(x => x.ExistsForAdminAsync(newProblemId, It.IsAny())) + .ReturnsAsync(false); + + Result result = await _handler.Handle( + new UpdateDailyChallengeCommand(date, newProblemId), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _dailyChallengeRepository.Verify( + x => x.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_ValidRequest_UpdatesTheChallengesProblem() + { + var date = Tomorrow(); + var challenge = new DailyChallenge(date, Guid.NewGuid()); + var newProblemId = Guid.NewGuid(); + + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(date, It.IsAny())) + .ReturnsAsync(challenge); + _problemReadRepository + .Setup(x => x.ExistsForAdminAsync(newProblemId, It.IsAny())) + .ReturnsAsync(true); + + Result result = await _handler.Handle( + new UpdateDailyChallengeCommand(date, newProblemId), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(challenge.ProblemId, Is.EqualTo(newProblemId)); + _dailyChallengeRepository.Verify(x => x.UpdateAsync(challenge, It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagValidatorTests.cs b/StackDuel.Application.Tests/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagValidatorTests.cs new file mode 100644 index 0000000..3443f1d --- /dev/null +++ b/StackDuel.Application.Tests/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagValidatorTests.cs @@ -0,0 +1,39 @@ +using CreateFeatureFlagCommand = StackDuel.Application.Commands.FeatureFlags.CreateFeatureFlag.CreateFeatureFlagCommand; +using CreateFeatureFlagValidator = StackDuel.Application.Commands.FeatureFlags.CreateFeatureFlag.CreateFeatureFlagValidator; + +namespace StackDuel.Application.Tests.Commands.FeatureFlags.CreateFeatureFlag; + +public class CreateFeatureFlagValidatorTests +{ + private CreateFeatureFlagValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new CreateFeatureFlagValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new CreateFeatureFlagCommand("leaderboards", "Leaderboards", "Global kill-switch.", true); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } + + [TestCase("")] + [TestCase("Leaderboards")] + [TestCase("leader_boards")] + [TestCase("leader boards")] + public void Validate_InvalidKeyFormat_IsInvalid(string key) + { + var command = new CreateFeatureFlagCommand(key, "Name", "Description", false); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_BlankName_IsInvalid() + { + var command = new CreateFeatureFlagCommand("leaderboards", " ", "Description", false); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutValidatorTests.cs b/StackDuel.Application.Tests/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutValidatorTests.cs new file mode 100644 index 0000000..1de6a63 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutValidatorTests.cs @@ -0,0 +1,39 @@ +using UpdateFeatureFlagRolloutCommand = StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagRollout.UpdateFeatureFlagRolloutCommand; +using UpdateFeatureFlagRolloutValidator = StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagRollout.UpdateFeatureFlagRolloutValidator; + +namespace StackDuel.Application.Tests.Commands.FeatureFlags.UpdateFeatureFlagRollout; + +public class UpdateFeatureFlagRolloutValidatorTests +{ + private UpdateFeatureFlagRolloutValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new UpdateFeatureFlagRolloutValidator(); + } + + [TestCase(0)] + [TestCase(50)] + [TestCase(100)] + public void Validate_PercentageInRange_IsValid(int percentage) + { + var command = new UpdateFeatureFlagRolloutCommand(Guid.NewGuid(), percentage); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } + + [TestCase(-1)] + [TestCase(101)] + public void Validate_PercentageOutOfRange_IsInvalid(int percentage) + { + var command = new UpdateFeatureFlagRolloutCommand(Guid.NewGuid(), percentage); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyFlagId_IsInvalid() + { + var command = new UpdateFeatureFlagRolloutCommand(Guid.Empty, 50); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Feedback/SubmitFeedback/SubmitFeedbackHandlerTests.cs b/StackDuel.Application.Tests/Commands/Feedback/SubmitFeedback/SubmitFeedbackHandlerTests.cs new file mode 100644 index 0000000..ad73157 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Feedback/SubmitFeedback/SubmitFeedbackHandlerTests.cs @@ -0,0 +1,69 @@ +using Moq; +using StackDuel.Application.Events; +using StackDuel.Domain.Feedback; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.Feedback.Factories; +using SubmitFeedbackCommand = StackDuel.Application.Commands.Feedback.SubmitFeedback.SubmitFeedbackCommand; +using SubmitFeedbackHandler = StackDuel.Application.Commands.Feedback.SubmitFeedback.SubmitFeedbackHandler; +using SubmitFeedbackValidator = StackDuel.Application.Commands.Feedback.SubmitFeedback.SubmitFeedbackValidator; + +namespace StackDuel.Application.Tests.Commands.Feedback.SubmitFeedback; + +public class SubmitFeedbackHandlerTests +{ + private Mock _feedbackRepository = null!; + private Mock _domainEventDispatcher = null!; + private SubmitFeedbackHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _feedbackRepository = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new SubmitFeedbackHandler( + new SubmitFeedbackValidator(), + new FeedbackSubmissionFactory(), + _feedbackRepository.Object, + _domainEventDispatcher.Object + ); + } + + private static SubmitFeedbackCommand ValidCommand() => + new( + Guid.NewGuid(), + FeedbackType.Bug, + "Something is broken.", + 4, + FeedbackContextType.None, + null, + "https://stackduel.dev/problems/two-sum", + "Mozilla/5.0" + ); + + [Test] + public async Task Handle_ValidCommand_AddsFeedbackAndReturnsItsId() + { + var result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _feedbackRepository.Verify( + r => r.AddAsync(It.Is(f => f.Id == result.Value), CancellationToken.None), + Times.Once + ); + } + + [Test] + public async Task Handle_InvalidCommand_DoesNotAddFeedback() + { + var command = ValidCommand() with { Message = "" }; + + await _handler.Handle(command, CancellationToken.None); + + _feedbackRepository.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Feedback/SubmitFeedback/SubmitFeedbackValidatorTests.cs b/StackDuel.Application.Tests/Commands/Feedback/SubmitFeedback/SubmitFeedbackValidatorTests.cs new file mode 100644 index 0000000..6468b32 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Feedback/SubmitFeedback/SubmitFeedbackValidatorTests.cs @@ -0,0 +1,92 @@ +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.Feedback.ValueObjects; +using SubmitFeedbackCommand = StackDuel.Application.Commands.Feedback.SubmitFeedback.SubmitFeedbackCommand; +using SubmitFeedbackValidator = StackDuel.Application.Commands.Feedback.SubmitFeedback.SubmitFeedbackValidator; + +namespace StackDuel.Application.Tests.Commands.Feedback.SubmitFeedback; + +public class SubmitFeedbackValidatorTests +{ + private SubmitFeedbackValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new SubmitFeedbackValidator(); + } + + private static SubmitFeedbackCommand ValidCommand( + int? rating = null, + FeedbackContextType contextType = FeedbackContextType.None, + Guid? contextEntityId = null + ) => + new( + Guid.NewGuid(), + FeedbackType.General, + "Something is broken.", + rating, + contextType, + contextEntityId, + "https://stackduel.dev/problems/two-sum", + "Mozilla/5.0" + ); + + [Test] + public void Validate_ValidCommand_IsValid() + { + Assert.That(_validator.Validate(ValidCommand()).IsValid, Is.True); + } + + [Test] + public void Validate_EmptyUserId_IsInvalid() + { + var command = ValidCommand() with { UserId = Guid.Empty }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyMessage_IsInvalid() + { + var command = ValidCommand() with { Message = "" }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_MessageTooLong_IsInvalid() + { + var command = ValidCommand() with { Message = new string('a', FeedbackMessage.MaxLength + 1) }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_RatingWithinRange_IsValid([Values(1, 2, 3, 4, 5)] int rating) + { + Assert.That(_validator.Validate(ValidCommand(rating: rating)).IsValid, Is.True); + } + + [Test] + public void Validate_RatingOutOfRange_IsInvalid([Values(0, 6)] int rating) + { + Assert.That(_validator.Validate(ValidCommand(rating: rating)).IsValid, Is.False); + } + + [Test] + public void Validate_NullRating_IsValid() + { + Assert.That(_validator.Validate(ValidCommand(rating: null)).IsValid, Is.True); + } + + [Test] + public void Validate_ContextTypeSetWithoutEntityId_IsInvalid() + { + var command = ValidCommand(contextType: FeedbackContextType.Problem, contextEntityId: null); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_ContextTypeSetWithEntityId_IsValid() + { + var command = ValidCommand(contextType: FeedbackContextType.Problem, contextEntityId: Guid.NewGuid()); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CloseLobby/CloseLobbyHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/CloseLobby/CloseLobbyHandlerTests.cs new file mode 100644 index 0000000..7baf176 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CloseLobby/CloseLobbyHandlerTests.cs @@ -0,0 +1,123 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using CloseLobbyCommand = StackDuel.Application.Commands.Games.CloseLobby.CloseLobbyCommand; +using CloseLobbyHandler = StackDuel.Application.Commands.Games.CloseLobby.CloseLobbyHandler; +using CloseLobbyValidator = StackDuel.Application.Commands.Games.CloseLobby.CloseLobbyValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CloseLobby; + +public class CloseLobbyHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _domainEventDispatcher = null!; + private CloseLobbyHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new CloseLobbyHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _domainEventDispatcher.Object, + new CloseLobbyValidator() + ); + } + + private static Game CreatePendingGame(out Guid hostId, out Guid otherId) + { + hostId = Guid.NewGuid(); + otherId = Guid.NewGuid(); + return new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId, otherId], 600); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new CloseLobbyCommand(Guid.Empty, Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + var gameId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(gameId, It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new CloseLobbyCommand(gameId, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_RequesterIsNotHost_ReturnsForbidden() + { + var game = CreatePendingGame(out _, out var otherId); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CloseLobbyCommand(game.Id, otherId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotPending_ReturnsInvalid() + { + var game = CreatePendingGame(out var hostId, out _); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CloseLobbyCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_HostClosesPendingLobby_CancelsGameAndDispatchesEvents() + { + var game = CreatePendingGame(out var hostId, out _); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CloseLobbyCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(StackDuel.Domain.Games.Enums.GameStatus.Cancelled)); + }); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => + x.DispatchAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CloseLobby/CloseLobbyValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/CloseLobby/CloseLobbyValidatorTests.cs new file mode 100644 index 0000000..bbe5410 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CloseLobby/CloseLobbyValidatorTests.cs @@ -0,0 +1,46 @@ +using FluentValidation.Results; +using CloseLobbyCommand = StackDuel.Application.Commands.Games.CloseLobby.CloseLobbyCommand; +using CloseLobbyValidator = StackDuel.Application.Commands.Games.CloseLobby.CloseLobbyValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CloseLobby; + +public class CloseLobbyValidatorTests +{ + private CloseLobbyValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new CloseLobbyValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new CloseLobbyCommand(Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new CloseLobbyCommand(Guid.Empty, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = new CloseLobbyCommand(Guid.NewGuid(), Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CompleteExpiredGame/CompleteExpiredGameHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/CompleteExpiredGame/CompleteExpiredGameHandlerTests.cs new file mode 100644 index 0000000..996c4a2 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CompleteExpiredGame/CompleteExpiredGameHandlerTests.cs @@ -0,0 +1,192 @@ +using Ardalis.Result; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using CompleteExpiredGameCommand = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameCommand; +using CompleteExpiredGameHandler = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameHandler; +using CompleteExpiredGameOutcome = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameOutcome; +using CompleteExpiredGameResult = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameResult; +using CompleteExpiredGameValidator = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CompleteExpiredGame; + +public class CompleteExpiredGameHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _domainEventDispatcher = null!; + private CompleteExpiredGameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new CompleteExpiredGameHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + new CompleteExpiredGameValidator(), + _domainEventDispatcher.Object, + NullLogger.Instance + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalid() + { + var command = new CompleteExpiredGameCommand(Guid.NewGuid(), DateTime.UtcNow, -1); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsStaleDropped() + { + var gameId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(gameId, It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new CompleteExpiredGameCommand(gameId, DateTime.UtcNow, 0); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.Outcome, Is.EqualTo(CompleteExpiredGameOutcome.Stale_Dropped)); + } + + [Test] + public async Task Handle_GameNotRunning_ReturnsAlreadyFinalizedNoOp() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteExpiredGameCommand(game.Id, DateTime.UtcNow, 0); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Value.Outcome, Is.EqualTo(CompleteExpiredGameOutcome.AlreadyFinalized_NoOp)); + } + + [Test] + public async Task Handle_StartedAtDoesNotMatchExpected_ReturnsStaleDropped() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteExpiredGameCommand(game.Id, game.StartedAt!.Value.AddMinutes(-5), 0); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Value.Outcome, Is.EqualTo(CompleteExpiredGameOutcome.Stale_Dropped)); + } + + [Test] + public async Task Handle_NotYetExpired_BelowRescheduleLimit_ReturnsRescheduled() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteExpiredGameCommand(game.Id, game.StartedAt!.Value, 0); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.Value.Outcome, Is.EqualTo(CompleteExpiredGameOutcome.NotYetExpired_Rescheduled)); + Assert.That(result.Value.RescheduleForUtc, Is.Not.Null); + Assert.That(game.Status, Is.EqualTo(StackDuel.Domain.Games.Enums.GameStatus.Running)); + }); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_NotYetExpired_RescheduleLimitReached_CompletesGameAnyway() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteExpiredGameCommand(game.Id, game.StartedAt!.Value, 3); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That( + result.Value.Outcome, + Is.EqualTo(CompleteExpiredGameOutcome.NotYetExpired_RescheduleLimitReached_CompletedAnyway) + ); + Assert.That(game.Status, Is.EqualTo(StackDuel.Domain.Games.Enums.GameStatus.Completed)); + }); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => + x.DispatchAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_NotYetExpired_ImplausibleDelay_ReturnsDropped() + { + var game = new Game( + Guid.NewGuid(), + Guid.NewGuid(), + [Guid.NewGuid()], + [Guid.NewGuid()], + (int)TimeSpan.FromHours(8).TotalSeconds + ); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteExpiredGameCommand(game.Id, game.StartedAt!.Value, 0); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That( + result.Value.Outcome, + Is.EqualTo(CompleteExpiredGameOutcome.NotYetExpired_ImplausibleDelay_Dropped) + ); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameHasExpired_CompletesGameAndReturnsCompleted() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 1); + game.Start(); + typeof(Game).GetProperty(nameof(Game.StartedAt))!.SetValue(game, DateTime.UtcNow.AddSeconds(-2)); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteExpiredGameCommand(game.Id, game.StartedAt!.Value, 0); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.Value.Outcome, Is.EqualTo(CompleteExpiredGameOutcome.Completed)); + Assert.That(game.Status, Is.EqualTo(StackDuel.Domain.Games.Enums.GameStatus.Completed)); + }); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => + x.DispatchAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CompleteExpiredGame/CompleteExpiredGameValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/CompleteExpiredGame/CompleteExpiredGameValidatorTests.cs new file mode 100644 index 0000000..7fd9e74 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CompleteExpiredGame/CompleteExpiredGameValidatorTests.cs @@ -0,0 +1,66 @@ +using FluentValidation.Results; +using CompleteExpiredGameCommand = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameCommand; +using CompleteExpiredGameValidator = StackDuel.Application.Commands.Games.CompleteExpiredGame.CompleteExpiredGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CompleteExpiredGame; + +public class CompleteExpiredGameValidatorTests +{ + private CompleteExpiredGameValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new CompleteExpiredGameValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new CompleteExpiredGameCommand(Guid.NewGuid(), DateTime.UtcNow, 0); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new CompleteExpiredGameCommand(Guid.Empty, DateTime.UtcNow, 0); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_DefaultExpectedStartedAt_IsInvalid() + { + var command = new CompleteExpiredGameCommand(Guid.NewGuid(), default, 0); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_NegativeRescheduleCount_IsInvalid() + { + var command = new CompleteExpiredGameCommand(Guid.NewGuid(), DateTime.UtcNow, -1); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_ZeroRescheduleCount_IsValid() + { + var command = new CompleteExpiredGameCommand(Guid.NewGuid(), DateTime.UtcNow, 0); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CompleteProblem/CompleteProblemHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/CompleteProblem/CompleteProblemHandlerTests.cs new file mode 100644 index 0000000..872551f --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CompleteProblem/CompleteProblemHandlerTests.cs @@ -0,0 +1,542 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.ValueObjects; +using CompleteProblemCommand = StackDuel.Application.Commands.Games.CompleteProblem.CompleteProblemCommand; +using CompleteProblemHandler = StackDuel.Application.Commands.Games.CompleteProblem.CompleteProblemHandler; +using CompleteProblemResultDto = StackDuel.Application.Commands.Games.CompleteProblem.CompleteProblemResultDto; +using CompleteProblemValidator = StackDuel.Application.Commands.Games.CompleteProblem.CompleteProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CompleteProblem; + +public class CompleteProblemHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _gameExpiryCanceller = null!; + private Mock _domainEventDispatcher = null!; + private Mock _strategyResolver = null!; + private Mock _strategy = null!; + private Mock _gameProblemSequencer = null!; + private Mock _submissionRepository = null!; + private CompleteProblemHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _gameExpiryCanceller = new Mock(); + _domainEventDispatcher = new Mock(); + _strategyResolver = new Mock(); + _strategy = new Mock(); + _gameProblemSequencer = new Mock(); + _submissionRepository = new Mock(); + + _strategyResolver.Setup(x => x.Resolve(It.IsAny())).Returns(_strategy.Object); + + var gameProblemAdvancer = new GameProblemAdvancer( + _gameProblemSequencer.Object, + _gameExpiryCanceller.Object, + _strategyResolver.Object + ); + + _handler = new CompleteProblemHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _domainEventDispatcher.Object, + gameProblemAdvancer, + _submissionRepository.Object, + new CompleteProblemValidator() + ); + } + + private static Submission CreateAcceptedSubmission(Guid userId) + { + var submission = new Submission(userId, Guid.NewGuid(), SubmissionType.Submit, new SourceCode("print(1)"), []); + submission.Complete(); + return submission; + } + + private static GameMode CreateGameMode(string key = "solo_rush") => + new(key, "Solo Rush", "Solo rush mode", true, 1, 1, Guid.NewGuid()); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalid() + { + var command = new CompleteProblemCommand(Guid.Empty, Guid.Empty, Guid.Empty, Guid.Empty); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + var gameId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(gameId, It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new CompleteProblemCommand(gameId, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UserNotParticipant_ReturnsForbidden() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteProblemCommand(game.Id, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameNotRunning_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteProblemCommand(game.Id, Guid.NewGuid(), Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ParticipantHasForfeited_ReturnsInvalid() + { + var forfeitingUserId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [forfeitingUserId, otherUserId], 600); + game.Start(); + game.Participants.First(p => p.UserId == forfeitingUserId).Forfeit(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteProblemCommand(game.Id, Guid.NewGuid(), Guid.NewGuid(), forfeitingUserId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ProblemSessionNotInitialized_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteProblemCommand(game.Id, Guid.NewGuid(), Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ProblemIdDoesNotMatchCurrentProblem_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + participant.InitializeProblemSession(Guid.NewGuid()); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteProblemCommand(game.Id, Guid.NewGuid(), Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_SubmissionIdNotActiveSubmission_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new CompleteProblemCommand(game.Id, problemId, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_SubmissionNotFound_ReturnsNotFound() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + participant.SetActiveSubmission(submissionId); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((Submission?)null); + + var command = new CompleteProblemCommand(game.Id, problemId, submissionId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_SubmissionBelongsToDifferentUser_ReturnsNotFound() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var submission = CreateAcceptedSubmission(Guid.NewGuid()); + participant.SetActiveSubmission(submission.Id); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + + var command = new CompleteProblemCommand(game.Id, problemId, submission.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_SubmissionNotAccepted_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var submission = new Submission(userId, Guid.NewGuid(), SubmissionType.Submit, new SourceCode("print(1)"), []); + participant.SetActiveSubmission(submission.Id); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + + var command = new CompleteProblemCommand(game.Id, problemId, submission.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameModeNoLongerExists_ReturnsNotFound() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var submission = CreateAcceptedSubmission(userId); + participant.SetActiveSubmission(submission.Id); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var command = new CompleteProblemCommand(game.Id, problemId, submission.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NextProblemAvailable_AdvancesProblemAndReturnsSuccess() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var submission = CreateAcceptedSubmission(userId); + participant.SetActiveSubmission(submission.Id); + var nextProblemId = Guid.NewGuid(); + var gameMode = CreateGameMode(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(nextProblemId); + + var command = new CompleteProblemCommand(game.Id, problemId, submission.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.NextProblemId, Is.EqualTo(nextProblemId)); + Assert.That(result.Value.NewScore, Is.EqualTo(1)); + Assert.That(participant.ProblemSession!.CurrentProblemId, Is.EqualTo(nextProblemId)); + Assert.That(participant.Score, Is.EqualTo(1)); + }); + _gameExpiryCanceller.Verify( + x => x.CancelIfScheduledAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => + x.DispatchAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_PriorSkipsAdvanceThePositionPassedToTheSequencerForDifficultyPacing() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + participant.InitializeProblemSession(Guid.NewGuid()); + participant.SkipToProblem(Guid.NewGuid()); + participant.SkipToProblem(Guid.NewGuid()); + var problemId = participant.ProblemSession!.CurrentProblemId; + var submission = CreateAcceptedSubmission(userId); + participant.SetActiveSubmission(submission.Id); + var gameMode = CreateGameMode(); + int? capturedPosition = null; + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .Callback( + (_, position, _, _, _) => capturedPosition = position + ) + .ReturnsAsync(Guid.NewGuid()); + + var command = new CompleteProblemCommand(game.Id, problemId, submission.Id, userId); + await _handler.Handle(command, CancellationToken.None); + + // Three problems already seen (initial, then two skips) — the fourth slot is position 3. + Assert.That(capturedPosition, Is.EqualTo(3)); + } + + [Test] + public async Task Handle_NoNextProblemAvailable_FinishesProblemsAndCancelsScheduledExpiry() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var submission = CreateAcceptedSubmission(userId); + participant.SetActiveSubmission(submission.Id); + var gameMode = CreateGameMode(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync((Guid?)null); + + var command = new CompleteProblemCommand(game.Id, problemId, submission.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.NextProblemId, Is.Null); + Assert.That(participant.HasFinishedProblems, Is.True); + }); + _gameExpiryCanceller.Verify(x => x.CancelIfScheduledAsync(game, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_SaveChangesThrowsPositionConflictOnce_RetriesWithAFreshReadAndSucceeds() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var winningProblemId = Guid.NewGuid(); + var gameMode = CreateGameMode(); + var submission = CreateAcceptedSubmission(userId); + + Game BuildReadyGame() + { + var g = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + g.Start(); + var p = g.Participants.First(x => x.UserId == userId); + p.InitializeProblemSession(problemId); + p.SetActiveSubmission(submission.Id); + return g; + } + + _gameReadRepository + .SetupSequence(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildReadyGame()) + .ReturnsAsync(BuildReadyGame()); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + It.IsAny(), + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(winningProblemId); + _gameWriteRepository + .SetupSequence(x => x.SaveChangesAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync( + new GameProblemPositionConflictException(Guid.NewGuid(), new InvalidOperationException("conflict")) + ) + .Returns(Task.CompletedTask); + + var command = new CompleteProblemCommand(Guid.NewGuid(), problemId, submission.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.NextProblemId, Is.EqualTo(winningProblemId)); + }); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } + + [Test] + public void Handle_SaveChangesThrowsPositionConflictTwice_PropagatesAfterOneRetry() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var gameMode = CreateGameMode(); + var submission = CreateAcceptedSubmission(userId); + + Game BuildReadyGame() + { + var g = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + g.Start(); + var p = g.Participants.First(x => x.UserId == userId); + p.InitializeProblemSession(problemId); + p.SetActiveSubmission(submission.Id); + return g; + } + + _gameReadRepository + .SetupSequence(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildReadyGame()) + .ReturnsAsync(BuildReadyGame()); + _submissionRepository + .Setup(x => x.FindByIdAsync(submission.Id, It.IsAny())) + .ReturnsAsync(submission); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + It.IsAny(), + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(Guid.NewGuid()); + _gameWriteRepository + .Setup(x => x.SaveChangesAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync( + new GameProblemPositionConflictException(Guid.NewGuid(), new InvalidOperationException("conflict")) + ); + + var command = new CompleteProblemCommand(Guid.NewGuid(), problemId, submission.Id, userId); + + Assert.That( + async () => await _handler.Handle(command, CancellationToken.None), + Throws.TypeOf() + ); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CompleteProblem/CompleteProblemValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/CompleteProblem/CompleteProblemValidatorTests.cs new file mode 100644 index 0000000..92de0c1 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CompleteProblem/CompleteProblemValidatorTests.cs @@ -0,0 +1,66 @@ +using FluentValidation.Results; +using CompleteProblemCommand = StackDuel.Application.Commands.Games.CompleteProblem.CompleteProblemCommand; +using CompleteProblemValidator = StackDuel.Application.Commands.Games.CompleteProblem.CompleteProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CompleteProblem; + +public class CompleteProblemValidatorTests +{ + private CompleteProblemValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new CompleteProblemValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new CompleteProblemCommand(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new CompleteProblemCommand(Guid.Empty, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyProblemId_IsInvalid() + { + var command = new CompleteProblemCommand(Guid.NewGuid(), Guid.Empty, Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptySubmissionId_IsInvalid() + { + var command = new CompleteProblemCommand(Guid.NewGuid(), Guid.NewGuid(), Guid.Empty, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = new CompleteProblemCommand(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CreateGame/CreateGameHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/CreateGame/CreateGameHandlerTests.cs new file mode 100644 index 0000000..0b1b28e --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CreateGame/CreateGameHandlerTests.cs @@ -0,0 +1,398 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Languages; +using StackDuel.Application.Tracks; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Tracks.Entities; +using CreateGameCommand = StackDuel.Application.Commands.Games.CreateGame.CreateGameCommand; +using CreateGameHandler = StackDuel.Application.Commands.Games.CreateGame.CreateGameHandler; +using CreateGameValidator = StackDuel.Application.Commands.Games.CreateGame.CreateGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CreateGame; + +public class CreateGameHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _trackReadRepository = null!; + private Mock _languageReadRepository = null!; + private UserContext _userContext = null!; + private CreateGameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _trackReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _userContext = new UserContext(); + + _handler = new CreateGameHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _trackReadRepository.Object, + _languageReadRepository.Object, + _userContext, + new CreateGameValidator() + ); + } + + private static GameMode CreateDuelMode(int durationSeconds = 600) + { + var mode = new GameMode("duel", "Duel", "Head to head", true, 2, 2, Guid.NewGuid()); + mode.AddTimeOption(durationSeconds); + return mode; + } + + private static Track CreateGeneralPurposeTrack(bool allowsLanguageSelection = true) + { + var track = new Track("general-purpose", "General Purpose"); + if (!allowsLanguageSelection) + track.DisableLanguageSelection(); + return track; + } + + private static Track CreateSqlTrack() => new("sql", "SQL"); + + private static Language CreateLanguage(string name, string slug, Guid trackId) => + new(new LanguageName(name), new LanguageSlug(slug), trackId); + + private void StubTracksFound(params Track[] tracks) => + _trackReadRepository + .Setup(x => x.FindByKeysAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(tracks); + + private void StubLanguagesFound(params Language[] languages) => + _languageReadRepository + .Setup(x => + x.GetActiveLanguagesByTrackIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync(languages); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotQueryGameMode() + { + var command = new CreateGameCommand(string.Empty, [], 0, Guid.Empty); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameModeByKeyAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameModeNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection("general-purpose", [Guid.NewGuid()])], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_TrackNotFound_ReturnsNotFound() + { + var mode = CreateDuelMode(); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(); + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection("frontend", [Guid.NewGuid()])], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_SomeTracksNotFound_ReturnsNotFound() + { + var mode = CreateDuelMode(); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(CreateGeneralPurposeTrack()); + + var command = new CreateGameCommand( + "duel", + [ + new TrackLanguageSelection("general-purpose", [Guid.NewGuid()]), + new TrackLanguageSelection("frontend", [Guid.NewGuid()]), + ], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_LanguageNotAvailableForTrack_ReturnsNotFound() + { + var mode = CreateDuelMode(); + var track = CreateGeneralPurposeTrack(); + var otherTrackLanguage = CreateLanguage("React", "react", Guid.NewGuid()); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(otherTrackLanguage); + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(track.Key, [Guid.NewGuid()])], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_TrackDisallowsLanguageSelection_PartialLanguageSet_ReturnsInvalid() + { + var mode = CreateDuelMode(); + var track = CreateGeneralPurposeTrack(allowsLanguageSelection: false); + var javascript = CreateLanguage("JavaScript", "javascript", track.Id); + var python = CreateLanguage("Python", "python", track.Id); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(javascript, python); + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(track.Key, [javascript.Id])], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameWriteRepository.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_TrackDisallowsLanguageSelection_AllActiveLanguagesIncluded_CreatesGame() + { + var mode = CreateDuelMode(600); + var track = CreateGeneralPurposeTrack(allowsLanguageSelection: false); + var javascript = CreateLanguage("JavaScript", "javascript", track.Id); + var python = CreateLanguage("Python", "python", track.Id); + var userId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(javascript, python); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + Game? addedGame = null; + _gameWriteRepository + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((g, _) => addedGame = g) + .Returns(Task.CompletedTask); + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(track.Key, [javascript.Id, python.Id])], + 600, + userId + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That( + addedGame!.Tracks.Single(t => t.TrackId == track.Id).Languages.Select(l => l.LanguageId), + Is.EquivalentTo(new[] { javascript.Id, python.Id }) + ); + }); + } + + [Test] + public async Task Handle_UserLacksRequiredPermission_ReturnsForbidden() + { + var mode = CreateDuelMode(); + var track = CreateGeneralPurposeTrack(); + var language = CreateLanguage("JavaScript", "javascript", track.Id); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(language); + _userContext.Permissions = []; + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(track.Key, [language.Id])], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_UnmappedGameModeKey_ReturnsForbiddenWithoutCheckingPermission() + { + var mode = new GameMode("mystery", "Mystery", "Unmapped mode", true, 1, 1, Guid.NewGuid()); + mode.AddTimeOption(600); + var track = CreateGeneralPurposeTrack(); + var language = CreateLanguage("JavaScript", "javascript", track.Id); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("mystery", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(language); + + var command = new CreateGameCommand( + "mystery", + [new TrackLanguageSelection(track.Key, [language.Id])], + 600, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_DurationNotOfferedByGameMode_ReturnsInvalid() + { + var mode = CreateDuelMode(600); + var track = CreateGeneralPurposeTrack(); + var language = CreateLanguage("JavaScript", "javascript", track.Id); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(language); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(track.Key, [language.Id])], + 900, + Guid.NewGuid() + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameWriteRepository.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_ValidRequest_CreatesGameAndReturnsSuccess() + { + var mode = CreateDuelMode(600); + var track = CreateGeneralPurposeTrack(); + var language = CreateLanguage("JavaScript", "javascript", track.Id); + var userId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(track); + StubLanguagesFound(language); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + Game? addedGame = null; + _gameWriteRepository + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((g, _) => addedGame = g) + .Returns(Task.CompletedTask); + + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(track.Key, [language.Id])], + 600, + userId + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(addedGame, Is.Not.Null); + Assert.That(addedGame!.GameModeId, Is.EqualTo(mode.Id)); + Assert.That(addedGame.Tracks.Select(t => t.TrackId), Does.Contain(track.Id)); + Assert.That( + addedGame.Tracks.Single(t => t.TrackId == track.Id).Languages.Select(l => l.LanguageId), + Is.EquivalentTo(new[] { language.Id }) + ); + Assert.That(addedGame.Participants.Select(p => p.UserId), Does.Contain(userId)); + Assert.That(result.Value, Is.EqualTo(addedGame.Id)); + }); + } + + [Test] + public async Task Handle_ValidRequest_WithMultipleTracks_CreatesGameWithAllTracks() + { + var mode = CreateDuelMode(600); + var generalPurposeTrack = CreateGeneralPurposeTrack(); + var sqlTrack = CreateSqlTrack(); + var generalPurposeLanguage = CreateLanguage("JavaScript", "javascript", generalPurposeTrack.Id); + var sqlLanguage = CreateLanguage("SQLite", "sqlite", sqlTrack.Id); + var userId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(mode); + StubTracksFound(generalPurposeTrack, sqlTrack); + StubLanguagesFound(generalPurposeLanguage, sqlLanguage); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + Game? addedGame = null; + _gameWriteRepository + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((g, _) => addedGame = g) + .Returns(Task.CompletedTask); + + var command = new CreateGameCommand( + "duel", + [ + new TrackLanguageSelection(generalPurposeTrack.Key, [generalPurposeLanguage.Id]), + new TrackLanguageSelection(sqlTrack.Key, [sqlLanguage.Id]), + ], + 600, + userId + ); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That( + addedGame!.Tracks.Select(t => t.TrackId), + Is.EquivalentTo(new[] { generalPurposeTrack.Id, sqlTrack.Id }) + ); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/CreateGame/CreateGameValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/CreateGame/CreateGameValidatorTests.cs new file mode 100644 index 0000000..5ddb0e2 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/CreateGame/CreateGameValidatorTests.cs @@ -0,0 +1,100 @@ +using FluentValidation.Results; +using StackDuel.Application.Games; +using CreateGameCommand = StackDuel.Application.Commands.Games.CreateGame.CreateGameCommand; +using CreateGameValidator = StackDuel.Application.Commands.Games.CreateGame.CreateGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.CreateGame; + +public class CreateGameValidatorTests +{ + private CreateGameValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new CreateGameValidator(); + } + + private static TrackLanguageSelection[] Selections(params string[] trackKeys) => + [.. trackKeys.Select(key => new TrackLanguageSelection(key, [Guid.NewGuid()]))]; + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new CreateGameCommand("duel", Selections("general-purpose"), 600, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameModeKey_IsInvalid() + { + var command = new CreateGameCommand(string.Empty, Selections("general-purpose"), 600, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyTrackSelections_IsInvalid() + { + var command = new CreateGameCommand("duel", [], 600, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_TrackSelectionWithEmptyTrackKey_IsInvalid() + { + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection(string.Empty, [Guid.NewGuid()])], + 600, + Guid.NewGuid() + ); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_TrackSelectionWithEmptyLanguageIds_IsInvalid() + { + var command = new CreateGameCommand( + "duel", + [new TrackLanguageSelection("general-purpose", [])], + 600, + Guid.NewGuid() + ); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_NonPositiveTimeLimit_IsInvalid() + { + var command = new CreateGameCommand("duel", Selections("general-purpose"), 0, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyCreatedByUserId_IsInvalid() + { + var command = new CreateGameCommand("duel", Selections("general-purpose"), 600, Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/ForfeitGame/ForfeitGameHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/ForfeitGame/ForfeitGameHandlerTests.cs new file mode 100644 index 0000000..1bab390 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/ForfeitGame/ForfeitGameHandlerTests.cs @@ -0,0 +1,173 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using ForfeitGameCommand = StackDuel.Application.Commands.Games.ForfeitGame.ForfeitGameCommand; +using ForfeitGameHandler = StackDuel.Application.Commands.Games.ForfeitGame.ForfeitGameHandler; +using ForfeitGameValidator = StackDuel.Application.Commands.Games.ForfeitGame.ForfeitGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.ForfeitGame; + +public class ForfeitGameHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _gameExpiryCanceller = null!; + private Mock _domainEventDispatcher = null!; + private ForfeitGameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _gameExpiryCanceller = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new ForfeitGameHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _gameExpiryCanceller.Object, + new ForfeitGameValidator(), + _domainEventDispatcher.Object + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalid() + { + var command = new ForfeitGameCommand(Guid.Empty, Guid.Empty); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + var gameId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(gameId, It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new ForfeitGameCommand(gameId, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UserNotParticipant_ReturnsForbidden() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new ForfeitGameCommand(game.Id, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameNotRunning_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new ForfeitGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ParticipantAlreadyForfeited_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId, otherUserId], 600); + game.Start(); + game.Participants.First(p => p.UserId == userId).Forfeit(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new ForfeitGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ParticipantAlreadyFinishedProblems_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId, otherUserId], 600); + game.Start(); + game.Participants.First(p => p.UserId == userId).FinishProblems(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new ForfeitGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_LastActiveParticipantForfeits_CompletesGameAndCancelsScheduledExpiry() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new ForfeitGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(StackDuel.Domain.Games.Enums.GameStatus.Completed)); + Assert.That(game.Participants.First().HasForfeited, Is.True); + }); + _gameExpiryCanceller.Verify(x => x.CancelIfScheduledAsync(game, It.IsAny()), Times.Once); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => + x.DispatchAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_OtherParticipantsStillPlaying_GameRemainsRunning() + { + var userId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId, otherUserId], 600); + game.Start(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new ForfeitGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(StackDuel.Domain.Games.Enums.GameStatus.Running)); + Assert.That(game.Participants.First(p => p.UserId == userId).HasForfeited, Is.True); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/ForfeitGame/ForfeitGameValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/ForfeitGame/ForfeitGameValidatorTests.cs new file mode 100644 index 0000000..a5c5a89 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/ForfeitGame/ForfeitGameValidatorTests.cs @@ -0,0 +1,46 @@ +using FluentValidation.Results; +using ForfeitGameCommand = StackDuel.Application.Commands.Games.ForfeitGame.ForfeitGameCommand; +using ForfeitGameValidator = StackDuel.Application.Commands.Games.ForfeitGame.ForfeitGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.ForfeitGame; + +public class ForfeitGameValidatorTests +{ + private ForfeitGameValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new ForfeitGameValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new ForfeitGameCommand(Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new ForfeitGameCommand(Guid.Empty, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = new ForfeitGameCommand(Guid.NewGuid(), Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/JoinGame/JoinGameHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/JoinGame/JoinGameHandlerTests.cs new file mode 100644 index 0000000..b5c13c6 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/JoinGame/JoinGameHandlerTests.cs @@ -0,0 +1,217 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.SeedWork; +using JoinGameCommand = StackDuel.Application.Commands.Games.JoinGame.JoinGameCommand; +using JoinGameHandler = StackDuel.Application.Commands.Games.JoinGame.JoinGameHandler; +using JoinGameValidator = StackDuel.Application.Commands.Games.JoinGame.JoinGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.JoinGame; + +public class JoinGameHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private UserContext _userContext = null!; + private Mock _domainEventDispatcher = null!; + private JoinGameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _userContext = new UserContext(); + _domainEventDispatcher = new Mock(); + + _handler = new JoinGameHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _userContext, + _domainEventDispatcher.Object, + new JoinGameValidator() + ); + } + + private static GameMode CreateGameMode(string key, int minPlayers, int maxPlayers) => + new(key, key, $"{key} description", isBuiltIn: true, minPlayers, maxPlayers, Guid.NewGuid()); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new JoinGameCommand(Guid.Empty, Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + var gameId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(gameId, It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new JoinGameCommand(gameId, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_GameNotPending_ReturnsInvalid() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + game.Start(); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new JoinGameCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameModeNotFound_ReturnsNotFound() + { + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var command = new JoinGameCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UnknownGameModeKey_ReturnsForbiddenWithoutCallingPermissionService() + { + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + var gameMode = CreateGameMode("unknown_mode", 1, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + + var command = new JoinGameCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_NoPermission_ReturnsForbidden() + { + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + var gameMode = CreateGameMode("duel", 1, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userContext.Permissions = []; + + var command = new JoinGameCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_AlreadyJoined_ReturnsInvalid() + { + var gameModeId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [requesterId], 600); + var gameMode = CreateGameMode("duel", 1, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + var command = new JoinGameCommand(game.Id, requesterId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameFull_ReturnsInvalid() + { + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid(), Guid.NewGuid()], 600); + var gameMode = CreateGameMode("duel", 1, 2); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + var command = new JoinGameCommand(game.Id, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_Success_JoinsGameAndDispatchesEvents() + { + var gameModeId = Guid.NewGuid(); + var requesterId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + var gameMode = CreateGameMode("duel", 1, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userContext.Permissions = [WellKnownAuthorization.PlayDuelPermission]; + + var command = new JoinGameCommand(game.Id, requesterId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Participants.Any(p => p.UserId == requesterId), Is.True); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/JoinGame/JoinGameValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/JoinGame/JoinGameValidatorTests.cs new file mode 100644 index 0000000..c320b0d --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/JoinGame/JoinGameValidatorTests.cs @@ -0,0 +1,46 @@ +using FluentValidation.Results; +using JoinGameCommand = StackDuel.Application.Commands.Games.JoinGame.JoinGameCommand; +using JoinGameValidator = StackDuel.Application.Commands.Games.JoinGame.JoinGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.JoinGame; + +public class JoinGameValidatorTests +{ + private JoinGameValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new JoinGameValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new JoinGameCommand(Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new JoinGameCommand(Guid.Empty, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = new JoinGameCommand(Guid.NewGuid(), Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/JoinGameByCode/JoinGameByCodeHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/JoinGameByCode/JoinGameByCodeHandlerTests.cs new file mode 100644 index 0000000..865ad29 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/JoinGameByCode/JoinGameByCodeHandlerTests.cs @@ -0,0 +1,169 @@ +using Ardalis.Result; +using MediatR; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Domain.Games.Entities; +using JoinGameByCodeCommand = StackDuel.Application.Commands.Games.JoinGameByCode.JoinGameByCodeCommand; +using JoinGameByCodeHandler = StackDuel.Application.Commands.Games.JoinGameByCode.JoinGameByCodeHandler; +using JoinGameByCodeValidator = StackDuel.Application.Commands.Games.JoinGameByCode.JoinGameByCodeValidator; +using JoinGameCommand = StackDuel.Application.Commands.Games.JoinGame.JoinGameCommand; + +namespace StackDuel.Application.Tests.Commands.Games.JoinGameByCode; + +public class JoinGameByCodeHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _mediator = null!; + private JoinGameByCodeHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _mediator = new Mock(); + + _handler = new JoinGameByCodeHandler( + _gameReadRepository.Object, + _mediator.Object, + new JoinGameByCodeValidator() + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new JoinGameByCodeCommand(string.Empty, Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_CodeNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new JoinGameByCodeCommand("ABCDEFG", Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_InvalidCodeLength_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new JoinGameByCodeCommand("ABC", Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotPendingAndNotAParticipant_ReturnsInvalid() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + game.Start(); + + _gameReadRepository + .Setup(x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new JoinGameByCodeCommand(game.JoinCode, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_AlreadyAParticipantInAPendingLobby_RedirectsWithoutDelegatingToJoin() + { + var requesterId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [requesterId], 600); + + _gameReadRepository + .Setup(x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new JoinGameByCodeCommand(game.JoinCode, requesterId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(game.Id)); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_AlreadyAParticipantInARunningGame_StillRedirects() + { + var requesterId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [requesterId, Guid.NewGuid()], 600); + game.Start(); + + _gameReadRepository + .Setup(x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new JoinGameByCodeCommand(game.JoinCode, requesterId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(game.Id)); + } + + [Test] + public async Task Handle_UnderlyingJoinFails_PropagatesFailure() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + + _gameReadRepository + .Setup(x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _mediator + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Forbidden()); + + var command = new JoinGameByCodeCommand(game.JoinCode, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_Success_ReturnsGameId() + { + var requesterId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + + _gameReadRepository + .Setup(x => x.FindGameByJoinCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _mediator + .Setup(x => + x.Send( + It.Is(c => c.GameId == game.Id && c.RequestedByUserId == requesterId), + It.IsAny() + ) + ) + .ReturnsAsync(Result.Success()); + + var command = new JoinGameByCodeCommand(game.JoinCode, requesterId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(game.Id)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/LeaveGame/LeaveGameHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/LeaveGame/LeaveGameHandlerTests.cs new file mode 100644 index 0000000..fd8a30e --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/LeaveGame/LeaveGameHandlerTests.cs @@ -0,0 +1,135 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.SeedWork; +using LeaveGameCommand = StackDuel.Application.Commands.Games.LeaveGame.LeaveGameCommand; +using LeaveGameHandler = StackDuel.Application.Commands.Games.LeaveGame.LeaveGameHandler; +using LeaveGameValidator = StackDuel.Application.Commands.Games.LeaveGame.LeaveGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.LeaveGame; + +public class LeaveGameHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _domainEventDispatcher = null!; + private LeaveGameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new LeaveGameHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _domainEventDispatcher.Object, + new LeaveGameValidator() + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new LeaveGameCommand(Guid.Empty, Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new LeaveGameCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NotParticipant_ReturnsForbidden() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new LeaveGameCommand(game.Id, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameNotPending_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId, Guid.NewGuid()], 600); + game.Start(); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new LeaveGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_Success_RemovesParticipantAndKeepsLobbyOpen() + { + var userId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId, otherUserId], 600); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new LeaveGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Participants.Any(p => p.UserId == userId), Is.False); + Assert.That(game.Status, Is.EqualTo(GameStatus.Pending)); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_LastParticipantLeaves_CancelsLobby() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new LeaveGameCommand(game.Id, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(GameStatus.Cancelled)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/LeaveGame/LeaveGameValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/LeaveGame/LeaveGameValidatorTests.cs new file mode 100644 index 0000000..b98b4b9 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/LeaveGame/LeaveGameValidatorTests.cs @@ -0,0 +1,46 @@ +using FluentValidation.Results; +using LeaveGameCommand = StackDuel.Application.Commands.Games.LeaveGame.LeaveGameCommand; +using LeaveGameValidator = StackDuel.Application.Commands.Games.LeaveGame.LeaveGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.LeaveGame; + +public class LeaveGameValidatorTests +{ + private LeaveGameValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new LeaveGameValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new LeaveGameCommand(Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new LeaveGameCommand(Guid.Empty, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = new LeaveGameCommand(Guid.NewGuid(), Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/SkipProblem/SkipProblemHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/SkipProblem/SkipProblemHandlerTests.cs new file mode 100644 index 0000000..bd322ee --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/SkipProblem/SkipProblemHandlerTests.cs @@ -0,0 +1,441 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using SkipProblemCommand = StackDuel.Application.Commands.Games.SkipProblem.SkipProblemCommand; +using SkipProblemHandler = StackDuel.Application.Commands.Games.SkipProblem.SkipProblemHandler; +using SkipProblemResultDto = StackDuel.Application.Commands.Games.SkipProblem.SkipProblemResultDto; +using SkipProblemValidator = StackDuel.Application.Commands.Games.SkipProblem.SkipProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Games.SkipProblem; + +public class SkipProblemHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _gameExpiryCanceller = null!; + private Mock _domainEventDispatcher = null!; + private Mock _strategyResolver = null!; + private Mock _strategy = null!; + private Mock _gameProblemSequencer = null!; + private SkipProblemHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _gameExpiryCanceller = new Mock(); + _domainEventDispatcher = new Mock(); + _strategyResolver = new Mock(); + _strategy = new Mock(); + _gameProblemSequencer = new Mock(); + + _strategyResolver.Setup(x => x.Resolve(It.IsAny())).Returns(_strategy.Object); + + var gameProblemAdvancer = new GameProblemAdvancer( + _gameProblemSequencer.Object, + _gameExpiryCanceller.Object, + _strategyResolver.Object + ); + + _handler = new SkipProblemHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _domainEventDispatcher.Object, + gameProblemAdvancer, + new SkipProblemValidator() + ); + } + + private static GameMode CreateGameMode(string key = "solo_rush") => + new(key, "Solo Rush", "Solo rush mode", true, 1, 1, Guid.NewGuid()); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalid() + { + var command = new SkipProblemCommand(Guid.Empty, Guid.Empty, Guid.Empty); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + var gameId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(gameId, It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new SkipProblemCommand(gameId, Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UserNotParticipant_ReturnsForbidden() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameNotRunning_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_SkipsDisabledForGame_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600, skipsEnabled: false); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ParticipantHasForfeited_ReturnsInvalid() + { + var forfeitingUserId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [forfeitingUserId, otherUserId], 600); + game.Start(); + game.Participants.First(p => p.UserId == forfeitingUserId).Forfeit(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, Guid.NewGuid(), forfeitingUserId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ProblemSessionNotInitialized_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ProblemIdDoesNotMatchCurrentProblem_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + participant.InitializeProblemSession(Guid.NewGuid()); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_NoSkipsRemaining_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + for (int i = 0; i < GameParticipant.TotalSkips; i++) + participant.UseSkip(); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var command = new SkipProblemCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameModeNoLongerExists_ReturnsNotFound() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var command = new SkipProblemCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NextProblemAvailable_SkipsToItWithoutAffectingScore() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var nextProblemId = Guid.NewGuid(); + var gameMode = CreateGameMode(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(nextProblemId); + + var command = new SkipProblemCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.NextProblemId, Is.EqualTo(nextProblemId)); + Assert.That(result.Value.SkipsRemaining, Is.EqualTo(GameParticipant.TotalSkips - 1)); + Assert.That(participant.ProblemSession!.CurrentProblemId, Is.EqualTo(nextProblemId)); + Assert.That(participant.ProblemSession.SkippedProblemIds, Does.Contain(problemId)); + Assert.That(participant.Score, Is.EqualTo(0)); + }); + _gameExpiryCanceller.Verify( + x => x.CancelIfScheduledAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_SkippingAdvancesThePositionPassedToTheSequencerForDifficultyPacing() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + participant.InitializeProblemSession(Guid.NewGuid()); + participant.SkipToProblem(Guid.NewGuid()); + var problemId = participant.ProblemSession!.CurrentProblemId; + var gameMode = CreateGameMode(); + int? capturedPosition = null; + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .Callback( + (_, position, _, _, _) => capturedPosition = position + ) + .ReturnsAsync(Guid.NewGuid()); + + var command = new SkipProblemCommand(game.Id, problemId, userId); + await _handler.Handle(command, CancellationToken.None); + + // Two problems already seen (the initial one, then the one skipped to) — the third slot + // is position 2. + Assert.That(capturedPosition, Is.EqualTo(2)); + } + + [Test] + public async Task Handle_NoNextProblemAvailable_FinishesProblemsAndCancelsScheduledExpiry() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.First(p => p.UserId == userId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + var gameMode = CreateGameMode(); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync((Guid?)null); + + var command = new SkipProblemCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.NextProblemId, Is.Null); + Assert.That(result.Value.SkipsRemaining, Is.EqualTo(GameParticipant.TotalSkips - 1)); + Assert.That(participant.HasFinishedProblems, Is.True); + }); + _gameExpiryCanceller.Verify(x => x.CancelIfScheduledAsync(game, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_SaveChangesThrowsPositionConflictOnce_RetriesWithAFreshReadAndSucceeds() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var winningProblemId = Guid.NewGuid(); + var gameMode = CreateGameMode(); + var gameId = Guid.NewGuid(); + + Game BuildReadyGame() + { + var g = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + g.Start(); + g.Participants.First(p => p.UserId == userId).InitializeProblemSession(problemId); + return g; + } + + _gameReadRepository + .SetupSequence(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildReadyGame()) + .ReturnsAsync(BuildReadyGame()); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + It.IsAny(), + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(winningProblemId); + _gameWriteRepository + .SetupSequence(x => x.SaveChangesAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new GameProblemPositionConflictException(gameId, new InvalidOperationException("conflict"))) + .Returns(Task.CompletedTask); + + var command = new SkipProblemCommand(gameId, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.NextProblemId, Is.EqualTo(winningProblemId)); + }); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } + + [Test] + public void Handle_SaveChangesThrowsPositionConflictTwice_PropagatesAfterOneRetry() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var gameMode = CreateGameMode(); + var gameId = Guid.NewGuid(); + + Game BuildReadyGame() + { + var g = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + g.Start(); + g.Participants.First(p => p.UserId == userId).InitializeProblemSession(problemId); + return g; + } + + _gameReadRepository + .SetupSequence(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(BuildReadyGame()) + .ReturnsAsync(BuildReadyGame()); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(gameMode); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + It.IsAny(), + It.IsAny(), + gameMode.Key, + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(Guid.NewGuid()); + _gameWriteRepository + .Setup(x => x.SaveChangesAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new GameProblemPositionConflictException(gameId, new InvalidOperationException("conflict"))); + + var command = new SkipProblemCommand(gameId, problemId, userId); + + Assert.That( + async () => await _handler.Handle(command, CancellationToken.None), + Throws.TypeOf() + ); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/StartGame/StartGameHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/StartGame/StartGameHandlerTests.cs new file mode 100644 index 0000000..a22aa2f --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/StartGame/StartGameHandlerTests.cs @@ -0,0 +1,302 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.SeedWork; +using StartGameCommand = StackDuel.Application.Commands.Games.StartGame.StartGameCommand; +using StartGameHandler = StackDuel.Application.Commands.Games.StartGame.StartGameHandler; +using StartGameValidator = StackDuel.Application.Commands.Games.StartGame.StartGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.StartGame; + +public class StartGameHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _strategyResolver = null!; + private Mock _strategy = null!; + private Mock _gameProblemSequencer = null!; + private Mock _domainEventDispatcher = null!; + private StartGameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _strategyResolver = new Mock(); + _strategy = new Mock(); + _gameProblemSequencer = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new StartGameHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _strategyResolver.Object, + _gameProblemSequencer.Object, + _domainEventDispatcher.Object, + new StartGameValidator() + ); + } + + private static GameMode CreateGameMode(string key, int minPlayers, int maxPlayers) => + new(key, key, $"{key} description", isBuiltIn: true, minPlayers, maxPlayers, Guid.NewGuid()); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new StartGameCommand(Guid.Empty, Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = new StartGameCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NotHost_ReturnsForbidden() + { + var hostId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId, Guid.NewGuid()], 600); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new StartGameCommand(game.Id, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameNotPending_ReturnsInvalid() + { + var hostId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + game.Start(); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GameModeNotFound_ReturnsNotFoundAndDoesNotStartGame() + { + var hostId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(game.Status, Is.EqualTo(GameStatus.Pending)); + } + + [Test] + public async Task Handle_NotEnoughPlayers_ReturnsInvalidAndDoesNotStartGame() + { + var hostId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + var gameMode = CreateGameMode("duel", 2, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + Assert.That(game.Status, Is.EqualTo(GameStatus.Pending)); + } + + [Test] + public async Task Handle_StrategyNotResolved_ReturnsNotFound() + { + var hostId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + var gameMode = CreateGameMode("duel", 1, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _strategyResolver.Setup(x => x.Resolve("duel")).Returns((IProblemSelectionStrategy)null!); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_NoInitialProblemSelected_ReturnsInvalid() + { + var hostId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + var gameMode = CreateGameMode("duel", 1, 4); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _strategyResolver.Setup(x => x.Resolve("duel")).Returns(_strategy.Object); + _gameProblemSequencer + .Setup(x => x.GetOrGenerateProblemAsync(game, 0, "duel", _strategy.Object, It.IsAny())) + .ReturnsAsync((Guid?)null); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_Success_StartsGameAndInitializesProblemSessions() + { + var hostId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId, otherId], 600); + var gameMode = CreateGameMode("duel", 1, 4); + var initialProblemId = Guid.NewGuid(); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _strategyResolver.Setup(x => x.Resolve("duel")).Returns(_strategy.Object); + _gameProblemSequencer + .Setup(x => x.GetOrGenerateProblemAsync(game, 0, "duel", _strategy.Object, It.IsAny())) + .ReturnsAsync(initialProblemId); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(GameStatus.Running)); + Assert.That(game.Participants.All(p => p.ProblemSession!.CurrentProblemId == initialProblemId), Is.True); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Once + ); + } + + [TestCase("duel")] + [TestCase("ffa")] + public async Task Handle_MultiplayerMode_DelaysStartedAtForASharedCountdown(string gameModeKey) + { + var hostId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + var gameMode = CreateGameMode(gameModeKey, 1, 4); + DateTime beforeStart = DateTime.UtcNow; + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _strategyResolver.Setup(x => x.Resolve(gameModeKey)).Returns(_strategy.Object); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync(game, 0, gameModeKey, _strategy.Object, It.IsAny()) + ) + .ReturnsAsync(Guid.NewGuid()); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That( + game.StartedAt, + Is.GreaterThan(beforeStart.AddSeconds(1)), + "multiplayer modes should get a real countdown window before StartedAt" + ); + } + + [Test] + public async Task Handle_SoloRush_DoesNotDelayStartedAt() + { + var hostId = Guid.NewGuid(); + var gameModeId = Guid.NewGuid(); + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], [hostId], 600); + var gameMode = CreateGameMode("solo_rush", 1, 1); + DateTime beforeStart = DateTime.UtcNow; + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(gameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _strategyResolver.Setup(x => x.Resolve("solo_rush")).Returns(_strategy.Object); + _gameProblemSequencer + .Setup(x => + x.GetOrGenerateProblemAsync(game, 0, "solo_rush", _strategy.Object, It.IsAny()) + ) + .ReturnsAsync(Guid.NewGuid()); + + var command = new StartGameCommand(game.Id, hostId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.StartedAt, Is.LessThan(beforeStart.AddSeconds(1))); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/StartGame/StartGameValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/StartGame/StartGameValidatorTests.cs new file mode 100644 index 0000000..7bd260f --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/StartGame/StartGameValidatorTests.cs @@ -0,0 +1,46 @@ +using FluentValidation.Results; +using StartGameCommand = StackDuel.Application.Commands.Games.StartGame.StartGameCommand; +using StartGameValidator = StackDuel.Application.Commands.Games.StartGame.StartGameValidator; + +namespace StackDuel.Application.Tests.Commands.Games.StartGame; + +public class StartGameValidatorTests +{ + private StartGameValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new StartGameValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new StartGameCommand(Guid.NewGuid(), Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = new StartGameCommand(Guid.Empty, Guid.NewGuid()); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = new StartGameCommand(Guid.NewGuid(), Guid.Empty); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/SubmitGameProblem/SubmitGameProblemHandlerTests.cs b/StackDuel.Application.Tests/Commands/Games/SubmitGameProblem/SubmitGameProblemHandlerTests.cs new file mode 100644 index 0000000..5629701 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/SubmitGameProblem/SubmitGameProblemHandlerTests.cs @@ -0,0 +1,257 @@ +using Ardalis.Result; +using MediatR; +using Moq; +using StackDuel.Application.Commands.Submissions.CreateSubmission; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using SubmitGameProblemCommand = StackDuel.Application.Commands.Games.SubmitGameProblem.SubmitGameProblemCommand; +using SubmitGameProblemHandler = StackDuel.Application.Commands.Games.SubmitGameProblem.SubmitGameProblemHandler; +using SubmitGameProblemValidator = StackDuel.Application.Commands.Games.SubmitGameProblem.SubmitGameProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Games.SubmitGameProblem; + +public class SubmitGameProblemHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _domainEventDispatcher = null!; + private Mock _mediator = null!; + private SubmitGameProblemHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _domainEventDispatcher = new Mock(); + _mediator = new Mock(); + + _handler = new SubmitGameProblemHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _domainEventDispatcher.Object, + _mediator.Object, + new SubmitGameProblemValidator() + ); + } + + private static SubmitGameProblemCommand CreateCommand(Guid gameId, Guid problemId, Guid requestedByUserId) => + new(gameId, problemId, Guid.NewGuid(), "print('hi')", requestedByUserId); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new SubmitGameProblemCommand(Guid.Empty, Guid.NewGuid(), Guid.NewGuid(), "code", Guid.NewGuid()); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _gameReadRepository.Verify( + x => x.FindGameByIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var command = CreateCommand(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NotParticipant_ReturnsForbidden() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 600); + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = CreateCommand(game.Id, Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameNotRunning_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = CreateCommand(game.Id, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_ParticipantForfeited_ReturnsInvalid() + { + var forfeitedUserId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [forfeitedUserId, otherUserId], 600); + game.Start(); + game.Forfeit(forfeitedUserId); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = CreateCommand(game.Id, Guid.NewGuid(), forfeitedUserId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + Assert.That(game.Status, Is.EqualTo(GameStatus.Running)); + } + + [Test] + public async Task Handle_ParticipantFinishedProblems_ReturnsInvalid() + { + var finishedUserId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [finishedUserId, otherUserId], 600); + game.Start(); + game.FinishProblemsFor(finishedUserId); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = CreateCommand(game.Id, Guid.NewGuid(), finishedUserId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ProblemSessionNotInitialized_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = CreateCommand(game.Id, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ProblemDoesNotMatchCurrentSession_ReturnsInvalid() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.Single(); + participant.InitializeProblemSession(Guid.NewGuid()); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + + var command = CreateCommand(game.Id, Guid.NewGuid(), userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_SubmissionCreationFails_ReturnsError() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.Single(); + participant.InitializeProblemSession(problemId); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _mediator + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Error("boom")); + + var command = CreateCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); + Assert.That(participant.ProblemSession!.ActiveSubmissionId, Is.Null); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_Success_SetsActiveSubmissionAndPersists() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.Single(); + participant.InitializeProblemSession(problemId); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _mediator + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(submissionId)); + + var command = CreateCommand(game.Id, problemId, userId); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(submissionId)); + Assert.That(participant.ProblemSession!.ActiveSubmissionId, Is.EqualTo(submissionId)); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_Success_PassesGameIdToCreateSubmissionCommand() + { + var userId = Guid.NewGuid(); + var problemId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + var participant = game.Participants.Single(); + participant.InitializeProblemSession(problemId); + + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(game); + _mediator + .Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(Guid.NewGuid())); + + var command = CreateCommand(game.Id, problemId, userId); + await _handler.Handle(command, CancellationToken.None); + + _mediator.Verify( + x => x.Send(It.Is(c => c.GameId == game.Id), It.IsAny()), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Games/SubmitGameProblem/SubmitGameProblemValidatorTests.cs b/StackDuel.Application.Tests/Commands/Games/SubmitGameProblem/SubmitGameProblemValidatorTests.cs new file mode 100644 index 0000000..d273aff --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Games/SubmitGameProblem/SubmitGameProblemValidatorTests.cs @@ -0,0 +1,77 @@ +using FluentValidation.Results; +using SubmitGameProblemCommand = StackDuel.Application.Commands.Games.SubmitGameProblem.SubmitGameProblemCommand; +using SubmitGameProblemValidator = StackDuel.Application.Commands.Games.SubmitGameProblem.SubmitGameProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Games.SubmitGameProblem; + +public class SubmitGameProblemValidatorTests +{ + private SubmitGameProblemValidator _validator = null!; + + private static SubmitGameProblemCommand ValidCommand() => + new(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "print('hi')", Guid.NewGuid()); + + [SetUp] + public void SetUp() + { + _validator = new SubmitGameProblemValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + ValidationResult result = _validator.Validate(ValidCommand()); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_EmptyGameId_IsInvalid() + { + var command = ValidCommand() with { GameId = Guid.Empty }; + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyProblemId_IsInvalid() + { + var command = ValidCommand() with { ProblemId = Guid.Empty }; + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyProblemSetupId_IsInvalid() + { + var command = ValidCommand() with { ProblemSetupId = Guid.Empty }; + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyCode_IsInvalid() + { + var command = ValidCommand() with { Code = string.Empty }; + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_EmptyRequestedByUserId_IsInvalid() + { + var command = ValidCommand() with { RequestedByUserId = Guid.Empty }; + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolHandlerTests.cs b/StackDuel.Application.Tests/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolHandlerTests.cs new file mode 100644 index 0000000..87b7c31 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolHandlerTests.cs @@ -0,0 +1,136 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using AddProblemsToPoolCommand = StackDuel.Application.Commands.ProblemPools.AddProblemsToPool.AddProblemsToPoolCommand; +using AddProblemsToPoolHandler = StackDuel.Application.Commands.ProblemPools.AddProblemsToPool.AddProblemsToPoolHandler; +using AddProblemsToPoolValidator = StackDuel.Application.Commands.ProblemPools.AddProblemsToPool.AddProblemsToPoolValidator; + +namespace StackDuel.Application.Tests.Commands.ProblemPools.AddProblemsToPool; + +public class AddProblemsToPoolHandlerTests +{ + private Mock _problemPoolRepository = null!; + private Mock _problemReadRepository = null!; + private AddProblemsToPoolHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemPoolRepository = new Mock(); + _problemReadRepository = new Mock(); + + _handler = new AddProblemsToPoolHandler( + _problemPoolRepository.Object, + _problemReadRepository.Object, + new AddProblemsToPoolValidator() + ); + } + + private static AdminProblemListRowDto CreateRow(Guid id) => + new(id, $"slug-{id}", $"Title {id}", 100, ProblemStatus.Published, 1000, 64, [], [], 1, DateTime.UtcNow, null); + + [Test] + public async Task Handle_PoolNotFound_ReturnsNotFound() + { + _problemPoolRepository + .Setup(x => x.FindByKeyAsync("missing-pool", It.IsAny())) + .ReturnsAsync((ProblemPool?)null); + + var command = new AddProblemsToPoolCommand("missing-pool", [Guid.NewGuid()], false, null, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ExplicitIds_NoSelection_ReturnsInvalid() + { + var command = new AddProblemsToPoolCommand("pool", [], false, null, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_ExplicitIds_AddsOnlyExistingProblemsAndReturnsDelta() + { + var pool = new ProblemPool("pool", "Pool"); + Guid existingId1 = Guid.NewGuid(); + Guid existingId2 = Guid.NewGuid(); + Guid staleId = Guid.NewGuid(); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + _problemReadRepository + .Setup(x => + x.FindByIdsAsync(It.Is>(ids => ids.Count == 3), It.IsAny()) + ) + .ReturnsAsync([CreateRow(existingId1), CreateRow(existingId2)]); + + var command = new AddProblemsToPoolCommand("pool", [existingId1, existingId2, staleId], false, null, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(2)); + Assert.That(pool.ProblemIds, Is.EquivalentTo(new[] { existingId1, existingId2 })); + }); + _problemPoolRepository.Verify(x => x.UpdateAsync(pool, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_ExplicitIds_AlreadyMemberDoesNotCountTowardDelta() + { + var pool = new ProblemPool("pool", "Pool"); + Guid alreadyMemberId = Guid.NewGuid(); + Guid newId = Guid.NewGuid(); + pool.AddProblem(alreadyMemberId); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + _problemReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([CreateRow(alreadyMemberId), CreateRow(newId)]); + + var command = new AddProblemsToPoolCommand("pool", [alreadyMemberId, newId], false, null, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(1)); + Assert.That(pool.ProblemIds, Is.EquivalentTo(new[] { alreadyMemberId, newId })); + }); + } + + [Test] + public async Task Handle_SelectAllMatching_ExcludesGivenIdsAndAddsRest() + { + var pool = new ProblemPool("pool", "Pool"); + Guid keep1 = Guid.NewGuid(); + Guid keep2 = Guid.NewGuid(); + Guid excluded = Guid.NewGuid(); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + _problemReadRepository + .Setup(x => x.GetAdminProblemIdsMatchingAsync("array", It.IsAny())) + .ReturnsAsync([keep1, keep2, excluded]); + + var command = new AddProblemsToPoolCommand("pool", [], true, "array", [excluded]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(2)); + Assert.That(pool.ProblemIds, Is.EquivalentTo(new[] { keep1, keep2 })); + Assert.That(pool.ProblemIds, Does.Not.Contain(excluded)); + }); + _problemReadRepository.Verify( + x => x.FindByIdsAsync(It.IsAny>(), It.IsAny()), + Times.Never + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolHandlerTests.cs b/StackDuel.Application.Tests/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolHandlerTests.cs new file mode 100644 index 0000000..2d88948 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolHandlerTests.cs @@ -0,0 +1,101 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; +using ReorderProblemPoolCommand = StackDuel.Application.Commands.ProblemPools.ReorderProblemPool.ReorderProblemPoolCommand; +using ReorderProblemPoolHandler = StackDuel.Application.Commands.ProblemPools.ReorderProblemPool.ReorderProblemPoolHandler; +using ReorderProblemPoolValidator = StackDuel.Application.Commands.ProblemPools.ReorderProblemPool.ReorderProblemPoolValidator; + +namespace StackDuel.Application.Tests.Commands.ProblemPools.ReorderProblemPool; + +public class ReorderProblemPoolHandlerTests +{ + private Mock _problemPoolRepository = null!; + private ReorderProblemPoolHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemPoolRepository = new Mock(); + _handler = new ReorderProblemPoolHandler(_problemPoolRepository.Object, new ReorderProblemPoolValidator()); + } + + [Test] + public async Task Handle_PoolNotFound_ReturnsNotFound() + { + _problemPoolRepository + .Setup(x => x.FindByKeyAsync("missing", It.IsAny())) + .ReturnsAsync((ProblemPool?)null); + + var command = new ReorderProblemPoolCommand("missing", [Guid.NewGuid()]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_EmptyProblemIds_ReturnsInvalid() + { + var command = new ReorderProblemPoolCommand("pool", []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } + + [Test] + public async Task Handle_GivenOrderMatchesCurrentMembers_ReordersAndSaves() + { + var pool = new ProblemPool("pool", "Pool"); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + Guid third = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(second); + pool.AddProblem(third); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + + var command = new ReorderProblemPoolCommand("pool", [third, first, second]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(pool.ProblemIds, Is.EqualTo(new[] { third, first, second })); + }); + _problemPoolRepository.Verify(x => x.UpdateAsync(pool, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_MissingAMember_ReturnsInvalidAndDoesNotSave() + { + var pool = new ProblemPool("pool", "Pool"); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(second); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + + var command = new ReorderProblemPoolCommand("pool", [first]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _problemPoolRepository.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_IncludesUnknownId_ReturnsInvalid() + { + var pool = new ProblemPool("pool", "Pool"); + Guid member = Guid.NewGuid(); + pool.AddProblem(member); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + + var command = new ReorderProblemPoolCommand("pool", [Guid.NewGuid()]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Problems/SetProblemReaction/SetProblemReactionHandlerTests.cs b/StackDuel.Application.Tests/Commands/Problems/SetProblemReaction/SetProblemReactionHandlerTests.cs new file mode 100644 index 0000000..45ac18b --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Problems/SetProblemReaction/SetProblemReactionHandlerTests.cs @@ -0,0 +1,255 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using SetProblemReactionCommand = StackDuel.Application.Commands.Problems.SetProblemReaction.SetProblemReactionCommand; +using SetProblemReactionHandler = StackDuel.Application.Commands.Problems.SetProblemReaction.SetProblemReactionHandler; +using SetProblemReactionValidator = StackDuel.Application.Commands.Problems.SetProblemReaction.SetProblemReactionValidator; + +namespace StackDuel.Application.Tests.Commands.Problems.SetProblemReaction; + +public class SetProblemReactionHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _problemReactionReadRepository = null!; + private Mock _problemReactionWriteRepository = null!; + private SetProblemReactionHandler _handler = null!; + + private static readonly ProblemReactionSummaryDto EmptySummary = new([], null); + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _problemReactionReadRepository = new Mock(); + _problemReactionWriteRepository = new Mock(); + + _handler = new SetProblemReactionHandler( + new SetProblemReactionValidator(), + _problemReadRepository.Object, + _problemReactionReadRepository.Object, + _problemReactionWriteRepository.Object + ); + + _problemReactionReadRepository + .Setup(x => x.GetSummaryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(EmptySummary); + } + + private static SetProblemReactionCommand ValidCommand(string reactionTypeKey = "like") => + new(Guid.NewGuid(), Guid.NewGuid(), reactionTypeKey); + + private static ProblemReactionType CreateReactionType(string key) => new(key, key, null, sortOrder: 1); + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepositories() + { + var command = new SetProblemReactionCommand(Guid.Empty, Guid.NewGuid(), "like"); + + var result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _problemReadRepository.Verify(x => x.ExistsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_ProblemNotFound_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UnknownReactionType_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _problemReactionReadRepository + .Setup(x => x.FindReactionTypeByKeyAsync("bogus", It.IsAny())) + .ReturnsAsync((ProblemReactionType?)null); + + var result = await _handler.Handle(ValidCommand("bogus"), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + _problemReactionWriteRepository.Verify( + x => + x.FindByProblemUserAndTypeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_NoExistingRow_CreatesActiveReaction() + { + var reactionType = CreateReactionType("like"); + _problemReadRepository + .Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _problemReactionReadRepository + .Setup(x => x.FindReactionTypeByKeyAsync("like", It.IsAny())) + .ReturnsAsync(reactionType); + _problemReactionWriteRepository + .Setup(x => + x.FindByProblemUserAndTypeAsync( + It.IsAny(), + It.IsAny(), + reactionType.Id, + It.IsAny() + ) + ) + .ReturnsAsync((ProblemReaction?)null); + _problemReactionWriteRepository + .Setup(x => + x.FindActiveByProblemAndUserAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync((ProblemReaction?)null); + + var result = await _handler.Handle(ValidCommand("like"), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _problemReactionWriteRepository.Verify( + x => + x.AddAsync( + It.Is(r => r.ReactionTypeId == reactionType.Id && r.IsActive), + It.IsAny() + ), + Times.Once + ); + _problemReactionWriteRepository.Verify( + x => x.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_ExistingActiveRowSameKindClickedAgain_Deactivates() + { + var reactionType = CreateReactionType("like"); + var existing = new ProblemReaction(Guid.NewGuid(), Guid.NewGuid(), reactionType.Id); + + _problemReadRepository + .Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _problemReactionReadRepository + .Setup(x => x.FindReactionTypeByKeyAsync("like", It.IsAny())) + .ReturnsAsync(reactionType); + _problemReactionWriteRepository + .Setup(x => + x.FindByProblemUserAndTypeAsync( + It.IsAny(), + It.IsAny(), + reactionType.Id, + It.IsAny() + ) + ) + .ReturnsAsync(existing); + + var result = await _handler.Handle(ValidCommand("like"), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(existing.IsActive, Is.False); + _problemReactionWriteRepository.Verify(x => x.UpdateAsync(existing, It.IsAny()), Times.Once); + _problemReactionWriteRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _problemReactionWriteRepository.Verify( + x => x.FindActiveByProblemAndUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_ExistingInactiveRowSameKind_Reactivates() + { + var reactionType = CreateReactionType("like"); + var existing = new ProblemReaction(Guid.NewGuid(), Guid.NewGuid(), reactionType.Id); + existing.Deactivate(); + + _problemReadRepository + .Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _problemReactionReadRepository + .Setup(x => x.FindReactionTypeByKeyAsync("like", It.IsAny())) + .ReturnsAsync(reactionType); + _problemReactionWriteRepository + .Setup(x => + x.FindByProblemUserAndTypeAsync( + It.IsAny(), + It.IsAny(), + reactionType.Id, + It.IsAny() + ) + ) + .ReturnsAsync(existing); + _problemReactionWriteRepository + .Setup(x => + x.FindActiveByProblemAndUserAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync((ProblemReaction?)null); + + var result = await _handler.Handle(ValidCommand("like"), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(existing.IsActive, Is.True); + _problemReactionWriteRepository.Verify(x => x.UpdateAsync(existing, It.IsAny()), Times.Once); + _problemReactionWriteRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_ActivatingDifferentKindWhileAnotherIsActive_DeactivatesOldFirst() + { + var likeType = CreateReactionType("like"); + var dislikeType = CreateReactionType("dislike"); + var problemId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var activeDislike = new ProblemReaction(problemId, userId, dislikeType.Id); + + _problemReadRepository + .Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _problemReactionReadRepository + .Setup(x => x.FindReactionTypeByKeyAsync("like", It.IsAny())) + .ReturnsAsync(likeType); + _problemReactionWriteRepository + .Setup(x => x.FindByProblemUserAndTypeAsync(problemId, userId, likeType.Id, It.IsAny())) + .ReturnsAsync((ProblemReaction?)null); + _problemReactionWriteRepository + .Setup(x => x.FindActiveByProblemAndUserAsync(problemId, userId, It.IsAny())) + .ReturnsAsync(activeDislike); + + var command = new SetProblemReactionCommand(problemId, userId, "like"); + var result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(activeDislike.IsActive, Is.False); + _problemReactionWriteRepository.Verify( + x => x.UpdateAsync(activeDislike, It.IsAny()), + Times.Once + ); + _problemReactionWriteRepository.Verify( + x => + x.AddAsync( + It.Is(r => r.ReactionTypeId == likeType.Id && r.IsActive), + It.IsAny() + ), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Problems/SetProblemReaction/SetProblemReactionValidatorTests.cs b/StackDuel.Application.Tests/Commands/Problems/SetProblemReaction/SetProblemReactionValidatorTests.cs new file mode 100644 index 0000000..2569d18 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Problems/SetProblemReaction/SetProblemReactionValidatorTests.cs @@ -0,0 +1,44 @@ +using SetProblemReactionCommand = StackDuel.Application.Commands.Problems.SetProblemReaction.SetProblemReactionCommand; +using SetProblemReactionValidator = StackDuel.Application.Commands.Problems.SetProblemReaction.SetProblemReactionValidator; + +namespace StackDuel.Application.Tests.Commands.Problems.SetProblemReaction; + +public class SetProblemReactionValidatorTests +{ + private SetProblemReactionValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new SetProblemReactionValidator(); + } + + private static SetProblemReactionCommand ValidCommand() => new(Guid.NewGuid(), Guid.NewGuid(), "like"); + + [Test] + public void Validate_ValidCommand_IsValid() + { + Assert.That(_validator.Validate(ValidCommand()).IsValid, Is.True); + } + + [Test] + public void Validate_EmptyProblemId_IsInvalid() + { + var command = ValidCommand() with { ProblemId = Guid.Empty }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyUserId_IsInvalid() + { + var command = ValidCommand() with { UserId = Guid.Empty }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyReactionTypeKey_IsInvalid() + { + var command = ValidCommand() with { ReactionTypeKey = "" }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Problems/UpdateProblem/UpdateProblemHandlerTests.cs b/StackDuel.Application.Tests/Commands/Problems/UpdateProblem/UpdateProblemHandlerTests.cs new file mode 100644 index 0000000..fdccd42 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Problems/UpdateProblem/UpdateProblemHandlerTests.cs @@ -0,0 +1,183 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.ValueObjects; +using UpdateProblemCommand = StackDuel.Application.Commands.Problems.UpdateProblem.UpdateProblemCommand; +using UpdateProblemHandler = StackDuel.Application.Commands.Problems.UpdateProblem.UpdateProblemHandler; +using UpdateProblemValidator = StackDuel.Application.Commands.Problems.UpdateProblem.UpdateProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Problems.UpdateProblem; + +public class UpdateProblemHandlerTests +{ + private static readonly string ValidQuestion = new('q', 60); + + private Mock _problemRepository = null!; + private UpdateProblemHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemRepository = new Mock(); + _handler = new UpdateProblemHandler(new UpdateProblemValidator(), _problemRepository.Object); + } + + private static Problem CreateProblem() => + new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question(ValidQuestion), + new Difficulty(100), + new TimeLimit(1000), + new MemoryLimit(256) + ); + + private static UpdateProblemCommand ValidCommand( + Guid problemId, + ProblemStatus? status = null, + IReadOnlyCollection? tags = null + ) => new(problemId, "Updated Title", ValidQuestion, 200, 2000, 256, tags ?? [], status); + + [Test] + public async Task Handle_ProblemNotFound_ReturnsNotFound() + { + _problemRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Problem?)null); + + Result result = await _handler.Handle(ValidCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ValidUpdate_UpdatesContentAndPersists() + { + var problem = CreateProblem(); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + Result result = await _handler.Handle(ValidCommand(problem.Id), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.Multiple(() => + { + Assert.That(problem.Title.Value, Is.EqualTo("Updated Title")); + Assert.That(problem.Difficulty.Value, Is.EqualTo(200)); + Assert.That(problem.History, Has.Count.EqualTo(1)); + }); + _problemRepository.Verify(x => x.UpdateAsync(problem, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_TagWithInvalidFormat_ReturnsInvalidResultAndDoesNotPersist() + { + var problem = CreateProblem(); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + Result result = await _handler.Handle(ValidCommand(problem.Id, tags: ["invalid tag"]), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _problemRepository.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_StatusTransition_DraftToPublished_Succeeds() + { + var problem = CreateProblem(); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + await _handler.Handle(ValidCommand(problem.Id, status: ProblemStatus.Published), CancellationToken.None); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Published)); + } + + [Test] + public async Task Handle_InvalidStatusTransition_ReturnsInvalidAndLeavesProblemUnchanged() + { + var problem = CreateProblem(); + problem.Archive(); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + Result result = await _handler.Handle( + ValidCommand(problem.Id, status: ProblemStatus.Published), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + Assert.Multiple(() => + { + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Archived)); + Assert.That(problem.Title.Value, Is.EqualTo("Two Sum")); + }); + _problemRepository.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_AddsNewTags_ViaFindOrCreateTagsAsync() + { + var problem = CreateProblem(); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + var createdTag = new ProblemTag(new Tag("arrays")); + _problemRepository + .Setup(x => + x.FindOrCreateTagsAsync( + It.Is>(names => names.Count == 1 && names.Contains("arrays")), + It.IsAny() + ) + ) + .ReturnsAsync([createdTag]); + + await _handler.Handle(ValidCommand(problem.Id, tags: ["arrays"]), CancellationToken.None); + + Assert.That(problem.Tags, Contains.Item(createdTag)); + } + + [Test] + public async Task Handle_RemovesTagsNoLongerDesired() + { + var problem = CreateProblem(); + var existingTag = new ProblemTag(new Tag("old-tag")); + problem.AddTag(existingTag); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + await _handler.Handle(ValidCommand(problem.Id, tags: []), CancellationToken.None); + + Assert.That(problem.Tags, Does.Not.Contain(existingTag)); + } + + [Test] + public async Task Handle_DuplicateTagNames_DeduplicatedBeforeLookup() + { + var problem = CreateProblem(); + _problemRepository.Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())).ReturnsAsync(problem); + + _problemRepository + .Setup(x => x.FindOrCreateTagsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + await _handler.Handle(ValidCommand(problem.Id, tags: ["arrays", "arrays", "Arrays"]), CancellationToken.None); + + _problemRepository.Verify( + x => + x.FindOrCreateTagsAsync( + It.Is>(names => names.Count == 1 && names.Contains("arrays")), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = ValidCommand(Guid.NewGuid()) with { Title = "" }; + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _problemRepository.Verify(x => x.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Problems/UpdateProblem/UpdateProblemValidatorTests.cs b/StackDuel.Application.Tests/Commands/Problems/UpdateProblem/UpdateProblemValidatorTests.cs new file mode 100644 index 0000000..ca1a1ec --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Problems/UpdateProblem/UpdateProblemValidatorTests.cs @@ -0,0 +1,107 @@ +using FluentValidation.Results; +using StackDuel.Domain.Problems.Enums; +using UpdateProblemCommand = StackDuel.Application.Commands.Problems.UpdateProblem.UpdateProblemCommand; +using UpdateProblemValidator = StackDuel.Application.Commands.Problems.UpdateProblem.UpdateProblemValidator; + +namespace StackDuel.Application.Tests.Commands.Problems.UpdateProblem; + +public class UpdateProblemValidatorTests +{ + private static readonly string ValidQuestion = new('q', 60); + + private UpdateProblemValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new UpdateProblemValidator(); + } + + private static UpdateProblemCommand ValidCommand() => + new(Guid.NewGuid(), "Two Sum", ValidQuestion, 100, 1000, 256, ["arrays"], ProblemStatus.Published); + + [Test] + public void Validate_ValidCommand_IsValid() + { + Assert.That(_validator.Validate(ValidCommand()).IsValid, Is.True); + } + + [Test] + public void Validate_EmptyProblemId_IsInvalid() + { + var command = ValidCommand() with { ProblemId = Guid.Empty }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_TitleTooShort_IsInvalid() + { + var command = ValidCommand() with { Title = "ab" }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_TitleTooLong_IsInvalid() + { + var command = ValidCommand() with { Title = new string('a', 201) }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_QuestionTooShort_IsInvalid() + { + var command = ValidCommand() with { Question = "too short" }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_DifficultyNegative_IsInvalid() + { + var command = ValidCommand() with { Difficulty = -1 }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [TestCase(99)] + [TestCase(10001)] + public void Validate_TimeLimitOutOfRange_IsInvalid(int timeLimitMs) + { + var command = ValidCommand() with { TimeLimitMs = timeLimitMs }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [TestCase(15)] + [TestCase(513)] + public void Validate_MemoryLimitOutOfRange_IsInvalid(int memoryLimitMb) + { + var command = ValidCommand() with { MemoryLimitMb = memoryLimitMb }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_NullTags_IsInvalid() + { + var command = ValidCommand() with { Tags = null! }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyTagString_IsInvalid() + { + var command = ValidCommand() with { Tags = [""] }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_TagTooLong_IsInvalid() + { + var command = ValidCommand() with { Tags = [new string('a', 51)] }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_NullStatus_IsValid() + { + var command = ValidCommand() with { Status = null }; + Assert.That(_validator.Validate(command).IsValid, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Submissions/CreateSubmission/CreateSubmissionHandlerTests.cs b/StackDuel.Application.Tests/Commands/Submissions/CreateSubmission/CreateSubmissionHandlerTests.cs new file mode 100644 index 0000000..20e933f --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Submissions/CreateSubmission/CreateSubmissionHandlerTests.cs @@ -0,0 +1,271 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.Factories; +using StackDuel.Domain.TestSuites; +using CreateSubmissionCommand = StackDuel.Application.Commands.Submissions.CreateSubmission.CreateSubmissionCommand; +using CreateSubmissionHandler = StackDuel.Application.Commands.Submissions.CreateSubmission.CreateSubmissionHandler; +using CreateSubmissionValidator = StackDuel.Application.Commands.Submissions.CreateSubmission.CreateSubmissionValidator; + +namespace StackDuel.Application.Tests.Commands.Submissions.CreateSubmission; + +public class CreateSubmissionHandlerTests +{ + private const int OfficialGradingCaseCount = 20; + + private Mock _submissionRepository = null!; + private Mock _submissionJobRepository = null!; + private Mock _pipelineRepository = null!; + private Mock _testSuiteRepository = null!; + private Mock _domainEventDispatcher = null!; + private CreateSubmissionHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _submissionRepository = new Mock(); + _submissionJobRepository = new Mock(); + _pipelineRepository = new Mock(); + _testSuiteRepository = new Mock(); + _domainEventDispatcher = new Mock(); + + _handler = new CreateSubmissionHandler( + new CreateSubmissionValidator(), + new SubmissionFactory(), + _submissionRepository.Object, + _submissionJobRepository.Object, + _pipelineRepository.Object, + _testSuiteRepository.Object, + _domainEventDispatcher.Object + ); + } + + private static CreateSubmissionCommand ValidCommand( + SubmissionType type = SubmissionType.Run, + IReadOnlyCollection? customTestCases = null, + Guid? gameId = null + ) => new(Guid.NewGuid(), type, "return 42;", Guid.NewGuid(), customTestCases, GameId: gameId); + + private static ExecutionPipeline CreatePipelineWithStep() + { + var pipeline = new ExecutionPipeline("default"); + pipeline.AddStep(ExecutionPipelineStepType.Judge0Execute, 1, 3, 10, false, "execute"); + return pipeline; + } + + private void SetUpRunTypeTestCases(params Guid[] publicCaseIds) + { + _testSuiteRepository + .Setup(x => x.FindPublicTestCaseIdsByProblemSetupIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(publicCaseIds); + _testSuiteRepository + .Setup(x => + x.CreateAdHocTestCasesAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + } + + private void SetUpPipeline(Guid pipelineId, ExecutionPipeline pipeline) + { + _testSuiteRepository + .Setup(x => x.FindPipelineIdByProblemSetupIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(pipelineId); + _pipelineRepository + .Setup(x => x.FindByIdWithStepsAsync(pipelineId, It.IsAny())) + .ReturnsAsync(pipeline); + } + + [Test] + public async Task Handle_RunType_Success_ReturnsSubmissionIdAndPersists() + { + SetUpRunTypeTestCases(Guid.NewGuid()); + var pipeline = CreatePipelineWithStep(); + SetUpPipeline(Guid.NewGuid(), pipeline); + + Result result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Not.EqualTo(Guid.Empty)); + _submissionRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Once + ); + _submissionJobRepository.Verify( + x => + x.AddAsync( + It.Is(j => + j.PipelineId == pipeline.Id && j.CurrentStepId == pipeline.FirstStep()!.Id + ), + It.IsAny() + ), + Times.Once + ); + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_WithGameId_PersistsSubmissionWithGameId() + { + SetUpRunTypeTestCases(Guid.NewGuid()); + SetUpPipeline(Guid.NewGuid(), CreatePipelineWithStep()); + var gameId = Guid.NewGuid(); + + await _handler.Handle(ValidCommand(gameId: gameId), CancellationToken.None); + + _submissionRepository.Verify( + x => x.AddAsync(It.Is(s => s.GameId == gameId), It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_RunType_PassesCustomTestCaseInputsToRepository() + { + SetUpRunTypeTestCases(Guid.NewGuid()); + SetUpPipeline(Guid.NewGuid(), CreatePipelineWithStep()); + + var customTestCases = new List { new(["1", "2"]) }; + + await _handler.Handle(ValidCommand(customTestCases: customTestCases), CancellationToken.None); + + _testSuiteRepository.Verify( + x => + x.CreateAdHocTestCasesAsync( + It.IsAny(), + It.Is>>(cases => + cases.Count == 1 && cases.First().SequenceEqual(new[] { "1", "2" }) + ), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_SubmitType_UsesGradingTestCaseIdsAndSkipsRunTypeLookups() + { + _testSuiteRepository + .Setup(x => + x.FindGradingTestCaseIdsByProblemSetupIdAsync( + It.IsAny(), + OfficialGradingCaseCount, + It.IsAny() + ) + ) + .ReturnsAsync([Guid.NewGuid()]); + SetUpPipeline(Guid.NewGuid(), CreatePipelineWithStep()); + + Result result = await _handler.Handle(ValidCommand(type: SubmissionType.Submit), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _testSuiteRepository.Verify( + x => x.FindPublicTestCaseIdsByProblemSetupIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _testSuiteRepository.Verify( + x => + x.CreateAdHocTestCasesAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_NoTestCasesAvailable_ReturnsErrorAndDoesNotLookUpPipeline() + { + SetUpRunTypeTestCases(); + + Result result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); + _testSuiteRepository.Verify( + x => x.FindPipelineIdByProblemSetupIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _submissionRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_PipelineIdNotFound_ReturnsNotFound() + { + SetUpRunTypeTestCases(Guid.NewGuid()); + _testSuiteRepository + .Setup(x => x.FindPipelineIdByProblemSetupIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid?)null); + + Result result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + _submissionRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_PipelineNotFound_ReturnsNotFound() + { + SetUpRunTypeTestCases(Guid.NewGuid()); + var pipelineId = Guid.NewGuid(); + _testSuiteRepository + .Setup(x => x.FindPipelineIdByProblemSetupIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(pipelineId); + _pipelineRepository + .Setup(x => x.FindByIdWithStepsAsync(pipelineId, It.IsAny())) + .ReturnsAsync((ExecutionPipeline?)null); + + Result result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_PipelineHasNoSteps_ReturnsError() + { + SetUpRunTypeTestCases(Guid.NewGuid()); + SetUpPipeline(Guid.NewGuid(), new ExecutionPipeline("empty")); + + Result result = await _handler.Handle(ValidCommand(), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); + _submissionRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepositories() + { + var command = ValidCommand() with { Code = "" }; + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _testSuiteRepository.Verify( + x => x.FindPublicTestCaseIdsByProblemSetupIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Submissions/CreateSubmission/CreateSubmissionValidatorTests.cs b/StackDuel.Application.Tests/Commands/Submissions/CreateSubmission/CreateSubmissionValidatorTests.cs new file mode 100644 index 0000000..7a46d36 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Submissions/CreateSubmission/CreateSubmissionValidatorTests.cs @@ -0,0 +1,87 @@ +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.Submissions.Enums; +using CreateSubmissionCommand = StackDuel.Application.Commands.Submissions.CreateSubmission.CreateSubmissionCommand; +using CreateSubmissionValidator = StackDuel.Application.Commands.Submissions.CreateSubmission.CreateSubmissionValidator; + +namespace StackDuel.Application.Tests.Commands.Submissions.CreateSubmission; + +public class CreateSubmissionValidatorTests +{ + private CreateSubmissionValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new CreateSubmissionValidator(); + } + + private static CreateSubmissionCommand ValidCommand( + SubmissionType type = SubmissionType.Run, + IReadOnlyCollection? customTestCases = null + ) => new(Guid.NewGuid(), type, "return 42;", Guid.NewGuid(), customTestCases); + + [Test] + public void Validate_ValidCommand_IsValid() + { + Assert.That(_validator.Validate(ValidCommand()).IsValid, Is.True); + } + + [Test] + public void Validate_EmptyProblemSetupId_IsInvalid() + { + var command = ValidCommand() with { ProblemSetupId = Guid.Empty }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyCode_IsInvalid() + { + var command = ValidCommand() with { Code = "" }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_CodeTooLong_IsInvalid() + { + var command = ValidCommand() with { Code = new string('a', 65537) }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyCreatedById_IsInvalid() + { + var command = ValidCommand() with { CreatedById = Guid.Empty }; + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_RunType_CustomTestCaseWithNoInputs_IsInvalid() + { + var command = ValidCommand(customTestCases: [new CreateSubmissionCustomTestCaseDto([])]); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_RunType_CustomTestCaseWithInputs_IsValid() + { + var command = ValidCommand(customTestCases: [new CreateSubmissionCustomTestCaseDto(["1"])]); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } + + [Test] + public void Validate_SubmitType_CustomTestCaseWithNoInputs_IsValid() + { + var command = ValidCommand( + type: SubmissionType.Submit, + customTestCases: [new CreateSubmissionCustomTestCaseDto([])] + ); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } + + [Test] + public void Validate_NullCustomTestCases_IsValid() + { + var command = ValidCommand(customTestCases: null); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackHandlerTests.cs b/StackDuel.Application.Tests/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackHandlerTests.cs new file mode 100644 index 0000000..801d092 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackHandlerTests.cs @@ -0,0 +1,104 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Configuration; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using ReceiveJudge0CallbackCommand = StackDuel.Application.Commands.Submissions.ReceiveJudge0Callback.ReceiveJudge0CallbackCommand; +using ReceiveJudge0CallbackHandler = StackDuel.Application.Commands.Submissions.ReceiveJudge0Callback.ReceiveJudge0CallbackHandler; + +namespace StackDuel.Application.Tests.Commands.Submissions.ReceiveJudge0Callback; + +public class ReceiveJudge0CallbackHandlerTests +{ + private Mock _messagePublisher = null!; + + [SetUp] + public void SetUp() + { + _messagePublisher = new Mock(); + } + + private ReceiveJudge0CallbackHandler CreateHandler(Judge0Options options) => new(_messagePublisher.Object, options); + + [Test] + public async Task Handle_UseCallbackDisabled_ReturnsUnauthorized() + { + var handler = CreateHandler(new Judge0Options { UseCallback = false, CallbackSecret = "secret" }); + + Result result = await handler.Handle( + new ReceiveJudge0CallbackCommand("secret", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Unauthorized)); + _messagePublisher.Verify( + x => x.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_WrongKey_ReturnsUnauthorized() + { + var handler = CreateHandler(new Judge0Options { UseCallback = true, CallbackSecret = "secret" }); + + Result result = await handler.Handle( + new ReceiveJudge0CallbackCommand("wrong-key", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Unauthorized)); + _messagePublisher.Verify( + x => x.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_MissingKey_ReturnsUnauthorized() + { + var handler = CreateHandler(new Judge0Options { UseCallback = true, CallbackSecret = "secret" }); + + Result result = await handler.Handle( + new ReceiveJudge0CallbackCommand(null, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Unauthorized)); + } + + [Test] + public async Task Handle_CorrectKey_PublishesContinuationAndSucceeds() + { + var handler = CreateHandler(new Judge0Options { UseCallback = true, CallbackSecret = "secret" }); + Guid submissionId = Guid.NewGuid(); + + Result result = await handler.Handle( + new ReceiveJudge0CallbackCommand("secret", submissionId), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + _messagePublisher.Verify( + x => + x.PublishAsync( + It.Is(m => m.SubmissionId == submissionId), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_EmptyConfiguredSecret_NeverAuthorizesEvenWithEmptyKey() + { + var handler = CreateHandler(new Judge0Options { UseCallback = true, CallbackSecret = "" }); + + Result result = await handler.Handle( + new ReceiveJudge0CallbackCommand("", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Unauthorized)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/SelectUserAvatar/SelectUserAvatarHandlerTests.cs b/StackDuel.Application.Tests/Commands/Users/SelectUserAvatar/SelectUserAvatarHandlerTests.cs new file mode 100644 index 0000000..5e7f66a --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/SelectUserAvatar/SelectUserAvatarHandlerTests.cs @@ -0,0 +1,83 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Factories; +using StackDuel.Domain.Users.ValueObjects; +using SelectUserAvatarCommand = StackDuel.Application.Commands.Users.SelectUserAvatar.SelectUserAvatarCommand; +using SelectUserAvatarHandler = StackDuel.Application.Commands.Users.SelectUserAvatar.SelectUserAvatarHandler; +using SelectUserAvatarValidator = StackDuel.Application.Commands.Users.SelectUserAvatar.SelectUserAvatarValidator; + +namespace StackDuel.Application.Tests.Commands.Users.SelectUserAvatar; + +public class SelectUserAvatarHandlerTests +{ + private Mock _userRepository = null!; + private Mock _userAvatarRepository = null!; + private SelectUserAvatarHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userRepository = new Mock(); + _userAvatarRepository = new Mock(); + + _handler = new SelectUserAvatarHandler( + new SelectUserAvatarValidator(), + _userRepository.Object, + _userAvatarRepository.Object + ); + } + + [Test] + public async Task Handle_UserNotFound_ReturnsNotFound() + { + _userRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((User?)null); + + var command = new SelectUserAvatarCommand(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_AvatarNotOwnedByUser_ReturnsNotFound() + { + var user = new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _userAvatarRepository + .Setup(x => x.FindByIdForUserAsync(It.IsAny(), user.Id, It.IsAny())) + .ReturnsAsync((UserAvatar?)null); + + var command = new SelectUserAvatarCommand(user.Id, Guid.NewGuid()); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_AvatarOwnedByUser_SetsImageUrlAndPersists() + { + var user = new UserFactory().Create( + new CreateUserParams("alice", "auth0|abc", "https://storage.example.com/avatars/current.png") + ); + var avatar = new UserAvatar(user.Id, new ImageUrl("https://storage.example.com/avatars/older.png")); + + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _userAvatarRepository + .Setup(x => x.FindByIdForUserAsync(avatar.Id, user.Id, It.IsAny())) + .ReturnsAsync(avatar); + + var command = new SelectUserAvatarCommand(user.Id, avatar.Id); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(user.ImageUrl!.Value, Is.EqualTo("https://storage.example.com/avatars/older.png")); + }); + _userRepository.Verify(x => x.UpdateAsync(user, It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyHandlerTests.cs b/StackDuel.Application.Tests/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyHandlerTests.cs new file mode 100644 index 0000000..c9e50c2 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyHandlerTests.cs @@ -0,0 +1,63 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Factories; +using UpdateProfilePrivacyCommand = StackDuel.Application.Commands.Users.UpdateProfilePrivacy.UpdateProfilePrivacyCommand; +using UpdateProfilePrivacyHandler = StackDuel.Application.Commands.Users.UpdateProfilePrivacy.UpdateProfilePrivacyHandler; +using UpdateProfilePrivacyValidator = StackDuel.Application.Commands.Users.UpdateProfilePrivacy.UpdateProfilePrivacyValidator; + +namespace StackDuel.Application.Tests.Commands.Users.UpdateProfilePrivacy; + +public class UpdateProfilePrivacyHandlerTests +{ + private Mock _userRepository = null!; + private UpdateProfilePrivacyHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userRepository = new Mock(); + _handler = new UpdateProfilePrivacyHandler(new UpdateProfilePrivacyValidator(), _userRepository.Object); + } + + [Test] + public async Task Handle_UserNotFound_ReturnsNotFound() + { + _userRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((User?)null); + + var command = new UpdateProfilePrivacyCommand(Guid.NewGuid(), true); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UserFound_SetsPrivacyAndPersists() + { + var user = new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + + var command = new UpdateProfilePrivacyCommand(user.Id, true); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(user.IsPrivate, Is.True); + _userRepository.Verify(x => x.UpdateAsync(user, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_UserFound_CanUnsetPrivacy() + { + var user = new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + user.SetPrivate(true); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + + var command = new UpdateProfilePrivacyCommand(user.Id, false); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(user.IsPrivate, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/UpdateUserGroups/UpdateUserGroupsHandlerTests.cs b/StackDuel.Application.Tests/Commands/Users/UpdateUserGroups/UpdateUserGroupsHandlerTests.cs new file mode 100644 index 0000000..31d178c --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/UpdateUserGroups/UpdateUserGroupsHandlerTests.cs @@ -0,0 +1,250 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Groups; +using StackDuel.Application.Groups.Dtos; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Events; +using StackDuel.Domain.Users.Factories; +using UpdateUserGroupsCommand = StackDuel.Application.Commands.Users.UpdateUserGroups.UpdateUserGroupsCommand; +using UpdateUserGroupsHandler = StackDuel.Application.Commands.Users.UpdateUserGroups.UpdateUserGroupsHandler; +using UpdateUserGroupsValidator = StackDuel.Application.Commands.Users.UpdateUserGroups.UpdateUserGroupsValidator; + +namespace StackDuel.Application.Tests.Commands.Users.UpdateUserGroups; + +public class UpdateUserGroupsHandlerTests +{ + private Mock _userRepository = null!; + private Mock _groupRepository = null!; + private Mock _domainEventDispatcher = null!; + private UserContext _userContext = null!; + private UpdateUserGroupsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userRepository = new Mock(); + _groupRepository = new Mock(); + _domainEventDispatcher = new Mock(); + _userContext = new UserContext(); + _handler = new UpdateUserGroupsHandler( + new UpdateUserGroupsValidator(), + _userRepository.Object, + _groupRepository.Object, + _domainEventDispatcher.Object, + _userContext + ); + } + + private static User CreateUser() => new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + + private static UserDto ToDto(User user) => + new(user.Id, user.Sub, user.Username.Value, null, null, false, null, user.CreatedAt, null, []); + + [Test] + public async Task Handle_UserNotFound_ReturnsNotFound() + { + _userRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((User?)null); + + var command = new UpdateUserGroupsCommand(Guid.NewGuid(), [Guid.NewGuid()]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UnknownGroupId_ReturnsInvalidAndDoesNotSetGroups() + { + var user = CreateUser(); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _groupRepository + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync([new GroupDto(Guid.NewGuid(), "Admins")]); + + var command = new UpdateUserGroupsCommand(user.Id, [Guid.NewGuid()]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _userRepository.Verify( + x => + x.SetGroupsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_AllGroupIdsValid_SetsGroupsAndSucceeds() + { + var user = CreateUser(); + var groupId = Guid.NewGuid(); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _groupRepository + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync([new GroupDto(groupId, "Admins")]); + + var command = new UpdateUserGroupsCommand(user.Id, [groupId]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _userRepository.Verify( + x => + x.SetGroupsAsync( + user.Id, + It.Is>(ids => ids.Single() == groupId), + It.IsAny() + ), + Times.Once + ); + _domainEventDispatcher.Verify( + x => + x.DispatchAsync( + It.Is>(events => + events.OfType().Any(e => e.Sub == user.Sub) + ), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_EmptyGroupIds_SetsEmptyGroupsAndSucceeds() + { + var user = CreateUser(); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _groupRepository + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync([new GroupDto(Guid.NewGuid(), "Admins")]); + + var command = new UpdateUserGroupsCommand(user.Id, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _userRepository.Verify( + x => + x.SetGroupsAsync( + user.Id, + It.Is>(ids => ids.Count == 0), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_RemovingAdminGroupFromAnotherAdmin_ReturnsForbiddenAndDoesNotSetGroups() + { + var actingAdmin = CreateUser(); + var targetAdmin = CreateUser(); + var adminGroupId = Guid.NewGuid(); + _userContext.User = ToDto(actingAdmin); + + _userRepository + .Setup(x => x.FindByIdAsync(targetAdmin.Id, It.IsAny())) + .ReturnsAsync(targetAdmin); + _groupRepository + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync([new GroupDto(adminGroupId, WellKnownAuthorization.AdminGroup)]); + _userRepository + .Setup(x => x.GetGroupIdsAsync(targetAdmin.Id, It.IsAny())) + .ReturnsAsync([adminGroupId]); + + var command = new UpdateUserGroupsCommand(targetAdmin.Id, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + _userRepository.Verify( + x => + x.SetGroupsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_AdminRemovingOwnAdminGroup_SetsGroupsAndSucceeds() + { + var actingAdmin = CreateUser(); + var adminGroupId = Guid.NewGuid(); + _userContext.User = ToDto(actingAdmin); + + _userRepository + .Setup(x => x.FindByIdAsync(actingAdmin.Id, It.IsAny())) + .ReturnsAsync(actingAdmin); + _groupRepository + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync([new GroupDto(adminGroupId, WellKnownAuthorization.AdminGroup)]); + _userRepository + .Setup(x => x.GetGroupIdsAsync(actingAdmin.Id, It.IsAny())) + .ReturnsAsync([adminGroupId]); + + var command = new UpdateUserGroupsCommand(actingAdmin.Id, []); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _userRepository.Verify( + x => + x.SetGroupsAsync( + actingAdmin.Id, + It.Is>(ids => ids.Count == 0), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_GrantingAdminGroupToNonAdmin_SetsGroupsAndSucceeds() + { + var actingAdmin = CreateUser(); + var targetUser = CreateUser(); + var adminGroupId = Guid.NewGuid(); + _userContext.User = ToDto(actingAdmin); + + _userRepository + .Setup(x => x.FindByIdAsync(targetUser.Id, It.IsAny())) + .ReturnsAsync(targetUser); + _groupRepository + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync([new GroupDto(adminGroupId, WellKnownAuthorization.AdminGroup)]); + _userRepository.Setup(x => x.GetGroupIdsAsync(targetUser.Id, It.IsAny())).ReturnsAsync([]); + + var command = new UpdateUserGroupsCommand(targetUser.Id, [adminGroupId]); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _userRepository.Verify( + x => + x.SetGroupsAsync( + targetUser.Id, + It.Is>(ids => ids.Single() == adminGroupId), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsInvalidAndDoesNotTouchRepository() + { + var command = new UpdateUserGroupsCommand(Guid.Empty, [Guid.NewGuid()]); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _userRepository.Verify(x => x.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/UpdateUserGroups/UpdateUserGroupsValidatorTests.cs b/StackDuel.Application.Tests/Commands/Users/UpdateUserGroups/UpdateUserGroupsValidatorTests.cs new file mode 100644 index 0000000..9a7fa78 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/UpdateUserGroups/UpdateUserGroupsValidatorTests.cs @@ -0,0 +1,50 @@ +using UpdateUserGroupsCommand = StackDuel.Application.Commands.Users.UpdateUserGroups.UpdateUserGroupsCommand; +using UpdateUserGroupsValidator = StackDuel.Application.Commands.Users.UpdateUserGroups.UpdateUserGroupsValidator; + +namespace StackDuel.Application.Tests.Commands.Users.UpdateUserGroups; + +public class UpdateUserGroupsValidatorTests +{ + private UpdateUserGroupsValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new UpdateUserGroupsValidator(); + } + + [Test] + public void Validate_ValidCommand_IsValid() + { + var command = new UpdateUserGroupsCommand(Guid.NewGuid(), [Guid.NewGuid()]); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } + + [Test] + public void Validate_EmptyUserId_IsInvalid() + { + var command = new UpdateUserGroupsCommand(Guid.Empty, [Guid.NewGuid()]); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_NullGroupIds_IsInvalid() + { + var command = new UpdateUserGroupsCommand(Guid.NewGuid(), null!); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_GroupIdsContainsEmptyGuid_IsInvalid() + { + var command = new UpdateUserGroupsCommand(Guid.NewGuid(), [Guid.Empty]); + Assert.That(_validator.Validate(command).IsValid, Is.False); + } + + [Test] + public void Validate_EmptyGroupIdsCollection_IsValid() + { + var command = new UpdateUserGroupsCommand(Guid.NewGuid(), []); + Assert.That(_validator.Validate(command).IsValid, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/UploadUserAvatar/UploadUserAvatarHandlerTests.cs b/StackDuel.Application.Tests/Commands/Users/UploadUserAvatar/UploadUserAvatarHandlerTests.cs new file mode 100644 index 0000000..71eacad --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/UploadUserAvatar/UploadUserAvatarHandlerTests.cs @@ -0,0 +1,206 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Factories; +using StackDuel.Domain.Users.ValueObjects; +using UploadUserAvatarCommand = StackDuel.Application.Commands.Users.UploadUserAvatar.UploadUserAvatarCommand; +using UploadUserAvatarHandler = StackDuel.Application.Commands.Users.UploadUserAvatar.UploadUserAvatarHandler; +using UploadUserAvatarValidator = StackDuel.Application.Commands.Users.UploadUserAvatar.UploadUserAvatarValidator; + +namespace StackDuel.Application.Tests.Commands.Users.UploadUserAvatar; + +public class UploadUserAvatarHandlerTests +{ + private static readonly byte[] ValidPngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0]; + private static readonly byte[] ValidJpegBytes = [0xFF, 0xD8, 0xFF, 0, 0, 0]; + private static readonly byte[] GifBytes = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0]; + + private Mock _userRepository = null!; + private Mock _userAvatarRepository = null!; + private Mock _avatarBlobStorage = null!; + private UploadUserAvatarHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userRepository = new Mock(); + _userAvatarRepository = new Mock(); + _avatarBlobStorage = new Mock(); + + _userAvatarRepository + .Setup(x => x.GetAllByUserIdNewestFirstAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IReadOnlyList)[]); + + _handler = new UploadUserAvatarHandler( + new UploadUserAvatarValidator(), + _userRepository.Object, + _userAvatarRepository.Object, + _avatarBlobStorage.Object + ); + } + + [Test] + public async Task Handle_UserNotFound_ReturnsNotFound() + { + _userRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((User?)null); + + var command = new UploadUserAvatarCommand(Guid.NewGuid(), ValidPngBytes); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ValidUpload_UploadsSetsImageUrlAndReturnsBlobUrl() + { + var user = new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _avatarBlobStorage + .Setup(x => + x.UploadAsync(user.Id, It.IsAny(), It.IsAny(), "image/png", It.IsAny()) + ) + .ReturnsAsync("https://storage.example.com/avatars/abc.png"); + + var command = new UploadUserAvatarCommand(user.Id, ValidPngBytes); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo("https://storage.example.com/avatars/abc.png")); + Assert.That(user.ImageUrl!.Value, Is.EqualTo("https://storage.example.com/avatars/abc.png")); + }); + _userAvatarRepository.Verify( + x => x.AddAsync(It.IsAny(), It.IsAny()), + Times.Once + ); + _userRepository.Verify(x => x.UpdateAsync(user, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_JpegSignature_UploadsWithDetectedContentType() + { + var user = new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _avatarBlobStorage + .Setup(x => + x.UploadAsync( + user.Id, + It.IsAny(), + It.IsAny(), + "image/jpeg", + It.IsAny() + ) + ) + .ReturnsAsync("https://storage.example.com/avatars/abc.jpg"); + + var command = new UploadUserAvatarCommand(user.Id, ValidJpegBytes); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _avatarBlobStorage.Verify( + x => + x.UploadAsync( + user.Id, + It.IsAny(), + It.IsAny(), + "image/jpeg", + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task Handle_MoreThanThreeAvatars_PrunesOldestBeyondThree() + { + var user = new UserFactory().Create(new CreateUserParams("alice", "auth0|abc", null)); + _userRepository.Setup(x => x.FindByIdAsync(user.Id, It.IsAny())).ReturnsAsync(user); + _avatarBlobStorage + .Setup(x => + x.UploadAsync( + user.Id, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync("https://storage.example.com/avatars/new.png"); + + var newest = new UserAvatar(user.Id, new ImageUrl("https://storage.example.com/avatars/1.png")); + var second = new UserAvatar(user.Id, new ImageUrl("https://storage.example.com/avatars/2.png")); + var third = new UserAvatar(user.Id, new ImageUrl("https://storage.example.com/avatars/3.png")); + var oldest = new UserAvatar(user.Id, new ImageUrl("https://storage.example.com/avatars/4.png")); + + _userAvatarRepository + .Setup(x => x.GetAllByUserIdNewestFirstAsync(user.Id, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[newest, second, third, oldest]); + + var command = new UploadUserAvatarCommand(user.Id, ValidPngBytes); + await _handler.Handle(command, CancellationToken.None); + + _avatarBlobStorage.Verify(x => x.DeleteAsync(oldest.ImageUrl.Value, It.IsAny()), Times.Once); + _userAvatarRepository.Verify(x => x.DeleteAsync(oldest, It.IsAny()), Times.Once); + _userAvatarRepository.Verify(x => x.DeleteAsync(newest, It.IsAny()), Times.Never); + _userAvatarRepository.Verify(x => x.DeleteAsync(second, It.IsAny()), Times.Never); + _userAvatarRepository.Verify(x => x.DeleteAsync(third, It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_UnrecognizedFileSignature_ReturnsInvalidAndDoesNotUpload() + { + var command = new UploadUserAvatarCommand(Guid.NewGuid(), GifBytes); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _avatarBlobStorage.Verify( + x => + x.UploadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_SpoofedSignature_ReturnsInvalidAndDoesNotUpload() + { + byte[] htmlDisguisedAsBytes = ""u8.ToArray(); + var command = new UploadUserAvatarCommand(Guid.NewGuid(), htmlDisguisedAsBytes); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _avatarBlobStorage.Verify( + x => + x.UploadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_ContentTooLarge_ReturnsInvalid() + { + byte[] content = new byte[UploadUserAvatarValidator.MaxContentBytes + 1]; + ValidPngBytes.CopyTo(content, 0); + + var command = new UploadUserAvatarCommand(Guid.NewGuid(), content); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/UpsertUser/UpsertUserHandlerTests.cs b/StackDuel.Application.Tests/Commands/Users/UpsertUser/UpsertUserHandlerTests.cs new file mode 100644 index 0000000..2c77624 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/UpsertUser/UpsertUserHandlerTests.cs @@ -0,0 +1,294 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Services.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Factories; +using UpsertUserCommand = StackDuel.Application.Commands.Users.UpsertUser.UpsertUserCommand; +using UpsertUserHandler = StackDuel.Application.Commands.Users.UpsertUser.UpsertUserHandler; +using UpsertUserValidator = StackDuel.Application.Commands.Users.UpsertUser.UpsertUserValidator; + +namespace StackDuel.Application.Tests.Commands.Users.UpsertUser; + +public class UpsertUserHandlerTests +{ + private const string ValidSub = "auth0|abc123"; + + private Mock _userRepository = null!; + private Mock _usernameGenerator = null!; + private Mock _domainEventDispatcher = null!; + private UserContext _userContext = null!; + private UpsertUserHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userRepository = new Mock(); + _usernameGenerator = new Mock(); + _domainEventDispatcher = new Mock(); + _userContext = new UserContext(); + + _handler = new UpsertUserHandler( + new UpsertUserValidator(), + new UserFactory(), + _usernameGenerator.Object, + _userRepository.Object, + _domainEventDispatcher.Object, + _userContext + ); + } + + private static UserDto ToDto(User user) => + new(user.Id, user.Sub, user.Username.Value, null, null, false, null, user.CreatedAt, null, []); + + [Test] + public async Task Handle_NewUser_NoUsernameProvided_GeneratesUsername() + { + _userRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync((User?)null); + _usernameGenerator.Setup(x => x.Generate()).Returns("generated_name42"); + + var command = new UpsertUserCommand(ValidSub, null, null, null); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _userRepository.Verify( + x => x.AddAsync(It.Is(u => u.Username.Value == "generated_name42"), It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_NewUser_NoUsernameProvided_DoesNotCompleteSetup() + { + _userRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync((User?)null); + _usernameGenerator.Setup(x => x.Generate()).Returns("generated_name42"); + + User? createdUser = null; + _userRepository + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => createdUser = u) + .Returns(Task.CompletedTask); + + var command = new UpsertUserCommand(ValidSub, null, null, null); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(createdUser!.SetupCompletedAt, Is.Null); + } + + [Test] + public async Task Handle_NewUser_WithUsername_CompletesSetup() + { + _userRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync((User?)null); + + User? createdUser = null; + _userRepository + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => createdUser = u) + .Returns(Task.CompletedTask); + + var command = new UpsertUserCommand(ValidSub, "alice", null, null); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(createdUser!.SetupCompletedAt, Is.Not.Null); + } + + [Test] + public async Task Handle_NewUser_DispatchesDomainEvents() + { + _userRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync((User?)null); + + var command = new UpsertUserCommand(ValidSub, "alice", null, null); + await _handler.Handle(command, CancellationToken.None); + + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_NewUser_WithLanguageIds_SetsLanguagePreferences() + { + _userRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync((User?)null); + + var languageIds = new List { Guid.NewGuid(), Guid.NewGuid() }; + var command = new UpsertUserCommand(ValidSub, "alice", null, null, languageIds); + await _handler.Handle(command, CancellationToken.None); + + _userRepository.Verify( + x => x.SetLanguagePreferencesAsync(It.IsAny(), languageIds, It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_NewUser_WithTenant_SetsTenant() + { + _userRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync((User?)null); + + User? createdUser = null; + _userRepository + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((u, _) => createdUser = u) + .Returns(Task.CompletedTask); + + var command = new UpsertUserCommand(ValidSub, "alice", null, null, Tenant: "stackduel"); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(createdUser!.Tenant, Is.EqualTo("stackduel")); + } + + [Test] + public async Task Handle_ExistingUser_DoesNotOverwriteTenant() + { + var existingUser = new UserFactory().Create(new CreateUserParams("bob", ValidSub, null, "stackduel")); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, "alice", null, null, Tenant: "some-other-tenant"); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(existingUser.Tenant, Is.EqualTo("stackduel")); + } + + [Test] + public async Task Handle_ExistingUser_ChangesUsername() + { + var existingUser = new UserFactory().Create(new CreateUserParams("bob", ValidSub, null)); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, "alice", null, null); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(existingUser.Username.Value, Is.EqualTo("alice")); + } + + [Test] + public async Task Handle_ExistingUser_UsernameChangeWithinCooldown_ReturnsInvalid() + { + var existingUser = new UserFactory().Create(new CreateUserParams("bob", ValidSub, null)); + existingUser.ChangeUsername(new Domain.Users.ValueObjects.Username("charlie")); + + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, "dave", null, null); + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _userRepository.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Handle_ExistingUser_UpdatesBioAndImageUrl() + { + var existingUser = new UserFactory().Create(new CreateUserParams("bob", ValidSub, null)); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand( + ValidSub, + null, + "https://example.com/avatar.png", + "Competitive programmer." + ); + await _handler.Handle(command, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(existingUser.Bio!.Value, Is.EqualTo("Competitive programmer.")); + Assert.That(existingUser.ImageUrl!.Value, Is.EqualTo("https://example.com/avatar.png")); + }); + _userRepository.Verify(x => x.UpdateAsync(existingUser, It.IsAny()), Times.Once); + } + + [Test] + public async Task Handle_ExistingUser_NullBioOnUpsert_DoesNotClearBio() + { + var existingUser = new UserFactory().Create(new CreateUserParams("bob", ValidSub, null)); + existingUser.UpdateBio(new Domain.Users.ValueObjects.Bio("Competitive programmer.")); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, null, null, null); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(existingUser.Bio!.Value, Is.EqualTo("Competitive programmer.")); + } + + [Test] + public async Task Handle_ExistingUser_EmptyBioOnUpsert_ClearsBio() + { + var existingUser = new UserFactory().Create(new CreateUserParams("bob", ValidSub, null)); + existingUser.UpdateBio(new Domain.Users.ValueObjects.Bio("Competitive programmer.")); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, null, null, ""); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(existingUser.Bio, Is.Null); + } + + [Test] + public async Task Handle_ExistingUser_WithImageUrl_NullImageUrlOnUpsert_DoesNotClearImage() + { + var existingUser = new UserFactory().Create( + new CreateUserParams("bob", ValidSub, "https://example.com/avatar.png") + ); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, null, null, null); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(existingUser.ImageUrl!.Value, Is.EqualTo("https://example.com/avatar.png")); + } + + [Test] + public async Task Handle_ExistingUser_WithImageUrl_DifferentImageUrlOnUpsert_DoesNotOverwriteImage() + { + var existingUser = new UserFactory().Create( + new CreateUserParams("bob", ValidSub, "https://example.com/avatar.png") + ); + _userRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync(existingUser); + _userContext.User = ToDto(existingUser); + + var command = new UpsertUserCommand(ValidSub, null, "https://example.com/other.png", null); + await _handler.Handle(command, CancellationToken.None); + + Assert.That(existingUser.ImageUrl!.Value, Is.EqualTo("https://example.com/avatar.png")); + } + + [Test] + public async Task Handle_InvalidCommand_ReturnsValidationErrorAndDoesNotTouchRepository() + { + var command = new UpsertUserCommand(ValidSub, null, null, new string('a', 1000)); + + Result result = await _handler.Handle(command, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Invalid)); + _userRepository.Verify(x => x.FindBySubAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Commands/Users/UpsertUser/UpsertUserValidatorTests.cs b/StackDuel.Application.Tests/Commands/Users/UpsertUser/UpsertUserValidatorTests.cs new file mode 100644 index 0000000..0f46f89 --- /dev/null +++ b/StackDuel.Application.Tests/Commands/Users/UpsertUser/UpsertUserValidatorTests.cs @@ -0,0 +1,97 @@ +using FluentValidation.Results; +using UpsertUserCommand = StackDuel.Application.Commands.Users.UpsertUser.UpsertUserCommand; +using UpsertUserValidator = StackDuel.Application.Commands.Users.UpsertUser.UpsertUserValidator; + +namespace StackDuel.Application.Tests.Commands.Users.UpsertUser; + +public class UpsertUserValidatorTests +{ + private UpsertUserValidator _validator = null!; + + [SetUp] + public void SetUp() + { + _validator = new UpsertUserValidator(); + } + + [Test] + public void Validate_AllFieldsWithinLimits_IsValid() + { + var command = new UpsertUserCommand("auth0|abc", "alice", "https://example.com/a.png", "Hello."); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_UsernameTooLong_IsInvalid() + { + var command = new UpsertUserCommand("auth0|abc", new string('a', 21), null, null); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_NullUsername_IsValid() + { + var command = new UpsertUserCommand("auth0|abc", null, null, null); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_ImageUrlTooLong_IsInvalid() + { + var command = new UpsertUserCommand("auth0|abc", null, new string('a', 2049), null); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_BioTooLong_IsInvalid() + { + var command = new UpsertUserCommand("auth0|abc", null, null, new string('a', 501)); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_DuplicateLanguageIds_IsInvalid() + { + var languageId = Guid.NewGuid(); + var command = new UpsertUserCommand("auth0|abc", null, null, null, [languageId, languageId]); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.False); + } + + [Test] + public void Validate_DistinctLanguageIds_IsValid() + { + var command = new UpsertUserCommand("auth0|abc", null, null, null, [Guid.NewGuid(), Guid.NewGuid()]); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } + + [Test] + public void Validate_NullLanguageIds_IsValid() + { + var command = new UpsertUserCommand("auth0|abc", null, null, null, null); + + ValidationResult result = _validator.Validate(command); + + Assert.That(result.IsValid, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/FeatureFlags/FeatureFlagBucketingTests.cs b/StackDuel.Application.Tests/FeatureFlags/FeatureFlagBucketingTests.cs new file mode 100644 index 0000000..235fe9e --- /dev/null +++ b/StackDuel.Application.Tests/FeatureFlags/FeatureFlagBucketingTests.cs @@ -0,0 +1,85 @@ +using StackDuel.Application.FeatureFlags; + +namespace StackDuel.Application.Tests.FeatureFlags; + +public class FeatureFlagBucketingTests +{ + [Test] + public void GetBucket_SameInputs_AlwaysReturnsSameBucket() + { + Guid userId = Guid.NewGuid(); + + int first = FeatureFlagBucketing.GetBucket("leaderboards", userId); + int second = FeatureFlagBucketing.GetBucket("leaderboards", userId); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void GetBucket_ReturnsValueInZeroToNinetyNineRange() + { + for (int i = 0; i < 1000; i++) + { + int bucket = FeatureFlagBucketing.GetBucket("flag", Guid.NewGuid()); + Assert.That(bucket, Is.InRange(0, 99)); + } + } + + [Test] + public void GetBucket_DifferentFlagKeys_ProduceDifferentBucketsForSameUser() + { + Guid userId = Guid.NewGuid(); + + int bucketA = FeatureFlagBucketing.GetBucket("flag-a", userId); + int bucketB = FeatureFlagBucketing.GetBucket("flag-b", userId); + + Assert.That(bucketA, Is.Not.EqualTo(bucketB)); + } + + [Test] + public void IsInRollout_ZeroPercent_NeverEnabled() + { + for (int i = 0; i < 100; i++) + { + Assert.That(FeatureFlagBucketing.IsInRollout("flag", Guid.NewGuid(), 0), Is.False); + } + } + + [Test] + public void IsInRollout_HundredPercent_AlwaysEnabled() + { + for (int i = 0; i < 100; i++) + { + Assert.That(FeatureFlagBucketing.IsInRollout("flag", Guid.NewGuid(), 100), Is.True); + } + } + + [Test] + public void IsInRollout_TenPercent_EnablesRoughlyTenPercentOfUsers() + { + const int sampleSize = 10_000; + int enabledCount = 0; + + for (int i = 0; i < sampleSize; i++) + { + if (FeatureFlagBucketing.IsInRollout("flag", Guid.NewGuid(), 10)) + enabledCount++; + } + + double enabledPercentage = enabledCount / (double)sampleSize * 100; + + Assert.That(enabledPercentage, Is.InRange(8.0, 12.0)); + } + + [Test] + public void IsInRollout_SameUserAndFlag_IsStickyAcrossRepeatedCalls() + { + Guid userId = Guid.NewGuid(); + + bool first = FeatureFlagBucketing.IsInRollout("flag", userId, 50); + bool second = FeatureFlagBucketing.IsInRollout("flag", userId, 50); + bool third = FeatureFlagBucketing.IsInRollout("flag", userId, 50); + + Assert.That(new[] { first, second, third }, Is.All.EqualTo(first)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs b/StackDuel.Application.Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs new file mode 100644 index 0000000..0b29be2 --- /dev/null +++ b/StackDuel.Application.Tests/FeatureFlags/FeatureFlagEvaluatorTests.cs @@ -0,0 +1,84 @@ +using StackDuel.Application.FeatureFlags; +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.FeatureFlags; + +namespace StackDuel.Application.Tests.FeatureFlags; + +public class FeatureFlagEvaluatorTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + + [Test] + public void Evaluate_UserDenyOverride_BeatsGroupAllowRolloutAndDefault() + { + var data = new FeatureFlagEvaluationData( + "flag", + DefaultEnabled: true, + RolloutPercentage: 100, + DecisionEffect.Deny, + DecisionEffect.Allow + ); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, UserId), Is.False); + } + + [Test] + public void Evaluate_UserAllowOverride_BeatsGroupDenyAndDefaultDisabled() + { + var data = new FeatureFlagEvaluationData( + "flag", + DefaultEnabled: false, + RolloutPercentage: 0, + DecisionEffect.Allow, + DecisionEffect.Deny + ); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, UserId), Is.True); + } + + [Test] + public void Evaluate_NoUserOverride_GroupDenyBeatsRolloutAndDefault() + { + var data = new FeatureFlagEvaluationData( + "flag", + DefaultEnabled: true, + RolloutPercentage: 100, + UserOverrideEffect: null, + DecisionEffect.Deny + ); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, UserId), Is.False); + } + + [Test] + public void Evaluate_NoOverrides_RolloutMatchBeatsDefaultDisabled() + { + var data = new FeatureFlagEvaluationData("flag", DefaultEnabled: false, RolloutPercentage: 100, null, null); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, UserId), Is.True); + } + + [Test] + public void Evaluate_NoOverrides_RolloutNonMatch_FallsThroughToDefault() + { + var data = new FeatureFlagEvaluationData("flag", DefaultEnabled: true, RolloutPercentage: 0, null, null); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, UserId), Is.True); + } + + [Test] + public void Evaluate_AnonymousUser_NeverTriggersRolloutRegardlessOfPercentage() + { + var data = new FeatureFlagEvaluationData("flag", DefaultEnabled: false, RolloutPercentage: 100, null, null); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, userId: null), Is.False); + } + + [Test] + public void Evaluate_AnonymousUser_FallsThroughToDefaultEnabled() + { + var data = new FeatureFlagEvaluationData("flag", DefaultEnabled: true, RolloutPercentage: 0, null, null); + + Assert.That(FeatureFlagEvaluator.Evaluate(data, userId: null), Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Games/DifficultyRampProblemSelectionStrategyTests.cs b/StackDuel.Application.Tests/Games/DifficultyRampProblemSelectionStrategyTests.cs new file mode 100644 index 0000000..116681f --- /dev/null +++ b/StackDuel.Application.Tests/Games/DifficultyRampProblemSelectionStrategyTests.cs @@ -0,0 +1,123 @@ +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Application.Tests.Games; + +public class DifficultyRampProblemSelectionStrategyTests +{ + private const string GameModeKey = "duel"; + + private Mock _problemReadRepository = null!; + private DifficultyRampProblemSelectionStrategy _strategy = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _strategy = new DifficultyRampProblemSelectionStrategy(GameModeKey, _problemReadRepository.Object); + } + + [Test] + public async Task SelectNextProblemIdAsync_SolverAndSkipperAtSameRound_UseTheSameSelectionSeed() + { + var gameId = Guid.NewGuid(); + var poolId = Guid.NewGuid(); + var excludedProblemIds = new[] { Guid.NewGuid() }; + var capturedSeeds = new List(); + + _problemReadRepository + .Setup(r => + r.GetRandomProblemIdByDifficultyAsync( + poolId, + It.IsAny(), + It.IsAny(), + excludedProblemIds, + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, long, IReadOnlyCollection, CancellationToken>( + (_, _, _, _, seed, _, _) => capturedSeeds.Add(seed) + ) + .ReturnsAsync(Guid.NewGuid()); + + await _strategy.SelectNextProblemIdAsync( + new ProblemSelectionContext(gameId, poolId, GameModeKey, RoundIndex: 1, excludedProblemIds, []) + ); + await _strategy.SelectNextProblemIdAsync( + new ProblemSelectionContext(gameId, poolId, GameModeKey, RoundIndex: 1, excludedProblemIds, []) + ); + + Assert.That(capturedSeeds, Has.Count.EqualTo(2)); + Assert.That(capturedSeeds[0], Is.EqualTo(capturedSeeds[1])); + } + + [Test] + public async Task SelectNextProblemIdAsync_DifferentGames_UseDifferentSelectionSeeds() + { + var poolId = Guid.NewGuid(); + var excludedProblemIds = new[] { Guid.NewGuid() }; + var capturedSeeds = new List(); + + _problemReadRepository + .Setup(r => + r.GetRandomProblemIdByDifficultyAsync( + poolId, + It.IsAny(), + It.IsAny(), + excludedProblemIds, + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, long, IReadOnlyCollection, CancellationToken>( + (_, _, _, _, seed, _, _) => capturedSeeds.Add(seed) + ) + .ReturnsAsync(Guid.NewGuid()); + + await _strategy.SelectNextProblemIdAsync( + new ProblemSelectionContext(Guid.NewGuid(), poolId, GameModeKey, RoundIndex: 1, excludedProblemIds, []) + ); + await _strategy.SelectNextProblemIdAsync( + new ProblemSelectionContext(Guid.NewGuid(), poolId, GameModeKey, RoundIndex: 1, excludedProblemIds, []) + ); + + Assert.That(capturedSeeds, Has.Count.EqualTo(2)); + Assert.That(capturedSeeds[0], Is.Not.EqualTo(capturedSeeds[1])); + } + + [Test] + public async Task SelectNextProblemIdAsync_RoundIndexPastFirstBand_UsesTheNextDifficultyBand() + { + var poolId = Guid.NewGuid(); + var capturedRanges = new List<(int Min, int Max)>(); + + _problemReadRepository + .Setup(r => + r.GetRandomProblemIdByDifficultyAsync( + poolId, + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, long, IReadOnlyCollection, CancellationToken>( + (_, min, max, _, _, _, _) => capturedRanges.Add((min, max)) + ) + .ReturnsAsync(Guid.NewGuid()); + + await _strategy.SelectNextProblemIdAsync( + new ProblemSelectionContext(Guid.NewGuid(), poolId, GameModeKey, RoundIndex: 3, [], []) + ); + + Assert.That(capturedRanges, Has.Count.EqualTo(1)); + Assert.That(capturedRanges[0], Is.EqualTo((121, Difficulty.BeginnerMax))); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Games/GameProblemAdvancerTests.cs b/StackDuel.Application.Tests/Games/GameProblemAdvancerTests.cs new file mode 100644 index 0000000..5e4b02e --- /dev/null +++ b/StackDuel.Application.Tests/Games/GameProblemAdvancerTests.cs @@ -0,0 +1,168 @@ +using Moq; +using StackDuel.Application.Games; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Tests.Games; + +public class GameProblemAdvancerTests +{ + private Mock _sequencer = null!; + private Mock _expiryCanceller = null!; + private Mock _strategyResolver = null!; + private Mock _strategy = null!; + private GameProblemAdvancer _advancer = null!; + + [SetUp] + public void SetUp() + { + _sequencer = new Mock(); + _expiryCanceller = new Mock(); + _strategyResolver = new Mock(); + _strategy = new Mock(); + + _strategyResolver.Setup(x => x.Resolve(It.IsAny())).Returns(_strategy.Object); + + _advancer = new GameProblemAdvancer(_sequencer.Object, _expiryCanceller.Object, _strategyResolver.Object); + } + + private static Game CreateRunningGameWithParticipant(out GameParticipant participant) + { + Guid userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 600); + game.Start(); + participant = game.Participants.First(p => p.UserId == userId); + participant.InitializeProblemSession(Guid.NewGuid()); + return game; + } + + [Test] + public async Task AdvanceAsync_NextProblemAvailable_InvokesOnAdvanceWithIt() + { + var game = CreateRunningGameWithParticipant(out var participant); + var nextProblemId = Guid.NewGuid(); + _sequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + "solo_rush", + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(nextProblemId); + Guid? advancedTo = null; + + Guid? result = await _advancer.AdvanceAsync( + game, + participant, + "solo_rush", + onAdvance: id => advancedTo = id, + onFinish: null, + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(nextProblemId)); + Assert.That(advancedTo, Is.EqualTo(nextProblemId)); + Assert.That(participant.HasFinishedProblems, Is.False); + }); + _expiryCanceller.Verify( + x => x.CancelIfScheduledAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task AdvanceAsync_NoNextProblem_FinishesParticipantAndCancelsScheduledExpiry() + { + var game = CreateRunningGameWithParticipant(out var participant); + _sequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + It.IsAny(), + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync((Guid?)null); + + Guid? result = await _advancer.AdvanceAsync( + game, + participant, + "solo_rush", + onAdvance: _ => Assert.Fail("onAdvance should not run when the pool is exhausted."), + onFinish: null, + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result, Is.Null); + Assert.That(participant.HasFinishedProblems, Is.True); + }); + _expiryCanceller.Verify(x => x.CancelIfScheduledAsync(game, It.IsAny()), Times.Once); + } + + [Test] + public async Task AdvanceAsync_NoNextProblem_InvokesOnFinishBeforeMarkingParticipantFinished() + { + var game = CreateRunningGameWithParticipant(out var participant); + _sequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + It.IsAny(), + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync((Guid?)null); + bool participantAlreadyFinishedWhenOnFinishRan = true; + + await _advancer.AdvanceAsync( + game, + participant, + "solo_rush", + onAdvance: _ => { }, + onFinish: () => participantAlreadyFinishedWhenOnFinishRan = participant.HasFinishedProblems, + CancellationToken.None + ); + + // Mirrors SkipProblemHandler's original ordering: UseSkip() ran before FinishProblemsFor, + // so a skip is always consumed even when the finish was caused by pool exhaustion. + Assert.That(participantAlreadyFinishedWhenOnFinishRan, Is.False); + } + + [Test] + public async Task AdvanceAsync_ResolvesStrategyForTheGivenGameModeKey() + { + var game = CreateRunningGameWithParticipant(out var participant); + _sequencer + .Setup(x => + x.GetOrGenerateProblemAsync( + game, + It.IsAny(), + "duel", + _strategy.Object, + It.IsAny() + ) + ) + .ReturnsAsync(Guid.NewGuid()); + + await _advancer.AdvanceAsync( + game, + participant, + "duel", + onAdvance: _ => { }, + onFinish: null, + CancellationToken.None + ); + + _strategyResolver.Verify(x => x.Resolve("duel"), Times.Once); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Games/GameProblemSequencerTests.cs b/StackDuel.Application.Tests/Games/GameProblemSequencerTests.cs new file mode 100644 index 0000000..c4ca09f --- /dev/null +++ b/StackDuel.Application.Tests/Games/GameProblemSequencerTests.cs @@ -0,0 +1,167 @@ +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Languages; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Tests.Games; + +public class GameProblemSequencerTests +{ + private Mock _languageReadRepository = null!; + private Mock _strategy = null!; + private GameProblemSequencer _sequencer = null!; + + [SetUp] + public void SetUp() + { + _languageReadRepository = new Mock(); + _strategy = new Mock(); + + _languageReadRepository + .Setup(x => + x.GetLanguageVersionIdsByLanguageIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([]); + + _sequencer = new GameProblemSequencer(_languageReadRepository.Object); + } + + private static Game CreateGame( + Guid? trackId = null, + Dictionary>? trackLanguageIds = null + ) => + new( + Guid.NewGuid(), + Guid.NewGuid(), + [trackId ?? Guid.NewGuid()], + [Guid.NewGuid()], + 600, + trackLanguageIds: trackLanguageIds + ); + + [Test] + public async Task GetOrGenerateProblemAsync_PositionAlreadyInSequence_ReusesItWithoutCallingTheStrategy() + { + var game = CreateGame(); + var existingProblemId = Guid.NewGuid(); + game.AppendProblem(existingProblemId); + + Guid? result = await _sequencer.GetOrGenerateProblemAsync( + game, + 0, + "solo_rush", + _strategy.Object, + CancellationToken.None + ); + + Assert.That(result, Is.EqualTo(existingProblemId)); + _strategy.Verify( + x => x.SelectNextProblemIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task GetOrGenerateProblemAsync_PositionNotYetInSequence_GeneratesAndAppendsIt() + { + var game = CreateGame(); + var generatedProblemId = Guid.NewGuid(); + _strategy + .Setup(x => x.SelectNextProblemIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(generatedProblemId); + + Guid? result = await _sequencer.GetOrGenerateProblemAsync( + game, + 0, + "solo_rush", + _strategy.Object, + CancellationToken.None + ); + + Assert.That(result, Is.EqualTo(generatedProblemId)); + Assert.That(game.ProblemIdAtPosition(0), Is.EqualTo(generatedProblemId)); + } + + [Test] + public async Task GetOrGenerateProblemAsync_StrategyReturnsNull_DoesNotAppendAndReturnsNull() + { + var game = CreateGame(); + _strategy + .Setup(x => x.SelectNextProblemIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid?)null); + + Guid? result = await _sequencer.GetOrGenerateProblemAsync( + game, + 0, + "solo_rush", + _strategy.Object, + CancellationToken.None + ); + + Assert.That(result, Is.Null); + Assert.That(game.ProblemSequence, Is.Empty); + } + + [Test] + public async Task GetOrGenerateProblemAsync_ExcludesProblemsAlreadyInTheSharedSequence() + { + var game = CreateGame(); + var firstProblemId = Guid.NewGuid(); + var secondProblemId = Guid.NewGuid(); + game.AppendProblem(firstProblemId); + game.AppendProblem(secondProblemId); + ProblemSelectionContext? capturedContext = null; + _strategy + .Setup(x => x.SelectNextProblemIdAsync(It.IsAny(), It.IsAny())) + .Callback((context, _) => capturedContext = context) + .ReturnsAsync(Guid.NewGuid()); + + await _sequencer.GetOrGenerateProblemAsync(game, 2, "solo_rush", _strategy.Object, CancellationToken.None); + + Assert.That(capturedContext, Is.Not.Null); + Assert.That(capturedContext!.ExcludedProblemIds, Is.EquivalentTo(new[] { firstProblemId, secondProblemId })); + } + + [Test] + public async Task GetOrGenerateProblemAsync_PassesThePositionAsTheRoundIndex() + { + var game = CreateGame(); + ProblemSelectionContext? capturedContext = null; + _strategy + .Setup(x => x.SelectNextProblemIdAsync(It.IsAny(), It.IsAny())) + .Callback((context, _) => capturedContext = context) + .ReturnsAsync(Guid.NewGuid()); + + await _sequencer.GetOrGenerateProblemAsync(game, 5, "solo_rush", _strategy.Object, CancellationToken.None); + + Assert.That(capturedContext, Is.Not.Null); + Assert.That(capturedContext!.RoundIndex, Is.EqualTo(5)); + } + + [Test] + public async Task GetOrGenerateProblemAsync_ResolvesSelectedTrackLanguagesIntoAllowedLanguageVersionIds() + { + var trackId = Guid.NewGuid(); + var languageId = Guid.NewGuid(); + var versionId = Guid.NewGuid(); + var game = CreateGame(trackId, new Dictionary> { [trackId] = [languageId] }); + _languageReadRepository + .Setup(x => + x.GetLanguageVersionIdsByLanguageIdsAsync( + It.Is>(ids => ids.Contains(languageId)), + It.IsAny() + ) + ) + .ReturnsAsync([versionId]); + ProblemSelectionContext? capturedContext = null; + _strategy + .Setup(x => x.SelectNextProblemIdAsync(It.IsAny(), It.IsAny())) + .Callback((context, _) => capturedContext = context) + .ReturnsAsync(Guid.NewGuid()); + + await _sequencer.GetOrGenerateProblemAsync(game, 0, "solo_rush", _strategy.Object, CancellationToken.None); + + Assert.That(capturedContext, Is.Not.Null); + Assert.That(capturedContext!.AllowedLanguageVersionIds, Is.EquivalentTo(new[] { versionId })); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Images/ImageSignatureTests.cs b/StackDuel.Application.Tests/Images/ImageSignatureTests.cs new file mode 100644 index 0000000..c568e3b --- /dev/null +++ b/StackDuel.Application.Tests/Images/ImageSignatureTests.cs @@ -0,0 +1,84 @@ +using StackDuel.Application.Images; + +namespace StackDuel.Application.Tests.Images; + +public class ImageSignatureTests +{ + [Test] + public void Detect_PngSignature_ReturnsPng() + { + byte[] content = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]; + + ImageFormat? format = ImageSignature.Detect(content); + + Assert.That(format?.ContentType, Is.EqualTo("image/png")); + } + + [Test] + public void Detect_JpegSignature_ReturnsJpeg() + { + byte[] content = [0xFF, 0xD8, 0xFF, 0, 0]; + + ImageFormat? format = ImageSignature.Detect(content); + + Assert.That(format?.ContentType, Is.EqualTo("image/jpeg")); + } + + [Test] + public void Detect_WebpSignature_ReturnsWebp() + { + byte[] content = + [ + (byte)'R', + (byte)'I', + (byte)'F', + (byte)'F', + 0, + 0, + 0, + 0, + (byte)'W', + (byte)'E', + (byte)'B', + (byte)'P', + ]; + + ImageFormat? format = ImageSignature.Detect(content); + + Assert.That(format?.ContentType, Is.EqualTo("image/webp")); + } + + [TestCase(new byte[] { })] + [TestCase(new byte[] { 0x47, 0x49, 0x46, 0x38, 0x39, 0x61 })] + [TestCase(new byte[] { 0x25, 0x50, 0x44, 0x46 })] + public void Detect_UnrecognizedOrEmptyContent_ReturnsNull(byte[] content) + { + ImageFormat? format = ImageSignature.Detect(content); + + Assert.That(format, Is.Null); + } + + [Test] + public void Detect_ContentTypeHeaderClaimingPngButBytesAreNotPng_IsNotFooled() + { + byte[] htmlBytes = ""u8.ToArray(); + + ImageFormat? format = ImageSignature.Detect(htmlBytes); + + Assert.That(format, Is.Null); + } + + [TestCase("image/png", ".png")] + [TestCase("image/jpeg", ".jpg")] + [TestCase("image/webp", ".webp")] + public void ExtensionFor_SupportedContentType_ReturnsMatchingExtension(string contentType, string expectedExtension) + { + Assert.That(ImageSignature.ExtensionFor(contentType), Is.EqualTo(expectedExtension)); + } + + [Test] + public void ExtensionFor_UnsupportedContentType_Throws() + { + Assert.Throws(() => ImageSignature.ExtensionFor("image/gif")); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/LanguageServer/LspFramingTests.cs b/StackDuel.Application.Tests/LanguageServer/LspFramingTests.cs new file mode 100644 index 0000000..97f27f4 --- /dev/null +++ b/StackDuel.Application.Tests/LanguageServer/LspFramingTests.cs @@ -0,0 +1,86 @@ +using StackDuel.Application.LanguageServer; + +namespace StackDuel.Application.Tests.LanguageServer; + +public class LspFramingTests +{ + [Test] + public async Task WriteThenRead_RoundTripsMessage() + { + using MemoryStream stream = new(); + + await LspFraming.WriteMessageAsync(stream, "{\"hello\":\"world\"}", CancellationToken.None); + stream.Position = 0; + + string? message = await LspFraming.ReadMessageAsync(stream, CancellationToken.None); + + Assert.That(message, Is.EqualTo("{\"hello\":\"world\"}")); + } + + [Test] + public async Task WriteThenRead_HandlesMultiByteUtf8ContentLengthCorrectly() + { + using MemoryStream stream = new(); + string message = "{\"emoji\":\"🚀\",\"text\":\"café\"}"; + + await LspFraming.WriteMessageAsync(stream, message, CancellationToken.None); + stream.Position = 0; + + string? read = await LspFraming.ReadMessageAsync(stream, CancellationToken.None); + + Assert.That(read, Is.EqualTo(message)); + } + + [Test] + public async Task ReadMessageAsync_ReadsMultipleSequentialMessages() + { + using MemoryStream stream = new(); + await LspFraming.WriteMessageAsync(stream, "first", CancellationToken.None); + await LspFraming.WriteMessageAsync(stream, "second", CancellationToken.None); + stream.Position = 0; + + string? first = await LspFraming.ReadMessageAsync(stream, CancellationToken.None); + string? second = await LspFraming.ReadMessageAsync(stream, CancellationToken.None); + + Assert.That(first, Is.EqualTo("first")); + Assert.That(second, Is.EqualTo("second")); + } + + [Test] + public async Task ReadMessageAsync_ToleratesAdditionalHeaders() + { + using MemoryStream stream = new(); + byte[] body = System.Text.Encoding.UTF8.GetBytes("{}"); + byte[] header = System.Text.Encoding.ASCII.GetBytes( + $"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\nContent-Length: {body.Length}\r\n\r\n" + ); + await stream.WriteAsync(header); + await stream.WriteAsync(body); + stream.Position = 0; + + string? message = await LspFraming.ReadMessageAsync(stream, CancellationToken.None); + + Assert.That(message, Is.EqualTo("{}")); + } + + [Test] + public async Task ReadMessageAsync_EmptyStream_ReturnsNull() + { + using MemoryStream stream = new(); + + string? message = await LspFraming.ReadMessageAsync(stream, CancellationToken.None); + + Assert.That(message, Is.Null); + } + + [Test] + public void ReadMessageAsync_MissingContentLength_Throws() + { + using MemoryStream stream = new(); + byte[] header = System.Text.Encoding.ASCII.GetBytes("Content-Type: application/json\r\n\r\n"); + stream.Write(header); + stream.Position = 0; + + Assert.ThrowsAsync(() => LspFraming.ReadMessageAsync(stream, CancellationToken.None)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeHandlerTests.cs b/StackDuel.Application.Tests/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeHandlerTests.cs new file mode 100644 index 0000000..0eea79e --- /dev/null +++ b/StackDuel.Application.Tests/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeHandlerTests.cs @@ -0,0 +1,246 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.Problems; +using StackDuel.Application.Submissions; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using GetTodaysDailyChallengeHandler = StackDuel.Application.Queries.DailyChallenges.GetTodaysDailyChallenge.GetTodaysDailyChallengeHandler; +using GetTodaysDailyChallengeQuery = StackDuel.Application.Queries.DailyChallenges.GetTodaysDailyChallenge.GetTodaysDailyChallengeQuery; + +namespace StackDuel.Application.Tests.Queries.DailyChallenges.GetTodaysDailyChallenge; + +public class GetTodaysDailyChallengeHandlerTests +{ + private Mock _dailyChallengeRepository = null!; + private Mock _problemReadRepository = null!; + private Mock _submissionReadRepository = null!; + private GetTodaysDailyChallengeHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _dailyChallengeRepository = new Mock(); + _problemReadRepository = new Mock(); + _submissionReadRepository = new Mock(); + + _handler = new GetTodaysDailyChallengeHandler( + _dailyChallengeRepository.Object, + _problemReadRepository.Object, + _submissionReadRepository.Object + ); + } + + private static Problem CreateProblem(string slug, string title, int difficulty) => + new( + new Slug(slug), + new Title(title), + new Question(new string('a', 50)), + new Difficulty(difficulty), + new TimeLimit(1000), + new MemoryLimit(256) + ); + + [Test] + public async Task Handle_NoChallengeToday_ReturnsNotFound() + { + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((DailyChallenge?)null); + + var result = await _handler.Handle(new GetTodaysDailyChallengeQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_TodaySolved_CurrentStreakIncludesToday() + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly yesterday = today.AddDays(-1); + Guid userId = Guid.NewGuid(); + Guid todayProblemId = Guid.NewGuid(); + Guid yesterdayProblemId = Guid.NewGuid(); + var todaysChallenge = new DailyChallenge(today, todayProblemId); + var yesterdaysChallenge = new DailyChallenge(yesterday, yesterdayProblemId); + Problem problem = CreateProblem("today-problem", "Today Problem", 50); + + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(today, It.IsAny())) + .ReturnsAsync(todaysChallenge); + _dailyChallengeRepository + .Setup(x => x.GetAllOrderedByDateDescendingAsync(It.IsAny())) + .ReturnsAsync([todaysChallenge, yesterdaysChallenge]); + _problemReadRepository + .Setup(x => x.FindByIdAsync(todayProblemId, It.IsAny())) + .ReturnsAsync(problem); + _submissionReadRepository + .Setup(x => + x.GetAcceptedProblemIdsForUserAsync( + userId, + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([todayProblemId, yesterdayProblemId]); + + var result = await _handler.Handle(new GetTodaysDailyChallengeQuery(userId), CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.SolvedByCurrentUser, Is.True); + Assert.That(result.Value.CurrentStreak, Is.EqualTo(2)); + Assert.That(result.Value.LongestStreak, Is.EqualTo(2)); + }); + } + + [Test] + public async Task Handle_TodayUnsolved_DoesNotBreakExistingStreak() + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly yesterday = today.AddDays(-1); + DateOnly twoDaysAgo = today.AddDays(-2); + Guid userId = Guid.NewGuid(); + Guid todayProblemId = Guid.NewGuid(); + Guid yesterdayProblemId = Guid.NewGuid(); + Guid twoDaysAgoProblemId = Guid.NewGuid(); + var todaysChallenge = new DailyChallenge(today, todayProblemId); + var yesterdaysChallenge = new DailyChallenge(yesterday, yesterdayProblemId); + var twoDaysAgoChallenge = new DailyChallenge(twoDaysAgo, twoDaysAgoProblemId); + Problem problem = CreateProblem("today-problem", "Today Problem", 50); + + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(today, It.IsAny())) + .ReturnsAsync(todaysChallenge); + _dailyChallengeRepository + .Setup(x => x.GetAllOrderedByDateDescendingAsync(It.IsAny())) + .ReturnsAsync([todaysChallenge, yesterdaysChallenge, twoDaysAgoChallenge]); + _problemReadRepository + .Setup(x => x.FindByIdAsync(todayProblemId, It.IsAny())) + .ReturnsAsync(problem); + _submissionReadRepository + .Setup(x => + x.GetAcceptedProblemIdsForUserAsync( + userId, + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([yesterdayProblemId, twoDaysAgoProblemId]); + + var result = await _handler.Handle(new GetTodaysDailyChallengeQuery(userId), CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.SolvedByCurrentUser, Is.False); + Assert.That(result.Value.CurrentStreak, Is.EqualTo(2)); + Assert.That(result.Value.LongestStreak, Is.EqualTo(2)); + }); + } + + [Test] + public async Task Handle_GapInSolvedHistory_LongestStreakTracksBestRunNotJustCurrent() + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + Guid userId = Guid.NewGuid(); + + List challenges = + [ + .. Enumerable.Range(0, 5).Select(i => new DailyChallenge(today.AddDays(-i), Guid.NewGuid())), + ]; + DailyChallenge todaysChallenge = challenges[0]; + Problem problem = CreateProblem("today-problem", "Today Problem", 50); + + Guid[] solvedIds = + [ + challenges[0].ProblemId, + challenges[2].ProblemId, + challenges[3].ProblemId, + challenges[4].ProblemId, + ]; + + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(today, It.IsAny())) + .ReturnsAsync(todaysChallenge); + _dailyChallengeRepository + .Setup(x => x.GetAllOrderedByDateDescendingAsync(It.IsAny())) + .ReturnsAsync(challenges); + _problemReadRepository + .Setup(x => x.FindByIdAsync(todaysChallenge.ProblemId, It.IsAny())) + .ReturnsAsync(problem); + _submissionReadRepository + .Setup(x => + x.GetAcceptedProblemIdsForUserAsync( + userId, + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(solvedIds); + + var result = await _handler.Handle(new GetTodaysDailyChallengeQuery(userId), CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.CurrentStreak, Is.EqualTo(1)); + Assert.That(result.Value.LongestStreak, Is.EqualTo(3)); + }); + } + + [Test] + public async Task Handle_FutureChallengeExists_DoesNotAffectStreakOrGetReturned() + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly tomorrow = today.AddDays(1); + Guid userId = Guid.NewGuid(); + Guid tomorrowProblemId = Guid.NewGuid(); + Guid todayProblemId = Guid.NewGuid(); + Guid yesterdayProblemId = Guid.NewGuid(); + var tomorrowsChallenge = new DailyChallenge(tomorrow, tomorrowProblemId); + var todaysChallenge = new DailyChallenge(today, todayProblemId); + var yesterdaysChallenge = new DailyChallenge(today.AddDays(-1), yesterdayProblemId); + Problem problem = CreateProblem("today-problem", "Today Problem", 50); + + _dailyChallengeRepository + .Setup(x => x.FindByDateAsync(today, It.IsAny())) + .ReturnsAsync(todaysChallenge); + _dailyChallengeRepository + .Setup(x => x.GetAllOrderedByDateDescendingAsync(It.IsAny())) + .ReturnsAsync([tomorrowsChallenge, todaysChallenge, yesterdaysChallenge]); + _problemReadRepository + .Setup(x => x.FindByIdAsync(todayProblemId, It.IsAny())) + .ReturnsAsync(problem); + _submissionReadRepository + .Setup(x => + x.GetAcceptedProblemIdsForUserAsync( + userId, + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([tomorrowProblemId, todayProblemId, yesterdayProblemId]); + + var result = await _handler.Handle(new GetTodaysDailyChallengeQuery(userId), CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.ProblemId, Is.EqualTo(problem.Id)); + Assert.That(result.Value.CurrentStreak, Is.EqualTo(2)); + Assert.That(result.Value.LongestStreak, Is.EqualTo(2)); + }); + _submissionReadRepository.Verify( + x => + x.GetAcceptedProblemIdsForUserAsync( + userId, + It.Is>(ids => !ids.Contains(tomorrowProblemId)), + It.IsAny() + ), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesHandlerTests.cs b/StackDuel.Application.Tests/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesHandlerTests.cs new file mode 100644 index 0000000..2ad08ad --- /dev/null +++ b/StackDuel.Application.Tests/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesHandlerTests.cs @@ -0,0 +1,106 @@ +using Moq; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Domain.Problems.Enums; +using GetUpcomingDailyChallengesHandler = StackDuel.Application.Queries.DailyChallenges.GetUpcomingDailyChallenges.GetUpcomingDailyChallengesHandler; +using GetUpcomingDailyChallengesQuery = StackDuel.Application.Queries.DailyChallenges.GetUpcomingDailyChallenges.GetUpcomingDailyChallengesQuery; + +namespace StackDuel.Application.Tests.Queries.DailyChallenges.GetUpcomingDailyChallenges; + +public class GetUpcomingDailyChallengesHandlerTests +{ + private Mock _dailyChallengeRepository = null!; + private Mock _problemReadRepository = null!; + private GetUpcomingDailyChallengesHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _dailyChallengeRepository = new Mock(); + _problemReadRepository = new Mock(); + _handler = new GetUpcomingDailyChallengesHandler( + _dailyChallengeRepository.Object, + _problemReadRepository.Object + ); + } + + [Test] + public async Task Handle_NoUpcomingChallenges_ReturnsEmptyWithoutQueryingProblems() + { + _dailyChallengeRepository + .Setup(x => x.GetUpcomingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + var result = await _handler.Handle(new GetUpcomingDailyChallengesQuery(), CancellationToken.None); + + Assert.That(result.Value, Is.Empty); + _problemReadRepository.Verify( + x => x.FindByIdsAsync(It.IsAny>(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_UpcomingChallengesExist_MapsProblemDetails() + { + var problemId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(3); + _dailyChallengeRepository + .Setup(x => x.GetUpcomingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([new DailyChallenge(date, problemId)]); + _problemReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([ + new AdminProblemListRowDto( + problemId, + "two-sum", + "Two Sum", + 150, + ProblemStatus.Published, + 1000, + 256, + [], + [], + 0, + DateTime.UtcNow, + null + ), + ]); + + var result = await _handler.Handle(new GetUpcomingDailyChallengesQuery(), CancellationToken.None); + + var dto = result.Value.Single(); + Assert.Multiple(() => + { + Assert.That(dto.Date, Is.EqualTo(date)); + Assert.That(dto.ProblemId, Is.EqualTo(problemId)); + Assert.That(dto.ProblemSlug, Is.EqualTo("two-sum")); + Assert.That(dto.ProblemTitle, Is.EqualTo("Two Sum")); + Assert.That(dto.Status, Is.EqualTo(ProblemStatus.Published)); + }); + } + + [Test] + public async Task Handle_ProblemNoLongerExists_FallsBackToPlaceholder() + { + var problemId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(3); + _dailyChallengeRepository + .Setup(x => x.GetUpcomingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([new DailyChallenge(date, problemId)]); + _problemReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + var result = await _handler.Handle(new GetUpcomingDailyChallengesQuery(), CancellationToken.None); + + var dto = result.Value.Single(); + Assert.Multiple(() => + { + Assert.That(dto.ProblemTitle, Is.EqualTo("(problem no longer exists)")); + Assert.That(dto.Status, Is.EqualTo(ProblemStatus.Archived)); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsHandlerTests.cs b/StackDuel.Application.Tests/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsHandlerTests.cs new file mode 100644 index 0000000..7cc23c3 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsHandlerTests.cs @@ -0,0 +1,44 @@ +using Moq; +using StackDuel.Application.Dashboard; +using StackDuel.Application.Dashboard.Dtos; +using StackDuel.Domain.Feedback.Enums; +using GetAdminDashboardStatsHandler = StackDuel.Application.Queries.Dashboard.GetAdminDashboardStats.GetAdminDashboardStatsHandler; +using GetAdminDashboardStatsQuery = StackDuel.Application.Queries.Dashboard.GetAdminDashboardStats.GetAdminDashboardStatsQuery; + +namespace StackDuel.Application.Tests.Queries.Dashboard.GetAdminDashboardStats; + +public class GetAdminDashboardStatsHandlerTests +{ + private Mock _dashboardReadRepository = null!; + private GetAdminDashboardStatsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _dashboardReadRepository = new Mock(); + _handler = new GetAdminDashboardStatsHandler(_dashboardReadRepository.Object); + } + + [Test] + public async Task Handle_DelegatesToRepositoryWithRequestedWindow_ReturnsStats() + { + var stats = new AdminDashboardStatsDto( + TotalUsers: 10, + TotalProblems: 5, + TotalGames: 3, + TotalSubmissions: 42, + NewUsersByDay: [new DailyUserCountDto(new DateOnly(2026, 8, 30), 2)], + FeedbackByStatus: [new FeedbackStatusCountDto(FeedbackStatus.New, 1)] + ); + + _dashboardReadRepository + .Setup(r => r.GetAdminDashboardStatsAsync(30, CancellationToken.None)) + .ReturnsAsync(stats); + + var result = await _handler.Handle(new GetAdminDashboardStatsQuery(30), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(stats)); + _dashboardReadRepository.Verify(r => r.GetAdminDashboardStatsAsync(30, CancellationToken.None), Times.Once); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetAdminGameDetail/GetAdminGameDetailHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetAdminGameDetail/GetAdminGameDetailHandlerTests.cs new file mode 100644 index 0000000..42b95ea --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetAdminGameDetail/GetAdminGameDetailHandlerTests.cs @@ -0,0 +1,111 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using GetAdminGameDetailHandler = StackDuel.Application.Queries.Games.GetAdminGameDetail.GetAdminGameDetailHandler; +using GetAdminGameDetailQuery = StackDuel.Application.Queries.Games.GetAdminGameDetail.GetAdminGameDetailQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetAdminGameDetail; + +public class GetAdminGameDetailHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _userReadRepository = null!; + private Mock _trackReadRepository = null!; + private GetAdminGameDetailHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _userReadRepository = new Mock(); + _trackReadRepository = new Mock(); + _trackReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + _handler = new GetAdminGameDetailHandler( + _gameReadRepository.Object, + _userReadRepository.Object, + _trackReadRepository.Object + ); + } + + private static UserDto MakeUser(Guid id, string username) => + new(id, $"auth0|{id}", username, null, null, false, null, DateTime.UtcNow, null, []); + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(r => r.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var result = await _handler.Handle(new GetAdminGameDetailQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_GameModeMissing_ReturnsError() + { + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [Guid.NewGuid()], 300); + + _gameReadRepository.Setup(r => r.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(r => r.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var result = await _handler.Handle(new GetAdminGameDetailQuery(game.Id), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task Handle_RequestedByNonParticipant_StillSucceeds() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + var gameMode = new GameMode("duel", "Duel", "desc", true, 2, 2, Guid.NewGuid()); + + _gameReadRepository.Setup(r => r.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(r => r.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(r => r.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { [participantId] = MakeUser(participantId, "alice") }); + + var result = await _handler.Handle(new GetAdminGameDetailQuery(game.Id), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.Participants.Single().Username, Is.EqualTo("alice")); + } + + [Test] + public async Task Handle_ExpiredRunningGame_DoesNotCompleteIt() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 1); + game.Start(); + var gameMode = new GameMode("duel", "Duel", "desc", true, 2, 2, Guid.NewGuid()); + + _gameReadRepository.Setup(r => r.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(r => r.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(r => r.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var result = await _handler.Handle(new GetAdminGameDetailQuery(game.Id), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(GameStatus.Running), "admin read must not mutate game state"); + Assert.That(result.Value.Status, Is.EqualTo(GameStatus.Running)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryHandlerTests.cs new file mode 100644 index 0000000..a19e74b --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryHandlerTests.cs @@ -0,0 +1,180 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Submissions.Enums; +using GetAdminGamePlayerHistoryHandler = StackDuel.Application.Queries.Games.GetAdminGamePlayerHistory.GetAdminGamePlayerHistoryHandler; +using GetAdminGamePlayerHistoryQuery = StackDuel.Application.Queries.Games.GetAdminGamePlayerHistory.GetAdminGamePlayerHistoryQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetAdminGamePlayerHistory; + +public class GetAdminGamePlayerHistoryHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _submissionReadRepository = null!; + private Mock _problemReadRepository = null!; + private GetAdminGamePlayerHistoryHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _submissionReadRepository = new Mock(); + _problemReadRepository = new Mock(); + _handler = new GetAdminGamePlayerHistoryHandler( + _gameReadRepository.Object, + _submissionReadRepository.Object, + _problemReadRepository.Object + ); + } + + private static AdminProblemListRowDto MakeProblemRow(Guid id, string title, string slug) => + new(id, slug, title, 1, ProblemStatus.Published, 1000, 256, [], [], 1, DateTime.UtcNow, null); + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(r => r.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var result = await _handler.Handle(new GetAdminGamePlayerHistoryQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ParticipantWithSolvedAndWrongSubmissions_OrdersEventsChronologically() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + var problemId = Guid.NewGuid(); + + var wrongSubmissionId = Guid.NewGuid(); + var acceptedSubmissionId = Guid.NewGuid(); + var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + _gameReadRepository.Setup(r => r.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionReadRepository + .Setup(r => r.GetSubmissionsForGameAsync(game.Id, It.IsAny())) + .ReturnsAsync([ + new GameSubmissionEventDto( + wrongSubmissionId, + participantId, + problemId, + "Two Sum", + "two-sum", + SubmissionStatus.WrongAnswer, + baseTime, + "Python" + ), + new GameSubmissionEventDto( + acceptedSubmissionId, + participantId, + problemId, + "Two Sum", + "two-sum", + SubmissionStatus.Accepted, + baseTime.AddMinutes(2), + "Python" + ), + ]); + + var result = await _handler.Handle(new GetAdminGamePlayerHistoryQuery(game.Id), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + var history = result.Value.Single(h => h.UserId == participantId); + Assert.That(history.Events, Has.Count.EqualTo(2)); + Assert.Multiple(() => + { + Assert.That(history.Events[0].Type, Is.EqualTo(AdminGameHistoryEventType.WrongAnswer)); + Assert.That(history.Events[0].SubmissionId, Is.EqualTo(wrongSubmissionId)); + Assert.That(history.Events[1].Type, Is.EqualTo(AdminGameHistoryEventType.Accepted)); + Assert.That(history.Events[1].SubmissionId, Is.EqualTo(acceptedSubmissionId)); + }); + } + + [Test] + public async Task Handle_SkippedProblemWithNoSubmissions_ResolvesTitleFromProblemRepository() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + + var firstProblemId = Guid.NewGuid(); + var skippedProblemId = Guid.NewGuid(); + + GameParticipant participant = game.Participants.Single(p => p.UserId == participantId); + participant.InitializeProblemSession(firstProblemId); + participant.ProblemSession!.AddSkippedProblem(skippedProblemId); + + _gameReadRepository.Setup(r => r.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionReadRepository + .Setup(r => r.GetSubmissionsForGameAsync(game.Id, It.IsAny())) + .ReturnsAsync([]); + _problemReadRepository + .Setup(r => + r.FindByIdsAsync( + It.Is>(ids => ids.Contains(skippedProblemId)), + It.IsAny() + ) + ) + .ReturnsAsync([MakeProblemRow(skippedProblemId, "Merge Intervals", "merge-intervals")]); + + var result = await _handler.Handle(new GetAdminGamePlayerHistoryQuery(game.Id), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + var history = result.Value.Single(h => h.UserId == participantId); + var skipEvent = history.Events.Single(); + Assert.Multiple(() => + { + Assert.That(skipEvent.Type, Is.EqualTo(AdminGameHistoryEventType.Skipped)); + Assert.That(skipEvent.ProblemId, Is.EqualTo(skippedProblemId)); + Assert.That(skipEvent.ProblemTitle, Is.EqualTo("Merge Intervals")); + Assert.That(skipEvent.OccurredAt, Is.Null); + }); + } + + [Test] + public async Task Handle_SkippedProblemWithPriorWrongSubmission_UsesLastSubmissionTimeAsApproxTimestamp() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + var skippedProblemId = Guid.NewGuid(); + var lastAttemptAt = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc); + + GameParticipant participant = game.Participants.Single(p => p.UserId == participantId); + participant.InitializeProblemSession(skippedProblemId); + participant.ProblemSession!.AddSkippedProblem(skippedProblemId); + + _gameReadRepository.Setup(r => r.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _submissionReadRepository + .Setup(r => r.GetSubmissionsForGameAsync(game.Id, It.IsAny())) + .ReturnsAsync([ + new GameSubmissionEventDto( + Guid.NewGuid(), + participantId, + skippedProblemId, + "Merge Intervals", + "merge-intervals", + SubmissionStatus.WrongAnswer, + lastAttemptAt, + "Python" + ), + ]); + _problemReadRepository + .Setup(r => r.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([MakeProblemRow(skippedProblemId, "Merge Intervals", "merge-intervals")]); + + var result = await _handler.Handle(new GetAdminGamePlayerHistoryQuery(game.Id), CancellationToken.None); + + var history = result.Value.Single(h => h.UserId == participantId); + var skipEvent = history.Events.Single(e => e.Type == AdminGameHistoryEventType.Skipped); + Assert.That(skipEvent.OccurredAt, Is.EqualTo(lastAttemptAt)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableHandlerTests.cs new file mode 100644 index 0000000..d9dfb5c --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableHandlerTests.cs @@ -0,0 +1,136 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using GetAdminGamesPageableHandler = StackDuel.Application.Queries.Games.GetAdminGamesPageable.GetAdminGamesPageableHandler; +using GetAdminGamesPageableQuery = StackDuel.Application.Queries.Games.GetAdminGamesPageable.GetAdminGamesPageableQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetAdminGamesPageable; + +public class GetAdminGamesPageableHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _userReadRepository = null!; + private GetAdminGamesPageableHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _userReadRepository = new Mock(); + _handler = new GetAdminGamesPageableHandler(_gameReadRepository.Object, _userReadRepository.Object); + } + + private static UserDto MakeUser(Guid id, string username) => + new(id, $"auth0|{id}", username, null, null, false, null, DateTime.UtcNow, null, []); + + [Test] + public async Task Handle_PassesStatusAndPaginationToRepository() + { + var paginationRequest = new PaginationRequest { Page = 2, Size = 10 }; + _gameReadRepository + .Setup(r => + r.GetAdminGamesPagedAsync(GameStatus.Completed, paginationRequest, It.IsAny()) + ) + .ReturnsAsync( + new PageResult + { + Results = [], + Total = 0, + Page = 2, + Size = 10, + } + ); + _userReadRepository + .Setup(r => r.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetAdminGamesPageableQuery(GameStatus.Completed, paginationRequest); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _gameReadRepository.Verify( + r => r.GetAdminGamesPagedAsync(GameStatus.Completed, paginationRequest, It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_ResolvesGameModeNamesAndParticipantUsernames() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 300); + var gameMode = new GameMode("duel", "Duel", "desc", true, 2, 2, Guid.NewGuid()); + var paginationRequest = new PaginationRequest { Page = 1, Size = 20 }; + + _gameReadRepository + .Setup(r => r.GetAdminGamesPagedAsync(null, paginationRequest, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [game], + Total = 1, + Page = 1, + Size = 20, + } + ); + _gameReadRepository + .Setup(r => r.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(r => r.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { [userId] = MakeUser(userId, "riva") }); + + var query = new GetAdminGamesPageableQuery(null, paginationRequest); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + AdminGameListItemDto item = result.Value.Results.Single(); + Assert.Multiple(() => + { + Assert.That(item.GameId, Is.EqualTo(game.Id)); + Assert.That(item.GameModeKey, Is.EqualTo("duel")); + Assert.That(item.GameModeName, Is.EqualTo("Duel")); + Assert.That(item.Participants, Has.Count.EqualTo(1)); + Assert.That(item.Participants[0].Username, Is.EqualTo("riva")); + }); + } + + [Test] + public async Task Handle_MissingGameMode_FallsBackToUnknown() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 300); + var paginationRequest = new PaginationRequest { Page = 1, Size = 20 }; + + _gameReadRepository + .Setup(r => r.GetAdminGamesPagedAsync(null, paginationRequest, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [game], + Total = 1, + Page = 1, + Size = 20, + } + ); + _gameReadRepository + .Setup(r => r.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + _userReadRepository + .Setup(r => r.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetAdminGamesPageableQuery(null, paginationRequest); + Result> result = await _handler.Handle(query, CancellationToken.None); + + AdminGameListItemDto item = result.Value.Results.Single(); + Assert.That(item.GameModeKey, Is.EqualTo("unknown")); + Assert.That(item.GameModeName, Is.EqualTo("Unknown")); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordHandlerTests.cs new file mode 100644 index 0000000..1fc1ad5 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordHandlerTests.cs @@ -0,0 +1,99 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Domain.Games.Entities; +using GetDuelHeadToHeadRecordHandler = StackDuel.Application.Queries.Games.GetDuelHeadToHeadRecord.GetDuelHeadToHeadRecordHandler; +using GetDuelHeadToHeadRecordQuery = StackDuel.Application.Queries.Games.GetDuelHeadToHeadRecord.GetDuelHeadToHeadRecordQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetDuelHeadToHeadRecord; + +public class GetDuelHeadToHeadRecordHandlerTests +{ + private Mock _gameReadRepository = null!; + private GetDuelHeadToHeadRecordHandler _handler = null!; + private readonly GameMode _duelMode = new("duel", "Duel", "Head to head", true, 2, 2, Guid.NewGuid()); + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _handler = new GetDuelHeadToHeadRecordHandler(_gameReadRepository.Object); + + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync(_duelMode); + } + + private static Game CreateCompletedGame(Guid gameModeId, params (Guid UserId, int Score)[] participants) + { + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], participants.Select(p => p.UserId), 600); + game.Start(); + foreach (var (userId, score) in participants) + for (int i = 0; i < score; i++) + game.RecordProblemSolved(userId); + game.Complete(); + return game; + } + + [Test] + public async Task Handle_DuelModeNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("duel", It.IsAny())) + .ReturnsAsync((GameMode?)null); + + Result result = await _handler.Handle( + new GetDuelHeadToHeadRecordQuery(Guid.NewGuid(), Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_TalliesWinsLossesAndDrawsAgainstThatSpecificOpponentOnly() + { + var userId = Guid.NewGuid(); + var opponentId = Guid.NewGuid(); + var strangerId = Guid.NewGuid(); + + var win = CreateCompletedGame(_duelMode.Id, (userId, 5), (opponentId, 2)); + var loss = CreateCompletedGame(_duelMode.Id, (userId, 1), (opponentId, 4)); + var draw = CreateCompletedGame(_duelMode.Id, (userId, 3), (opponentId, 3)); + var againstSomeoneElse = CreateCompletedGame(_duelMode.Id, (userId, 5), (strangerId, 0)); + var differentMode = CreateCompletedGame(Guid.NewGuid(), (userId, 5), (opponentId, 0)); + + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[win, loss, draw, againstSomeoneElse, differentMode]); + + Result result = await _handler.Handle( + new GetDuelHeadToHeadRecordQuery(userId, opponentId), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.Value.Wins, Is.EqualTo(1)); + Assert.That(result.Value.Losses, Is.EqualTo(1)); + Assert.That(result.Value.Draws, Is.EqualTo(1)); + Assert.That(result.Value.GamesPlayed, Is.EqualTo(3)); + }); + } + + [Test] + public async Task Handle_NoSharedGames_ReturnsAllZeroes() + { + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IReadOnlyList)[]); + + Result result = await _handler.Handle( + new GetDuelHeadToHeadRecordQuery(Guid.NewGuid(), Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Value.GamesPlayed, Is.EqualTo(0)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetGameModes/GetGameModesHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetGameModes/GetGameModesHandlerTests.cs new file mode 100644 index 0000000..b85d168 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetGameModes/GetGameModesHandlerTests.cs @@ -0,0 +1,74 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Domain.Games.Entities; +using GetGameModesHandler = StackDuel.Application.Queries.Games.GetGameModes.GetGameModesHandler; +using GetGameModesQuery = StackDuel.Application.Queries.Games.GetGameModes.GetGameModesQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetGameModes; + +public class GetGameModesHandlerTests +{ + private Mock _gameReadRepository = null!; + private GetGameModesHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _handler = new GetGameModesHandler(_gameReadRepository.Object); + } + + [Test] + public async Task Handle_NoActiveModes_ReturnsEmptyList() + { + _gameReadRepository.Setup(x => x.GetActiveGameModesAsync(It.IsAny())).ReturnsAsync([]); + + Result> result = await _handler.Handle( + new GetGameModesQuery(), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Empty); + } + + [Test] + public async Task Handle_ActiveModes_MapsFieldsAndOrdersTimeOptionsByDuration() + { + var mode = new GameMode( + "blitz", + "Blitz", + "Fast paced games.", + isBuiltIn: true, + minPlayers: 1, + maxPlayers: 4, + defaultPoolId: Guid.NewGuid() + ); + mode.AddTimeOption(300, isDefault: false); + mode.AddTimeOption(60, isDefault: true); + mode.AddTimeOption(180, isDefault: false); + + _gameReadRepository.Setup(x => x.GetActiveGameModesAsync(It.IsAny())).ReturnsAsync([mode]); + + Result> result = await _handler.Handle( + new GetGameModesQuery(), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + GameModeDto dto = result.Value.Single(); + Assert.Multiple(() => + { + Assert.That(dto.Id, Is.EqualTo(mode.Id)); + Assert.That(dto.Key, Is.EqualTo("blitz")); + Assert.That(dto.Name, Is.EqualTo("Blitz")); + Assert.That(dto.Description, Is.EqualTo("Fast paced games.")); + Assert.That(dto.MinPlayers, Is.EqualTo(1)); + Assert.That(dto.MaxPlayers, Is.EqualTo(4)); + Assert.That(dto.TimeOptions.Select(o => o.DurationSeconds), Is.EqualTo(new[] { 60, 180, 300 })); + Assert.That(dto.TimeOptions.Single(o => o.DurationSeconds == 60).IsDefault, Is.True); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetGameProblems/GetGameProblemsHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetGameProblems/GetGameProblemsHandlerTests.cs new file mode 100644 index 0000000..e61f182 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetGameProblems/GetGameProblemsHandlerTests.cs @@ -0,0 +1,102 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Domain.Games.Entities; +using GetGameProblemsHandler = StackDuel.Application.Queries.Games.GetGameProblems.GetGameProblemsHandler; +using GetGameProblemsQuery = StackDuel.Application.Queries.Games.GetGameProblems.GetGameProblemsQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetGameProblems; + +public class GetGameProblemsHandlerTests +{ + private Mock _gameReadRepository = null!; + private GetGameProblemsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _handler = new GetGameProblemsHandler(_gameReadRepository.Object); + } + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var query = new GetGameProblemsQuery(Guid.NewGuid(), Guid.NewGuid()); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_RequestedByNonParticipant_ReturnsForbidden() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var query = new GetGameProblemsQuery(game.Id, Guid.NewGuid()); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_ParticipantWithoutProblemSession_ReturnsEmptyHistories() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var query = new GetGameProblemsQuery(game.Id, participantId); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + GameProblemHistoryDto history = result.Value.Single(); + Assert.Multiple(() => + { + Assert.That(history.UserId, Is.EqualTo(participantId)); + Assert.That(history.SolvedProblemIds, Is.Empty); + Assert.That(history.SolvedProblemSubmissions, Is.Empty); + }); + } + + [Test] + public async Task Handle_ParticipantWithProblemSession_ReturnsSolvedProblemsAndSubmissions() + { + var requesterId = Guid.NewGuid(); + var otherParticipantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [requesterId, otherParticipantId], 300); + + var initialProblemId = Guid.NewGuid(); + var solvedProblemId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + + GameParticipant requester = game.Participants.Single(p => p.UserId == requesterId); + requester.InitializeProblemSession(initialProblemId); + requester.ProblemSession!.AddSolvedProblem(solvedProblemId); + requester.ProblemSession!.RecordSolvedSubmission(solvedProblemId, submissionId); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var query = new GetGameProblemsQuery(game.Id, requesterId); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + GameProblemHistoryDto history = result.Value.Single(h => h.UserId == requesterId); + Assert.Multiple(() => + { + Assert.That(history.SolvedProblemIds, Is.EqualTo(new[] { solvedProblemId })); + Assert.That(history.SolvedProblemSubmissions.Single().ProblemId, Is.EqualTo(solvedProblemId)); + Assert.That(history.SolvedProblemSubmissions.Single().SubmissionId, Is.EqualTo(submissionId)); + }); + Assert.That(result.Value, Has.Count.EqualTo(2)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetGameState/GetGameStateHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetGameState/GetGameStateHandlerTests.cs new file mode 100644 index 0000000..7e04537 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetGameState/GetGameStateHandlerTests.cs @@ -0,0 +1,240 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Tracks.Entities; +using GetGameStateHandler = StackDuel.Application.Queries.Games.GetGameState.GetGameStateHandler; +using GetGameStateQuery = StackDuel.Application.Queries.Games.GetGameState.GetGameStateQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetGameState; + +public class GetGameStateHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _gameWriteRepository = null!; + private Mock _gameExpiryCanceller = null!; + private Mock _domainEventDispatcher = null!; + private Mock _userReadRepository = null!; + private Mock _trackReadRepository = null!; + private GetGameStateHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _gameWriteRepository = new Mock(); + _gameExpiryCanceller = new Mock(); + _domainEventDispatcher = new Mock(); + _userReadRepository = new Mock(); + _trackReadRepository = new Mock(); + _trackReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + _handler = new GetGameStateHandler( + _gameReadRepository.Object, + _gameWriteRepository.Object, + _gameExpiryCanceller.Object, + _domainEventDispatcher.Object, + _userReadRepository.Object, + _trackReadRepository.Object + ); + } + + private static UserDto MakeUser(Guid id, string username, string? imageUrl = null) => + new(id, $"auth0|{id}", username, imageUrl, null, false, null, DateTime.UtcNow, null, []); + + [Test] + public async Task Handle_GameNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Game?)null); + + var query = new GetGameStateQuery(Guid.NewGuid(), Guid.NewGuid()); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_RequestedByNonParticipant_ReturnsForbidden() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + + var query = new GetGameStateQuery(game.Id, Guid.NewGuid()); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Forbidden)); + } + + [Test] + public async Task Handle_GameModeMissing_ReturnsError() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var query = new GetGameStateQuery(game.Id, participantId); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task Handle_EveryoneStopped_SelfHealsCompletionAndCancelsScheduledExpiry() + { + var participantAId = Guid.NewGuid(); + var participantBId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantAId, participantBId], 300); + game.Start(); + + foreach (GameParticipant participant in game.Participants) + participant.Forfeit(); + + Assert.That( + game.Status, + Is.EqualTo(GameStatus.Running), + "sanity: race simulated by forfeiting without Game.Forfeit's own self-heal" + ); + + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetGameStateQuery(game.Id, participantAId); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(game.Status, Is.EqualTo(GameStatus.Completed)); + Assert.That(result.Value.Status, Is.EqualTo(GameStatus.Completed)); + + _gameExpiryCanceller.Verify(x => x.CancelIfScheduledAsync(game, It.IsAny()), Times.Once); + _gameWriteRepository.Verify(x => x.SaveChangesAsync(game, It.IsAny()), Times.Once); + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_RunningGame_DoesNotPersistWhenNothingChanged() + { + var participantId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [participantId], 300); + game.Start(); + + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetGameStateQuery(game.Id, participantId); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + _gameWriteRepository.Verify( + x => x.SaveChangesAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _domainEventDispatcher.Verify( + x => x.DispatchAsync(It.IsAny>(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_Success_MapsParticipantsAndFallsBackForMissingUsers() + { + var knownUserId = Guid.NewGuid(); + var unknownUserId = Guid.NewGuid(); + var track = new Track("python", "Python"); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [track.Id], [knownUserId, unknownUserId], 600); + game.Start(); + + var initialProblemId = Guid.NewGuid(); + GameParticipant known = game.Participants.Single(p => p.UserId == knownUserId); + known.InitializeProblemSession(initialProblemId); + known.IncrementScore(); + + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var knownUserDto = MakeUser(knownUserId, "alice", "https://example.com/a.png"); + + _gameReadRepository.Setup(x => x.FindGameByIdAsync(game.Id, It.IsAny())).ReturnsAsync(game); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { [knownUserId] = knownUserDto }); + _trackReadRepository + .Setup(x => + x.FindByIdsAsync(It.Is>(ids => ids.Contains(track.Id)), It.IsAny()) + ) + .ReturnsAsync([track]); + + var query = new GetGameStateQuery(game.Id, knownUserId); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + GameStateDto dto = result.Value; + + Assert.Multiple(() => + { + Assert.That(dto.GameId, Is.EqualTo(game.Id)); + Assert.That(dto.GameModeId, Is.EqualTo(game.GameModeId)); + Assert.That(dto.GameModeKey, Is.EqualTo("blitz")); + Assert.That(dto.Status, Is.EqualTo(GameStatus.Running)); + Assert.That(dto.TimeLimitInSeconds, Is.EqualTo(600)); + Assert.That(dto.EndedAt, Is.Null); + Assert.That(dto.Participants, Has.Count.EqualTo(2)); + Assert.That(dto.TechStacks, Is.EquivalentTo(new[] { "Python" })); + }); + + GameParticipantDto knownDto = dto.Participants.Single(p => p.UserId == knownUserId); + Assert.Multiple(() => + { + Assert.That(knownDto.Username, Is.EqualTo("alice")); + Assert.That(knownDto.ImageUrl, Is.EqualTo("https://example.com/a.png")); + Assert.That(knownDto.Score, Is.EqualTo(1)); + Assert.That(knownDto.CurrentProblem, Is.Not.Null); + Assert.That(knownDto.CurrentProblem!.ProblemId, Is.EqualTo(initialProblemId)); + Assert.That(knownDto.HasForfeited, Is.False); + Assert.That(knownDto.HasFinishedProblems, Is.False); + }); + + GameParticipantDto unknownDto = dto.Participants.Single(p => p.UserId == unknownUserId); + Assert.Multiple(() => + { + Assert.That(unknownDto.Username, Is.EqualTo(string.Empty)); + Assert.That(unknownDto.ImageUrl, Is.Null); + Assert.That(unknownDto.CurrentProblem, Is.Null); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetMyActiveGames/GetMyActiveGamesHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetMyActiveGames/GetMyActiveGamesHandlerTests.cs new file mode 100644 index 0000000..8a2c0d0 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetMyActiveGames/GetMyActiveGamesHandlerTests.cs @@ -0,0 +1,142 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Tracks.Entities; +using GetMyActiveGamesHandler = StackDuel.Application.Queries.Games.GetMyActiveGames.GetMyActiveGamesHandler; +using GetMyActiveGamesQuery = StackDuel.Application.Queries.Games.GetMyActiveGames.GetMyActiveGamesQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetMyActiveGames; + +public class GetMyActiveGamesHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _trackReadRepository = null!; + private Mock _userReadRepository = null!; + private GetMyActiveGamesHandler _handler = null!; + + private static UserDto MakeUser(Guid id, string username) => + new(id, $"auth0|{id}", username, null, null, false, null, DateTime.UtcNow, null, []); + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _trackReadRepository = new Mock(); + _trackReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + _userReadRepository = new Mock(); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + _handler = new GetMyActiveGamesHandler( + _gameReadRepository.Object, + _trackReadRepository.Object, + _userReadRepository.Object + ); + } + + [Test] + public async Task Handle_NoActiveGames_ReturnsEmptyList() + { + var userId = Guid.NewGuid(); + _gameReadRepository + .Setup(x => x.GetActiveGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync([]); + + var result = await _handler.Handle(new GetMyActiveGamesQuery(userId), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Empty); + } + + [Test] + public async Task Handle_GameWithMissingGameMode_IsExcludedFromResults() + { + var userId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [userId], 300); + + _gameReadRepository + .Setup(x => x.GetActiveGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync([game]); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var result = await _handler.Handle(new GetMyActiveGamesQuery(userId), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Empty); + } + + [Test] + public async Task Handle_Success_MapsFieldsAndSetsIsHostForLowestSeat() + { + var hostId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var track = new Track("python", "Python"); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [track.Id], [hostId, otherId], 300); + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + + _gameReadRepository + .Setup(x => x.GetActiveGamesForUserAsync(hostId, It.IsAny())) + .ReturnsAsync([game]); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _trackReadRepository + .Setup(x => + x.FindByIdsAsync(It.Is>(ids => ids.Contains(track.Id)), It.IsAny()) + ) + .ReturnsAsync([track]); + _userReadRepository + .Setup(x => + x.FindByIdsAsync(It.Is>(ids => ids.Contains(hostId)), It.IsAny()) + ) + .ReturnsAsync(new Dictionary { [hostId] = MakeUser(hostId, "hostname") }); + + var result = await _handler.Handle(new GetMyActiveGamesQuery(hostId), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + MyActiveGameDto dto = result.Value.Single(); + Assert.Multiple(() => + { + Assert.That(dto.GameId, Is.EqualTo(game.Id)); + Assert.That(dto.GameModeKey, Is.EqualTo("blitz")); + Assert.That(dto.GameModeName, Is.EqualTo("Blitz")); + Assert.That(dto.Status, Is.EqualTo(GameStatus.Pending)); + Assert.That(dto.TimeLimitInSeconds, Is.EqualTo(300)); + Assert.That(dto.ParticipantCount, Is.EqualTo(2)); + Assert.That(dto.MaxPlayers, Is.EqualTo(4)); + Assert.That(dto.IsHost, Is.True); + Assert.That(dto.HostUsername, Is.EqualTo("hostname")); + Assert.That(dto.TechStacks, Is.EquivalentTo(new[] { "Python" })); + }); + } + + [Test] + public async Task Handle_Success_NonHostParticipant_IsHostIsFalse() + { + var hostId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId, otherId], 300); + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + + _gameReadRepository + .Setup(x => x.GetActiveGamesForUserAsync(otherId, It.IsAny())) + .ReturnsAsync([game]); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + + var result = await _handler.Handle(new GetMyActiveGamesQuery(otherId), CancellationToken.None); + + Assert.That(result.Value.Single().IsHost, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Games/GetOpenGames/GetOpenGamesHandlerTests.cs b/StackDuel.Application.Tests/Queries/Games/GetOpenGames/GetOpenGamesHandlerTests.cs new file mode 100644 index 0000000..2b65709 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Games/GetOpenGames/GetOpenGamesHandlerTests.cs @@ -0,0 +1,227 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Tracks.Entities; +using GetOpenGamesHandler = StackDuel.Application.Queries.Games.GetOpenGames.GetOpenGamesHandler; +using GetOpenGamesQuery = StackDuel.Application.Queries.Games.GetOpenGames.GetOpenGamesQuery; + +namespace StackDuel.Application.Tests.Queries.Games.GetOpenGames; + +public class GetOpenGamesHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _userReadRepository = null!; + private Mock _trackReadRepository = null!; + private GetOpenGamesHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _userReadRepository = new Mock(); + _trackReadRepository = new Mock(); + _trackReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + _handler = new GetOpenGamesHandler( + _gameReadRepository.Object, + _userReadRepository.Object, + _trackReadRepository.Object + ); + } + + private static UserDto MakeUser(Guid id, string username) => + new(id, $"auth0|{id}", username, null, null, false, null, DateTime.UtcNow, null, []); + + [Test] + public async Task Handle_PassesGameModeKeyAndPaginationToRepository() + { + var pagination = new PaginationRequest { Page = 2, Size = 10 }; + _gameReadRepository + .Setup(x => x.GetPendingGamesAsync("blitz", pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [], + Total = 0, + Page = 2, + Size = 10, + } + ); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetOpenGamesQuery("blitz", pagination, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.Total, Is.EqualTo(0)); + Assert.That(result.Value.Page, Is.EqualTo(2)); + Assert.That(result.Value.Size, Is.EqualTo(10)); + _gameReadRepository.Verify( + x => x.GetPendingGamesAsync("blitz", pagination, It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_GameWithMissingGameMode_IsExcludedFromResults() + { + var hostId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId], 300); + var pagination = new PaginationRequest { Page = 1, Size = 10 }; + + _gameReadRepository + .Setup(x => x.GetPendingGamesAsync(null, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [game], + Total = 1, + Page = 1, + Size = 10, + } + ); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetOpenGamesQuery(null, pagination, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results, Is.Empty); + } + + [Test] + public async Task Handle_Success_MapsSummaryWithHostAndParticipantFlags() + { + var hostId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var track = new Track("python", "Python"); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [track.Id], [hostId, otherId], 300); + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var pagination = new PaginationRequest { Page = 1, Size = 10 }; + + _gameReadRepository + .Setup(x => x.GetPendingGamesAsync(null, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [game], + Total = 1, + Page = 1, + Size = 10, + } + ); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(x => + x.FindByIdsAsync(It.Is>(ids => ids.Contains(hostId)), It.IsAny()) + ) + .ReturnsAsync(new Dictionary { [hostId] = MakeUser(hostId, "hostname") }); + _trackReadRepository + .Setup(x => + x.FindByIdsAsync(It.Is>(ids => ids.Contains(track.Id)), It.IsAny()) + ) + .ReturnsAsync([track]); + + var query = new GetOpenGamesQuery(null, pagination, hostId); + Result> result = await _handler.Handle(query, CancellationToken.None); + + GameLobbySummaryDto summary = result.Value.Results.Single(); + Assert.Multiple(() => + { + Assert.That(summary.GameId, Is.EqualTo(game.Id)); + Assert.That(summary.GameModeKey, Is.EqualTo("blitz")); + Assert.That(summary.GameModeName, Is.EqualTo("Blitz")); + Assert.That(summary.MinPlayers, Is.EqualTo(1)); + Assert.That(summary.MaxPlayers, Is.EqualTo(4)); + Assert.That(summary.ParticipantCount, Is.EqualTo(2)); + Assert.That(summary.HostUsername, Is.EqualTo("hostname")); + Assert.That(summary.IsHost, Is.True); + Assert.That(summary.IsParticipant, Is.True); + Assert.That(summary.TechStacks, Is.EquivalentTo(new[] { "Python" })); + }); + } + + [Test] + public async Task Handle_RequestedByUserIdNull_IsHostAndIsParticipantAreFalse() + { + var hostId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId], 300); + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var pagination = new PaginationRequest { Page = 1, Size = 10 }; + + _gameReadRepository + .Setup(x => x.GetPendingGamesAsync(null, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [game], + Total = 1, + Page = 1, + Size = 10, + } + ); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { [hostId] = MakeUser(hostId, "hostname") }); + + var query = new GetOpenGamesQuery(null, pagination, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + GameLobbySummaryDto summary = result.Value.Results.Single(); + Assert.Multiple(() => + { + Assert.That(summary.IsHost, Is.False); + Assert.That(summary.IsParticipant, Is.False); + }); + } + + [Test] + public async Task Handle_HostNotResolvedInUserMap_HostUsernameIsEmpty() + { + var hostId = Guid.NewGuid(); + var game = new Game(Guid.NewGuid(), Guid.NewGuid(), [Guid.NewGuid()], [hostId], 300); + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var pagination = new PaginationRequest { Page = 1, Size = 10 }; + + _gameReadRepository + .Setup(x => x.GetPendingGamesAsync(null, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [game], + Total = 1, + Page = 1, + Size = 10, + } + ); + _gameReadRepository + .Setup(x => x.FindGameModeByIdAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync(gameMode); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetOpenGamesQuery(null, pagination, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results.Single().HostUsername, Is.EqualTo(string.Empty)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Groups/GetGroups/GetGroupsHandlerTests.cs b/StackDuel.Application.Tests/Queries/Groups/GetGroups/GetGroupsHandlerTests.cs new file mode 100644 index 0000000..98ef709 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Groups/GetGroups/GetGroupsHandlerTests.cs @@ -0,0 +1,44 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Groups; +using StackDuel.Application.Groups.Dtos; +using GetGroupsHandler = StackDuel.Application.Queries.Groups.GetGroups.GetGroupsHandler; +using GetGroupsQuery = StackDuel.Application.Queries.Groups.GetGroups.GetGroupsQuery; + +namespace StackDuel.Application.Tests.Queries.Groups.GetGroups; + +public class GetGroupsHandlerTests +{ + private Mock _groupReadRepository = null!; + private GetGroupsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _groupReadRepository = new Mock(); + _handler = new GetGroupsHandler(_groupReadRepository.Object); + } + + [Test] + public async Task Handle_NoGroups_ReturnsEmptyList() + { + _groupReadRepository.Setup(x => x.GetAllAsync(It.IsAny())).ReturnsAsync([]); + + Result> result = await _handler.Handle(new GetGroupsQuery(), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Empty); + } + + [Test] + public async Task Handle_ReturnsGroupsFromRepository() + { + var groups = new List { new(Guid.NewGuid(), "Admins"), new(Guid.NewGuid(), "Beta Testers") }; + _groupReadRepository.Setup(x => x.GetAllAsync(It.IsAny())).ReturnsAsync(groups); + + Result> result = await _handler.Handle(new GetGroupsQuery(), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.EqualTo(groups)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Languages/GetLanguages/GetLanguagesHandlerTests.cs b/StackDuel.Application.Tests/Queries/Languages/GetLanguages/GetLanguagesHandlerTests.cs new file mode 100644 index 0000000..db22cc2 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Languages/GetLanguages/GetLanguagesHandlerTests.cs @@ -0,0 +1,66 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Languages.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using GetLanguagesHandler = StackDuel.Application.Queries.Languages.GetLanguages.GetLanguagesHandler; +using GetLanguagesQuery = StackDuel.Application.Queries.Languages.GetLanguages.GetLanguagesQuery; + +namespace StackDuel.Application.Tests.Queries.Languages.GetLanguages; + +public class GetLanguagesHandlerTests +{ + private Mock _languageReadRepository = null!; + private GetLanguagesHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _languageReadRepository = new Mock(); + _handler = new GetLanguagesHandler(_languageReadRepository.Object); + } + + [Test] + public async Task Handle_NoLanguages_ReturnsEmptyList() + { + _languageReadRepository.Setup(x => x.GetActiveLanguagesAsync(It.IsAny())).ReturnsAsync([]); + + Result> result = await _handler.Handle( + new GetLanguagesQuery(), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Empty); + } + + [Test] + public async Task Handle_Success_MapsNameAndOnlyActiveVersions() + { + var language = new Language(new LanguageName("Python"), new LanguageSlug("python"), Guid.NewGuid()); + var activeVersion = language.AddVersion(new LanguageVersion("3.12"), new Judge0Id(1)); + var deprecatedVersion = language.AddVersion(new LanguageVersion("2.7"), new Judge0Id(2)); + deprecatedVersion.Deprecate(); + + _languageReadRepository + .Setup(x => x.GetActiveLanguagesAsync(It.IsAny())) + .ReturnsAsync([language]); + + Result> result = await _handler.Handle( + new GetLanguagesQuery(), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + LanguageDto dto = result.Value.Single(); + Assert.Multiple(() => + { + Assert.That(dto.Id, Is.EqualTo(language.Id)); + Assert.That(dto.Name, Is.EqualTo("Python")); + Assert.That(dto.Versions, Has.Count.EqualTo(1)); + Assert.That(dto.Versions.Single().Id, Is.EqualTo(activeVersion.Id)); + Assert.That(dto.Versions.Single().Version, Is.EqualTo("3.12")); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Leaderboards/GetLeaderboard/GetLeaderboardHandlerTests.cs b/StackDuel.Application.Tests/Queries/Leaderboards/GetLeaderboard/GetLeaderboardHandlerTests.cs new file mode 100644 index 0000000..8dceae5 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Leaderboards/GetLeaderboard/GetLeaderboardHandlerTests.cs @@ -0,0 +1,170 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Leaderboards; +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using GetLeaderboardHandler = StackDuel.Application.Queries.Leaderboards.GetLeaderboard.GetLeaderboardHandler; +using GetLeaderboardQuery = StackDuel.Application.Queries.Leaderboards.GetLeaderboard.GetLeaderboardQuery; + +namespace StackDuel.Application.Tests.Queries.Leaderboards.GetLeaderboard; + +public class GetLeaderboardHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _leaderboardReadRepository = null!; + private Mock _userReadRepository = null!; + private GetLeaderboardHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _leaderboardReadRepository = new Mock(); + _userReadRepository = new Mock(); + _handler = new GetLeaderboardHandler( + _gameReadRepository.Object, + _leaderboardReadRepository.Object, + _userReadRepository.Object + ); + } + + private static UserDto MakeUser(Guid id, string username, string? imageUrl = null) => + new(id, $"auth0|{id}", username, imageUrl, null, false, null, DateTime.UtcNow, null, []); + + [Test] + public async Task Handle_GameModeNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var query = new GetLeaderboardQuery("blitz", 300, new PaginationRequest { Page = 1, Size = 10 }, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_Success_ComputesRankFromPageOffset() + { + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var participant1 = new LeaderboardParticipant(Guid.NewGuid(), 100); + var participant2 = new LeaderboardParticipant(Guid.NewGuid(), 90); + var pagination = new PaginationRequest { Page = 2, Size = 5 }; + + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync(gameMode); + _leaderboardReadRepository + .Setup(x => x.GetRankingsAsync(gameMode.Id, 300, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [participant1, participant2], + Total = 7, + Page = 2, + Size = 5, + } + ); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync( + new Dictionary + { + [participant1.UserId] = MakeUser(participant1.UserId, "alice", "https://example.com/a.png"), + [participant2.UserId] = MakeUser(participant2.UserId, "bob"), + } + ); + + var query = new GetLeaderboardQuery("blitz", 300, pagination, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.Total, Is.EqualTo(7)); + + LeaderboardEntryDto entry1 = result.Value.Results[0]; + LeaderboardEntryDto entry2 = result.Value.Results[1]; + Assert.Multiple(() => + { + Assert.That(entry1.Rank, Is.EqualTo(6)); + Assert.That(entry1.Username, Is.EqualTo("alice")); + Assert.That(entry1.ImageUrl, Is.EqualTo("https://example.com/a.png")); + Assert.That(entry1.HighScore, Is.EqualTo(100)); + Assert.That(entry2.Rank, Is.EqualTo(7)); + Assert.That(entry2.Username, Is.EqualTo("bob")); + }); + } + + [Test] + public async Task Handle_UserNotResolved_UsernameEmptyAndImageNull() + { + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var participant = new LeaderboardParticipant(Guid.NewGuid(), 50); + var pagination = new PaginationRequest { Page = 1, Size = 10 }; + + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync(gameMode); + _leaderboardReadRepository + .Setup(x => x.GetRankingsAsync(gameMode.Id, 300, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [participant], + Total = 1, + Page = 1, + Size = 10, + } + ); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + var query = new GetLeaderboardQuery("blitz", 300, pagination, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + LeaderboardEntryDto entry = result.Value.Results.Single(); + Assert.Multiple(() => + { + Assert.That(entry.Username, Is.EqualTo(string.Empty)); + Assert.That(entry.ImageUrl, Is.Null); + }); + } + + [Test] + public async Task Handle_RequestedByUserIdMatchesParticipant_SetsIsCurrentUser() + { + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var participant = new LeaderboardParticipant(Guid.NewGuid(), 50); + var pagination = new PaginationRequest { Page = 1, Size = 10 }; + + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync(gameMode); + _leaderboardReadRepository + .Setup(x => x.GetRankingsAsync(gameMode.Id, 300, pagination, It.IsAny())) + .ReturnsAsync( + new PageResult + { + Results = [participant], + Total = 1, + Page = 1, + Size = 10, + } + ); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync( + new Dictionary { [participant.UserId] = MakeUser(participant.UserId, "alice") } + ); + + var query = new GetLeaderboardQuery("blitz", 300, pagination, participant.UserId); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results.Single().IsCurrentUser, Is.True); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryHandlerTests.cs b/StackDuel.Application.Tests/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryHandlerTests.cs new file mode 100644 index 0000000..0482a4e --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryHandlerTests.cs @@ -0,0 +1,77 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Leaderboards; +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Domain.Games.Entities; +using GetMyLeaderboardEntryHandler = StackDuel.Application.Queries.Leaderboards.GetMyLeaderboardEntry.GetMyLeaderboardEntryHandler; +using GetMyLeaderboardEntryQuery = StackDuel.Application.Queries.Leaderboards.GetMyLeaderboardEntry.GetMyLeaderboardEntryQuery; + +namespace StackDuel.Application.Tests.Queries.Leaderboards.GetMyLeaderboardEntry; + +public class GetMyLeaderboardEntryHandlerTests +{ + private Mock _gameReadRepository = null!; + private Mock _leaderboardReadRepository = null!; + private GetMyLeaderboardEntryHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _gameReadRepository = new Mock(); + _leaderboardReadRepository = new Mock(); + _handler = new GetMyLeaderboardEntryHandler(_gameReadRepository.Object, _leaderboardReadRepository.Object); + } + + [Test] + public async Task Handle_GameModeNotFound_ReturnsNotFound() + { + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync((GameMode?)null); + + var query = new GetMyLeaderboardEntryQuery("blitz", 300, Guid.NewGuid()); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NoParticipantEntry_ReturnsNotFound() + { + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var userId = Guid.NewGuid(); + + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync(gameMode); + _leaderboardReadRepository + .Setup(x => x.FindParticipantForUserAsync(gameMode.Id, 300, userId, It.IsAny())) + .ReturnsAsync((LeaderboardParticipant?)null); + + var query = new GetMyLeaderboardEntryQuery("blitz", 300, userId); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ParticipantFound_ReturnsHighScore() + { + var gameMode = new GameMode("blitz", "Blitz", "desc", true, 1, 4, Guid.NewGuid()); + var userId = Guid.NewGuid(); + var participant = new LeaderboardParticipant(userId, 42); + + _gameReadRepository + .Setup(x => x.FindGameModeByKeyAsync("blitz", It.IsAny())) + .ReturnsAsync(gameMode); + _leaderboardReadRepository + .Setup(x => x.FindParticipantForUserAsync(gameMode.Id, 300, userId, It.IsAny())) + .ReturnsAsync(participant); + + var query = new GetMyLeaderboardEntryQuery("blitz", 300, userId); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.HighScore, Is.EqualTo(42)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Permissions/GetUserAccessContext/GetUserAccessContextHandlerTests.cs b/StackDuel.Application.Tests/Queries/Permissions/GetUserAccessContext/GetUserAccessContextHandlerTests.cs new file mode 100644 index 0000000..43968d7 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Permissions/GetUserAccessContext/GetUserAccessContextHandlerTests.cs @@ -0,0 +1,98 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Authorization; +using GetUserAccessContextHandler = StackDuel.Application.Queries.Permissions.GetUserAccessContext.GetUserAccessContextHandler; +using GetUserAccessContextQuery = StackDuel.Application.Queries.Permissions.GetUserAccessContext.GetUserAccessContextQuery; + +namespace StackDuel.Application.Tests.Queries.Permissions.GetUserAccessContext; + +public class GetUserAccessContextHandlerTests +{ + private const string ValidSub = "auth0|abc123"; + + private Mock _userReadRepository = null!; + private Mock _authorizationReadRepository = null!; + private GetUserAccessContextHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userReadRepository = new Mock(); + _authorizationReadRepository = new Mock(); + _handler = new GetUserAccessContextHandler(_userReadRepository.Object, _authorizationReadRepository.Object); + } + + private static UserDto CreateUser() => + new(Guid.NewGuid(), ValidSub, "alice", null, null, false, null, DateTime.UtcNow, DateTime.UtcNow, []); + + [Test] + public async Task Handle_UserNotFound_ReturnsNotFound() + { + _userReadRepository + .Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())) + .ReturnsAsync((UserDto?)null); + + Result result = await _handler.Handle( + new GetUserAccessContextQuery(ValidSub), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + _authorizationReadRepository.Verify( + x => x.GetUserPermissionsAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_UserFound_ComposesAccessContextFromPermissionsAndRoles() + { + var user = CreateUser(); + _userReadRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync(user); + _authorizationReadRepository + .Setup(x => x.GetUserPermissionsAsync(user.Id, It.IsAny())) + .ReturnsAsync(["problems:read", "problems:write"]); + _authorizationReadRepository + .Setup(x => x.GetUserRoleNamesAsync(user.Id, It.IsAny())) + .ReturnsAsync(["Admin"]); + + Result result = await _handler.Handle( + new GetUserAccessContextQuery(ValidSub), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.Multiple(() => + { + Assert.That(result.Value.User, Is.EqualTo(user)); + Assert.That(result.Value.Permissions, Is.EquivalentTo(new[] { "problems:read", "problems:write" })); + Assert.That(result.Value.Roles, Is.EquivalentTo(new[] { "Admin" })); + }); + } + + [Test] + public async Task Handle_UserFound_LooksUpPermissionsAndRolesByUserId() + { + var user = CreateUser(); + _userReadRepository.Setup(x => x.FindBySubAsync(ValidSub, It.IsAny())).ReturnsAsync(user); + _authorizationReadRepository + .Setup(x => x.GetUserPermissionsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + _authorizationReadRepository + .Setup(x => x.GetUserRoleNamesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + await _handler.Handle(new GetUserAccessContextQuery(ValidSub), CancellationToken.None); + + _authorizationReadRepository.Verify( + x => x.GetUserPermissionsAsync(user.Id, It.IsAny()), + Times.Once + ); + _authorizationReadRepository.Verify( + x => x.GetUserRoleNamesAsync(user.Id, It.IsAny()), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersHandlerTests.cs b/StackDuel.Application.Tests/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersHandlerTests.cs new file mode 100644 index 0000000..12fde2c --- /dev/null +++ b/StackDuel.Application.Tests/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersHandlerTests.cs @@ -0,0 +1,87 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using GetOrderedProblemPoolMembersHandler = StackDuel.Application.Queries.ProblemPools.GetOrderedProblemPoolMembers.GetOrderedProblemPoolMembersHandler; +using GetOrderedProblemPoolMembersQuery = StackDuel.Application.Queries.ProblemPools.GetOrderedProblemPoolMembers.GetOrderedProblemPoolMembersQuery; + +namespace StackDuel.Application.Tests.Queries.ProblemPools.GetOrderedProblemPoolMembers; + +public class GetOrderedProblemPoolMembersHandlerTests +{ + private Mock _problemPoolRepository = null!; + private Mock _problemReadRepository = null!; + private GetOrderedProblemPoolMembersHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemPoolRepository = new Mock(); + _problemReadRepository = new Mock(); + _handler = new GetOrderedProblemPoolMembersHandler(_problemPoolRepository.Object, _problemReadRepository.Object); + } + + private static AdminProblemListRowDto CreateRow(Guid id) => + new(id, $"slug-{id}", $"Title {id}", 100, ProblemStatus.Published, 1000, 64, [], [], 1, DateTime.UtcNow, null); + + [Test] + public async Task Handle_PoolNotFound_ReturnsNotFound() + { + _problemPoolRepository + .Setup(x => x.FindByKeyAsync("missing", It.IsAny())) + .ReturnsAsync((ProblemPool?)null); + + Result> result = await _handler.Handle( + new GetOrderedProblemPoolMembersQuery("missing"), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ReturnsMembersInPoolOrder() + { + var pool = new ProblemPool("pool", "Pool"); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(second); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + _problemReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([CreateRow(second), CreateRow(first)]); + + Result> result = await _handler.Handle( + new GetOrderedProblemPoolMembersQuery("pool"), + CancellationToken.None + ); + + Assert.That(result.Value.Select(row => row.Id), Is.EqualTo(new[] { first, second })); + } + + [Test] + public async Task Handle_MemberMissingFromProblemLookup_IsOmitted() + { + var pool = new ProblemPool("pool", "Pool"); + Guid first = Guid.NewGuid(); + Guid deleted = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(deleted); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("pool", It.IsAny())).ReturnsAsync(pool); + _problemReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([CreateRow(first)]); + + Result> result = await _handler.Handle( + new GetOrderedProblemPoolMembersQuery("pool"), + CancellationToken.None + ); + + Assert.That(result.Value.Select(row => row.Id), Is.EqualTo(new[] { first })); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsHandlerTests.cs b/StackDuel.Application.Tests/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsHandlerTests.cs new file mode 100644 index 0000000..682afb2 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsHandlerTests.cs @@ -0,0 +1,67 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; +using GetProblemPoolMemberIdsHandler = StackDuel.Application.Queries.ProblemPools.GetProblemPoolMemberIds.GetProblemPoolMemberIdsHandler; +using GetProblemPoolMemberIdsQuery = StackDuel.Application.Queries.ProblemPools.GetProblemPoolMemberIds.GetProblemPoolMemberIdsQuery; + +namespace StackDuel.Application.Tests.Queries.ProblemPools.GetProblemPoolMemberIds; + +public class GetProblemPoolMemberIdsHandlerTests +{ + private Mock _problemPoolRepository = null!; + private GetProblemPoolMemberIdsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemPoolRepository = new Mock(); + _handler = new GetProblemPoolMemberIdsHandler(_problemPoolRepository.Object); + } + + [Test] + public async Task Handle_PoolNotFound_ReturnsNotFound() + { + _problemPoolRepository + .Setup(x => x.FindByKeyAsync("missing", It.IsAny())) + .ReturnsAsync((ProblemPool?)null); + + Result> result = await _handler.Handle( + new GetProblemPoolMemberIdsQuery("missing"), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_PoolFound_ReturnsItsProblemIds() + { + var pool = new ProblemPool("daily", "Daily challenge"); + var problemId = Guid.NewGuid(); + pool.AddProblem(problemId); + + _problemPoolRepository.Setup(x => x.FindByKeyAsync("daily", It.IsAny())).ReturnsAsync(pool); + + Result> result = await _handler.Handle( + new GetProblemPoolMemberIdsQuery("daily"), + CancellationToken.None + ); + + Assert.That(result.Value, Is.EquivalentTo(new[] { problemId })); + } + + [Test] + public async Task Handle_EmptyPool_ReturnsEmptyList() + { + var pool = new ProblemPool("empty", "Empty pool"); + _problemPoolRepository.Setup(x => x.FindByKeyAsync("empty", It.IsAny())).ReturnsAsync(pool); + + Result> result = await _handler.Handle( + new GetProblemPoolMemberIdsQuery("empty"), + CancellationToken.None + ); + + Assert.That(result.Value, Is.Empty); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedHandlerTests.cs b/StackDuel.Application.Tests/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedHandlerTests.cs new file mode 100644 index 0000000..3443cb4 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedHandlerTests.cs @@ -0,0 +1,106 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using GetProblemPoolMembersPagedHandler = StackDuel.Application.Queries.ProblemPools.GetProblemPoolMembersPaged.GetProblemPoolMembersPagedHandler; +using GetProblemPoolMembersPagedQuery = StackDuel.Application.Queries.ProblemPools.GetProblemPoolMembersPaged.GetProblemPoolMembersPagedQuery; + +namespace StackDuel.Application.Tests.Queries.ProblemPools.GetProblemPoolMembersPaged; + +public class GetProblemPoolMembersPagedHandlerTests +{ + private Mock _problemPoolRepository = null!; + private Mock _problemReadRepository = null!; + private Mock _languageReadRepository = null!; + private GetProblemPoolMembersPagedHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemPoolRepository = new Mock(); + _problemReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _handler = new GetProblemPoolMembersPagedHandler( + _problemPoolRepository.Object, + _problemReadRepository.Object, + _languageReadRepository.Object + ); + + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + } + + private static AdminProblemListRowDto CreateRow(Guid? id = null) => + new( + id ?? Guid.NewGuid(), + "two-sum", + "Two Sum", + 150, + ProblemStatus.Published, + 1000, + 256, + [], + [], + 0, + DateTime.UtcNow, + null + ); + + [Test] + public async Task Handle_PoolNotFound_ReturnsNotFound() + { + _problemPoolRepository + .Setup(x => x.FindByKeyAsync("missing", It.IsAny())) + .ReturnsAsync((ProblemPool?)null); + + var result = await _handler.Handle( + new GetProblemPoolMembersPagedQuery("missing", new PaginationRequest { Page = 1, Size = 20 }), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_PoolFound_PassesThroughPageMetadataFromRepository() + { + var pool = new ProblemPool("daily", "Daily challenge"); + _problemPoolRepository.Setup(x => x.FindByKeyAsync("daily", It.IsAny())).ReturnsAsync(pool); + + var page = new PageResult + { + Results = [CreateRow()], + Total = 42, + Page = 2, + Size = 10, + }; + _problemReadRepository + .Setup(x => + x.GetPoolMembersPagedAsync(pool.Id, It.IsAny(), It.IsAny()) + ) + .ReturnsAsync(page); + + var pagination = new PaginationRequest { Page = 2, Size = 10 }; + Result> result = await _handler.Handle( + new GetProblemPoolMembersPagedQuery("daily", pagination), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.Value.Total, Is.EqualTo(42)); + Assert.That(result.Value.Page, Is.EqualTo(2)); + Assert.That(result.Value.Size, Is.EqualTo(10)); + Assert.That(result.Value.Results, Has.Count.EqualTo(1)); + }); + _problemReadRepository.Verify( + x => x.GetPoolMembersPagedAsync(pool.Id, pagination, It.IsAny()), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailHandlerTests.cs b/StackDuel.Application.Tests/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailHandlerTests.cs new file mode 100644 index 0000000..6f3c6a4 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailHandlerTests.cs @@ -0,0 +1,192 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.Tracks.Entities; +using GetAdminProblemDetailHandler = StackDuel.Application.Queries.Problems.GetAdminProblemDetail.GetAdminProblemDetailHandler; +using GetAdminProblemDetailQuery = StackDuel.Application.Queries.Problems.GetAdminProblemDetail.GetAdminProblemDetailQuery; + +namespace StackDuel.Application.Tests.Queries.Problems.GetAdminProblemDetail; + +public class GetAdminProblemDetailHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _languageReadRepository = null!; + private Mock _problemPoolRepository = null!; + private Mock _trackReadRepository = null!; + private GetAdminProblemDetailHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _problemPoolRepository = new Mock(); + _trackReadRepository = new Mock(); + _handler = new GetAdminProblemDetailHandler( + _problemReadRepository.Object, + _languageReadRepository.Object, + _problemPoolRepository.Object, + _trackReadRepository.Object + ); + + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + _problemPoolRepository + .Setup(x => x.GetPoolKeysForProblemAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + _trackReadRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Track?)null); + } + + private static Problem CreateProblem() => + new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question(new string('q', 60)), + new Difficulty(150), + new TimeLimit(1000), + new MemoryLimit(256) + ); + + [Test] + public async Task Handle_ProblemNotFound_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.FindByIdForAdminAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Problem?)null); + + var query = new GetAdminProblemDetailQuery(Guid.NewGuid()); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ProblemFound_MapsCoreFields() + { + var problem = CreateProblem(); + problem.AddTag(new ProblemTag(new Tag("arrays"))); + problem.AddTag(new ProblemTag(new Tag("hash-map"))); + _problemReadRepository + .Setup(x => x.FindByIdForAdminAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + + var query = new GetAdminProblemDetailQuery(problem.Id); + Result result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + AdminProblemDetailDto dto = result.Value; + Assert.Multiple(() => + { + Assert.That(dto.Id, Is.EqualTo(problem.Id)); + Assert.That(dto.Slug, Is.EqualTo("two-sum")); + Assert.That(dto.Title, Is.EqualTo("Two Sum")); + Assert.That(dto.DifficultyValue, Is.EqualTo(150)); + Assert.That(dto.DifficultyTier, Is.EqualTo(DifficultyTier.Beginner)); + Assert.That(dto.TimeLimitMs, Is.EqualTo(1000)); + Assert.That(dto.MemoryLimitMb, Is.EqualTo(256)); + Assert.That(dto.Status, Is.EqualTo(ProblemStatus.Draft)); + Assert.That(dto.CreatedByUsername, Is.Null); + Assert.That(dto.Tags, Is.EquivalentTo(new[] { "arrays", "hash-map" })); + Assert.That(dto.PoolKeys, Is.Empty); + Assert.That(dto.Setups, Is.Empty); + }); + } + + [Test] + public async Task Handle_ProblemInPools_MapsPoolKeys() + { + var problem = CreateProblem(); + _problemReadRepository + .Setup(x => x.FindByIdForAdminAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + _problemPoolRepository + .Setup(x => x.GetPoolKeysForProblemAsync(problem.Id, It.IsAny())) + .ReturnsAsync(["daily", "interview-prep"]); + + Result result = await _handler.Handle( + new GetAdminProblemDetailQuery(problem.Id), + CancellationToken.None + ); + + Assert.That(result.Value.PoolKeys, Is.EquivalentTo(new[] { "daily", "interview-prep" })); + } + + [Test] + public async Task Handle_SetupWithKnownLanguageVersion_MapsLanguageNameAndVersion() + { + var problem = CreateProblem(); + var pipelineId = Guid.NewGuid(); + + var language = new Language(new LanguageName("JavaScript"), new LanguageSlug("javascript"), Guid.NewGuid()); + var versionEntry = language.AddVersion(new LanguageVersion("ES2020"), new Judge0Id(63)); + + ProblemSetup setup = problem.AddSetup(versionEntry.Id, "function solve() {}", "solve", pipelineId); + problem.SetReferenceSolution(setup.Id, "function solve() { return 1; }"); + problem.SetGenerationSpecId(setup.Id, Guid.NewGuid()); + + _problemReadRepository + .Setup(x => x.FindByIdForAdminAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([language]); + + Result result = await _handler.Handle( + new GetAdminProblemDetailQuery(problem.Id), + CancellationToken.None + ); + + AdminProblemSetupDto setupDto = result.Value.Setups.Single(); + Assert.Multiple(() => + { + Assert.That(setupDto.LanguageVersionId, Is.EqualTo(versionEntry.Id)); + Assert.That(setupDto.LanguageName, Is.EqualTo("JavaScript")); + Assert.That(setupDto.LanguageVersion, Is.EqualTo("ES2020")); + Assert.That(setupDto.FunctionName, Is.EqualTo("solve")); + Assert.That(setupDto.InitialCode, Is.EqualTo("function solve() {}")); + Assert.That(setupDto.HasReferenceSolution, Is.True); + Assert.That(setupDto.HasGenerationSpec, Is.True); + Assert.That(setupDto.TestSuiteCount, Is.Zero); + Assert.That(setupDto.TestCaseCount, Is.Zero); + }); + } + + [Test] + public async Task Handle_SetupWithUnknownLanguageVersion_FallsBackToUnknownLabels() + { + var problem = CreateProblem(); + ProblemSetup setup = problem.AddSetup(Guid.NewGuid(), "code", "solve", Guid.NewGuid()); + + _problemReadRepository + .Setup(x => x.FindByIdForAdminAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + + Result result = await _handler.Handle( + new GetAdminProblemDetailQuery(problem.Id), + CancellationToken.None + ); + + AdminProblemSetupDto setupDto = result.Value.Setups.Single(); + Assert.Multiple(() => + { + Assert.That(setupDto.Id, Is.EqualTo(setup.Id)); + Assert.That(setupDto.LanguageName, Is.EqualTo("Unknown")); + Assert.That(setupDto.LanguageVersion, Is.EqualTo("?")); + Assert.That(setupDto.HasReferenceSolution, Is.False); + Assert.That(setupDto.HasGenerationSpec, Is.False); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableHandlerTests.cs b/StackDuel.Application.Tests/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableHandlerTests.cs new file mode 100644 index 0000000..5d232b3 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableHandlerTests.cs @@ -0,0 +1,185 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Enums; +using GetAdminProblemsPageableHandler = StackDuel.Application.Queries.Problems.GetAdminProblemsPageable.GetAdminProblemsPageableHandler; +using GetAdminProblemsPageableQuery = StackDuel.Application.Queries.Problems.GetAdminProblemsPageable.GetAdminProblemsPageableQuery; + +namespace StackDuel.Application.Tests.Queries.Problems.GetAdminProblemsPageable; + +public class GetAdminProblemsPageableHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _languageReadRepository = null!; + private GetAdminProblemsPageableHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _handler = new GetAdminProblemsPageableHandler(_problemReadRepository.Object, _languageReadRepository.Object); + } + + private static AdminProblemListRowDto CreateRow(int difficultyValue, IReadOnlyList? versionIds = null) => + new( + Guid.NewGuid(), + "two-sum", + "Two Sum", + difficultyValue, + ProblemStatus.Published, + 1000, + 256, + ["arrays"], + versionIds ?? [], + 1, + DateTime.UtcNow, + "alice" + ); + + [Test] + public async Task Handle_PassesThroughPageMetadata() + { + var page = new PageResult + { + Results = [CreateRow(100)], + Total = 42, + Page = 2, + Size = 10, + }; + _problemReadRepository + .Setup(x => + x.GetAdminProblemsPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(page); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + var pagination = new PaginationRequest { Page = 2, Size = 10 }; + var query = new GetAdminProblemsPageableQuery(pagination, "two"); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.Multiple(() => + { + Assert.That(result.Value.Total, Is.EqualTo(42)); + Assert.That(result.Value.Page, Is.EqualTo(2)); + Assert.That(result.Value.Size, Is.EqualTo(10)); + Assert.That(result.Value.Results, Has.Count.EqualTo(1)); + }); + _problemReadRepository.Verify( + x => x.GetAdminProblemsPagedAsync(pagination, "two", It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_KnownLanguageVersion_MapsDisplayName() + { + var language = new Language(new LanguageName("Python"), new LanguageSlug("python"), Guid.NewGuid()); + var versionEntry = language.AddVersion(new LanguageVersion("3.12"), new Judge0Id(71)); + _problemReadRepository + .Setup(x => + x.GetAdminProblemsPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + new PageResult + { + Results = [CreateRow(100, [versionEntry.Id])], + Total = 1, + Page = 1, + Size = 20, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([language]); + + var query = new GetAdminProblemsPageableQuery(new PaginationRequest { Page = 1, Size = 20 }, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results[0].Languages, Is.EquivalentTo(new[] { "Python 3.12" })); + } + + [Test] + public async Task Handle_UnknownLanguageVersion_FallsBackToUnknown() + { + _problemReadRepository + .Setup(x => + x.GetAdminProblemsPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + new PageResult + { + Results = [CreateRow(100, [Guid.NewGuid()])], + Total = 1, + Page = 1, + Size = 20, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + var query = new GetAdminProblemsPageableQuery(new PaginationRequest { Page = 1, Size = 20 }, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results[0].Languages, Is.EquivalentTo(new[] { "Unknown" })); + } + + [TestCase(0, DifficultyTier.Beginner)] + [TestCase(200, DifficultyTier.Beginner)] + [TestCase(201, DifficultyTier.Easy)] + [TestCase(500, DifficultyTier.Easy)] + [TestCase(501, DifficultyTier.Intermediate)] + [TestCase(1000, DifficultyTier.Intermediate)] + [TestCase(1001, DifficultyTier.Advanced)] + [TestCase(2000, DifficultyTier.Advanced)] + [TestCase(2001, DifficultyTier.Expert)] + public async Task Handle_MapsDifficultyValueToExpectedTier(int difficultyValue, DifficultyTier expectedTier) + { + _problemReadRepository + .Setup(x => + x.GetAdminProblemsPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + new PageResult + { + Results = [CreateRow(difficultyValue)], + Total = 1, + Page = 1, + Size = 20, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + var query = new GetAdminProblemsPageableQuery(new PaginationRequest { Page = 1, Size = 20 }, null); + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results[0].DifficultyTier, Is.EqualTo(expectedTier)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Problems/GetProblemById/GetProblemByIdHandlerTests.cs b/StackDuel.Application.Tests/Queries/Problems/GetProblemById/GetProblemByIdHandlerTests.cs new file mode 100644 index 0000000..b287936 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Problems/GetProblemById/GetProblemByIdHandlerTests.cs @@ -0,0 +1,133 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using GetProblemByIdHandler = StackDuel.Application.Queries.Problems.GetProblemById.GetProblemByIdHandler; +using GetProblemByIdQuery = StackDuel.Application.Queries.Problems.GetProblemById.GetProblemByIdQuery; + +namespace StackDuel.Application.Tests.Queries.Problems.GetProblemById; + +public class GetProblemByIdHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _languageReadRepository = null!; + private GetProblemByIdHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _handler = new GetProblemByIdHandler(_problemReadRepository.Object, _languageReadRepository.Object); + + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + } + + private static Problem CreateProblem() => + new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question(new string('q', 60)), + new Difficulty(150), + new TimeLimit(1000), + new MemoryLimit(256) + ); + + [Test] + public async Task Handle_ProblemNotFound_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.FindByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Problem?)null); + + Result result = await _handler.Handle( + new GetProblemByIdQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ProblemWithoutSetups_MapsCoreFieldsAndEmptyTestCases() + { + var problem = CreateProblem(); + problem.AddTag(new ProblemTag(new Tag("arrays"))); + _problemReadRepository + .Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + + Result result = await _handler.Handle( + new GetProblemByIdQuery(problem.Id), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + ProblemWithSetupsDto dto = result.Value; + Assert.Multiple(() => + { + Assert.That(dto.Id, Is.EqualTo(problem.Id)); + Assert.That(dto.Slug, Is.EqualTo("two-sum")); + Assert.That(dto.Title, Is.EqualTo("Two Sum")); + Assert.That(dto.DifficultyTier, Is.EqualTo(problem.Difficulty.Tier)); + Assert.That(dto.Question, Is.EqualTo(problem.Question.Value)); + Assert.That(dto.Tags, Is.EquivalentTo(new[] { "arrays" })); + Assert.That(dto.Author, Is.Null); + Assert.That(dto.PublicTestCases, Is.Empty); + Assert.That(dto.AvailableLanguages, Is.Empty); + }); + } + + [Test] + public async Task Handle_ProblemWithSetupButNoSampleSuites_ReturnsEmptyPublicTestCases() + { + var problem = CreateProblem(); + problem.AddSetup(Guid.NewGuid(), "code", "solve", Guid.NewGuid()); + _problemReadRepository + .Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + + Result result = await _handler.Handle( + new GetProblemByIdQuery(problem.Id), + CancellationToken.None + ); + + Assert.That(result.Value.PublicTestCases, Is.Empty); + } + + [Test] + public async Task Handle_AvailableLanguages_MapsLanguageAndVersions() + { + var problem = CreateProblem(); + var language = new Language(new LanguageName("Python"), new LanguageSlug("python"), Guid.NewGuid()); + var versionEntry = language.AddVersion(new LanguageVersion("3.12"), new Judge0Id(71)); + problem.AddSetup(versionEntry.Id, "code", "solve", Guid.NewGuid()); + + _problemReadRepository + .Setup(x => x.FindByIdAsync(problem.Id, It.IsAny())) + .ReturnsAsync(problem); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([language]); + + Result result = await _handler.Handle( + new GetProblemByIdQuery(problem.Id), + CancellationToken.None + ); + + var mappedLanguage = result.Value.AvailableLanguages.Single(); + Assert.Multiple(() => + { + Assert.That(mappedLanguage.Id, Is.EqualTo(language.Id)); + Assert.That(mappedLanguage.Name, Is.EqualTo("Python")); + Assert.That(mappedLanguage.Versions.Single().Version, Is.EqualTo("3.12")); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandlerTests.cs b/StackDuel.Application.Tests/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandlerTests.cs new file mode 100644 index 0000000..7f6cb68 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandlerTests.cs @@ -0,0 +1,94 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using GetProblemBySlugHandler = StackDuel.Application.Queries.Problems.GetProblemBySlug.GetProblemBySlugHandler; +using GetProblemBySlugQuery = StackDuel.Application.Queries.Problems.GetProblemBySlug.GetProblemBySlugQuery; + +namespace StackDuel.Application.Tests.Queries.Problems.GetProblemBySlug; + +public class GetProblemBySlugHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _languageReadRepository = null!; + private GetProblemBySlugHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _handler = new GetProblemBySlugHandler(_problemReadRepository.Object, _languageReadRepository.Object); + + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + } + + private static Problem CreateProblem() => + new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question(new string('q', 60)), + new Difficulty(150), + new TimeLimit(1000), + new MemoryLimit(256) + ); + + [Test] + public async Task Handle_ProblemNotFound_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.FindBySlugAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Problem?)null); + + Result result = await _handler.Handle( + new GetProblemBySlugQuery("missing"), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_ProblemFound_LooksUpBySlugAndMapsFields() + { + var problem = CreateProblem(); + problem.AddTag(new ProblemTag(new Tag("arrays"))); + _problemReadRepository + .Setup(x => x.FindBySlugAsync("two-sum", It.IsAny())) + .ReturnsAsync(problem); + + Result result = await _handler.Handle( + new GetProblemBySlugQuery("two-sum"), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + ProblemWithSetupsDto dto = result.Value; + Assert.Multiple(() => + { + Assert.That(dto.Id, Is.EqualTo(problem.Id)); + Assert.That(dto.Slug, Is.EqualTo("two-sum")); + Assert.That(dto.Title, Is.EqualTo("Two Sum")); + Assert.That(dto.Tags, Is.EquivalentTo(new[] { "arrays" })); + Assert.That(dto.Author, Is.Null); + Assert.That(dto.PublicTestCases, Is.Empty); + }); + } + + [Test] + public async Task Handle_QueriesRepositoryWithGivenSlug() + { + _problemReadRepository + .Setup(x => x.FindBySlugAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Problem?)null); + + await _handler.Handle(new GetProblemBySlugQuery("some-slug"), CancellationToken.None); + + _problemReadRepository.Verify(x => x.FindBySlugAsync("some-slug", It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Problems/GetProblemSetup/GetProblemSetupHandlerTests.cs b/StackDuel.Application.Tests/Queries/Problems/GetProblemSetup/GetProblemSetupHandlerTests.cs new file mode 100644 index 0000000..23ae775 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Problems/GetProblemSetup/GetProblemSetupHandlerTests.cs @@ -0,0 +1,89 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using GetProblemSetupHandler = StackDuel.Application.Queries.Problems.GetProblemSetup.GetProblemSetupHandler; +using GetProblemSetupQuery = StackDuel.Application.Queries.Problems.GetProblemSetup.GetProblemSetupQuery; + +namespace StackDuel.Application.Tests.Queries.Problems.GetProblemSetup; + +public class GetProblemSetupHandlerTests +{ + private Mock _problemReadRepository = null!; + private GetProblemSetupHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _handler = new GetProblemSetupHandler(_problemReadRepository.Object); + } + + private static Problem CreateProblem() => + new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question(new string('q', 60)), + new Difficulty(150), + new TimeLimit(1000), + new MemoryLimit(256) + ); + + [Test] + public async Task Handle_ProblemNotFound_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.FindBySlugAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Problem?)null); + + Result result = await _handler.Handle( + new GetProblemSetupQuery("missing", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_LanguageVersionNotOnProblem_ReturnsNotFound() + { + var problem = CreateProblem(); + problem.AddSetup(Guid.NewGuid(), "code", "solve", Guid.NewGuid()); + _problemReadRepository + .Setup(x => x.FindBySlugAsync("two-sum", It.IsAny())) + .ReturnsAsync(problem); + + Result result = await _handler.Handle( + new GetProblemSetupQuery("two-sum", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_MatchingSetupFound_ReturnsSetupWithEmptyTestCases() + { + var problem = CreateProblem(); + var languageVersionId = Guid.NewGuid(); + ProblemSetup setup = problem.AddSetup(languageVersionId, "def solve(): pass", "solve", Guid.NewGuid()); + _problemReadRepository + .Setup(x => x.FindBySlugAsync("two-sum", It.IsAny())) + .ReturnsAsync(problem); + + Result result = await _handler.Handle( + new GetProblemSetupQuery("two-sum", languageVersionId), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.Multiple(() => + { + Assert.That(result.Value.Id, Is.EqualTo(setup.Id)); + Assert.That(result.Value.InitialCode, Is.EqualTo("def solve(): pass")); + Assert.That(result.Value.TestCases, Is.Empty); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandlerTests.cs b/StackDuel.Application.Tests/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandlerTests.cs new file mode 100644 index 0000000..02e25c5 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandlerTests.cs @@ -0,0 +1,176 @@ +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using GetProblemsPageableHandler = StackDuel.Application.Queries.Problems.GetProblemsPageable.GetProblemsPageableHandler; +using GetProblemsPageableQuery = StackDuel.Application.Queries.Problems.GetProblemsPageable.GetProblemsPageableQuery; + +namespace StackDuel.Application.Tests.Queries.Problems.GetProblemsPageable; + +public class GetProblemsPageableHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _languageReadRepository = null!; + private GetProblemsPageableHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _handler = new GetProblemsPageableHandler(_problemReadRepository.Object, _languageReadRepository.Object); + } + + private static ProblemListRowDto CreateRow(int difficultyValue = 100, IReadOnlyList? versionIds = null) => + new(Guid.NewGuid(), "two-sum", "Two Sum", difficultyValue, ["arrays"], versionIds ?? []); + + [Test] + public async Task Handle_PassesThroughPageMetadata() + { + var page = new PageResult + { + Results = [CreateRow()], + Total = 1, + Page = 1, + Size = 20, + }; + var pagination = new PaginationRequest { Page = 1, Size = 20 }; + _problemReadRepository + .Setup(x => x.GetPagedAsync(pagination, null, It.IsAny())) + .ReturnsAsync(page); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + var result = await _handler.Handle(new GetProblemsPageableQuery(pagination, null), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.Multiple(() => + { + Assert.That(result.Value.Total, Is.EqualTo(1)); + Assert.That(result.Value.Page, Is.EqualTo(1)); + Assert.That(result.Value.Size, Is.EqualTo(20)); + Assert.That(result.Value.Results, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task Handle_PassesPaginationRequestAndSearchThroughToRepository() + { + var pagination = new PaginationRequest { Page = 3, Size = 5 }; + _problemReadRepository + .Setup(x => + x.GetPagedAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync( + new PageResult + { + Results = [], + Total = 0, + Page = 3, + Size = 5, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + await _handler.Handle(new GetProblemsPageableQuery(pagination, "two sum"), CancellationToken.None); + + _problemReadRepository.Verify( + x => x.GetPagedAsync(pagination, "two sum", It.IsAny()), + Times.Once + ); + } + + [Test] + public async Task Handle_KnownLanguageVersion_MapsLanguageWithSlug() + { + var language = new Language(new LanguageName("SQLite"), new LanguageSlug("sqlite"), Guid.NewGuid()); + var versionEntry = language.AddVersion(new LanguageVersion("3.27.2"), new Judge0Id(82)); + _problemReadRepository + .Setup(x => + x.GetPagedAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync( + new PageResult + { + Results = [CreateRow(versionIds: [versionEntry.Id])], + Total = 1, + Page = 1, + Size = 20, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([language]); + + var query = new GetProblemsPageableQuery(new PaginationRequest { Page = 1, Size = 20 }, null); + var result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results[0].Languages, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(result.Value.Results[0].Languages[0].Name, Is.EqualTo("SQLite")); + Assert.That(result.Value.Results[0].Languages[0].Slug, Is.EqualTo("sqlite")); + }); + } + + [Test] + public async Task Handle_MultipleVersionsOfSameLanguage_DeduplicatesToOneEntry() + { + var language = new Language(new LanguageName("Python"), new LanguageSlug("python"), Guid.NewGuid()); + var v1 = language.AddVersion(new LanguageVersion("3.12"), new Judge0Id(71)); + var v2 = language.AddVersion(new LanguageVersion("3.13"), new Judge0Id(109)); + _problemReadRepository + .Setup(x => + x.GetPagedAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync( + new PageResult + { + Results = [CreateRow(versionIds: [v1.Id, v2.Id])], + Total = 1, + Page = 1, + Size = 20, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([language]); + + var query = new GetProblemsPageableQuery(new PaginationRequest { Page = 1, Size = 20 }, null); + var result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results[0].Languages, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Handle_UnknownLanguageVersion_OmitsFromLanguages() + { + _problemReadRepository + .Setup(x => + x.GetPagedAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync( + new PageResult + { + Results = [CreateRow(versionIds: [Guid.NewGuid()])], + Total = 1, + Page = 1, + Size = 20, + } + ); + _languageReadRepository + .Setup(x => x.FindLanguagesByVersionId(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + var query = new GetProblemsPageableQuery(new PaginationRequest { Page = 1, Size = 20 }, null); + var result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Value.Results[0].Languages, Is.Empty); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailHandlerTests.cs b/StackDuel.Application.Tests/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailHandlerTests.cs new file mode 100644 index 0000000..b342ad7 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailHandlerTests.cs @@ -0,0 +1,202 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.ValueObjects; +using GetAdminSubmissionDetailHandler = StackDuel.Application.Queries.Submissions.GetAdminSubmissionDetail.GetAdminSubmissionDetailHandler; +using GetAdminSubmissionDetailQuery = StackDuel.Application.Queries.Submissions.GetAdminSubmissionDetail.GetAdminSubmissionDetailQuery; + +namespace StackDuel.Application.Tests.Queries.Submissions.GetAdminSubmissionDetail; + +public class GetAdminSubmissionDetailHandlerTests +{ + private Mock _submissionRepository = null!; + private Mock _submissionReadRepository = null!; + private Mock _submissionJobRepository = null!; + private Mock _pipelineRepository = null!; + private GetAdminSubmissionDetailHandler _handler = null!; + + private static readonly Guid TestCaseId = Guid.NewGuid(); + + [SetUp] + public void SetUp() + { + _submissionRepository = new Mock(); + _submissionReadRepository = new Mock(); + _submissionJobRepository = new Mock(); + _pipelineRepository = new Mock(); + + _handler = new GetAdminSubmissionDetailHandler( + _submissionRepository.Object, + _submissionReadRepository.Object, + _submissionJobRepository.Object, + _pipelineRepository.Object + ); + } + + private static Submission CreateSubmission() + { + var submission = new Submission( + Guid.NewGuid(), + Guid.NewGuid(), + SubmissionType.Submit, + new SourceCode("print(1)"), + [TestCaseId] + ); + submission.UpdateResult( + TestCaseId, + SubmissionResultStatus.Accepted, + runtime: 12, + memoryUsed: 256, + actualOutput: "1" + ); + return submission; + } + + private static AdminSubmissionListItemDto CreateContext(Submission submission) => + new( + submission.Id, + submission.Type, + submission.Status, + submission.ProblemSetupId, + Guid.NewGuid(), + "Two Sum", + "two-sum", + new SubmissionLanguageDto(Guid.NewGuid(), "Python", "3.12"), + new SubmissionUserDto("alice", null), + submission.CreatedAt, + submission.MemoryUsage, + submission.ExecutionTime + ); + + [Test] + public async Task Handle_SubmissionNotFound_ReturnsNotFound() + { + var submissionId = Guid.NewGuid(); + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((Submission?)null); + + Result result = await _handler.Handle( + new GetAdminSubmissionDetailQuery(submissionId), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_AdminContextNotFound_ReturnsNotFound() + { + var submissionId = Guid.NewGuid(); + var submission = CreateSubmission(); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + _submissionReadRepository + .Setup(x => x.FindAdminSubmissionByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((AdminSubmissionListItemDto?)null); + + Result result = await _handler.Handle( + new GetAdminSubmissionDetailQuery(submissionId), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NoJob_ReturnsDtoWithNullJobAndMappedResults() + { + var submissionId = Guid.NewGuid(); + var submission = CreateSubmission(); + var context = CreateContext(submission); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + _submissionReadRepository + .Setup(x => x.FindAdminSubmissionByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(context); + _submissionJobRepository + .Setup(x => x.FindBySubmissionIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((SubmissionJob?)null); + + Result result = await _handler.Handle( + new GetAdminSubmissionDetailQuery(submissionId), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.Job, Is.Null); + Assert.That(result.Value.Id, Is.EqualTo(submission.Id)); + Assert.That(result.Value.ProblemTitle, Is.EqualTo("Two Sum")); + Assert.That(result.Value.MemoryUsage, Is.EqualTo(256)); + Assert.That(result.Value.ExecutionTime, Is.EqualTo(12)); + Assert.That(result.Value.Results, Has.Count.EqualTo(1)); + Assert.That(result.Value.Results[0].Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + }); + } + + [Test] + public async Task Handle_WithJob_DerivesStepStatusesForPendingRunningSucceededAndFailedSteps() + { + var submissionId = Guid.NewGuid(); + var submission = CreateSubmission(); + var context = CreateContext(submission); + + var pipeline = new ExecutionPipeline("standard"); + var step1 = pipeline.AddStep(ExecutionPipelineStepType.Judge0Execute, 1, 3, 30, false, "execute"); + var step2 = pipeline.AddStep(ExecutionPipelineStepType.Judge0Poll, 2, 3, 30, false, "poll"); + var step3 = pipeline.AddStep(ExecutionPipelineStepType.Evaluate, 3, 3, 30, false, "evaluate"); + + var job = new SubmissionJob(submission.Id, Guid.NewGuid(), step1.Id); + var step1Attempt = job.StartAttempt(); + step1Attempt.Succeed("req", "res"); + job.AdvanceTo(step2.Id); + var step2Attempt = job.StartAttempt(); + step2Attempt.Fail("boom"); + job.Fail("boom"); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + _submissionReadRepository + .Setup(x => x.FindAdminSubmissionByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(context); + _submissionJobRepository + .Setup(x => x.FindBySubmissionIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(job); + _pipelineRepository + .Setup(x => x.FindByIdWithStepsAsync(job.PipelineId, It.IsAny())) + .ReturnsAsync(pipeline); + + Result result = await _handler.Handle( + new GetAdminSubmissionDetailQuery(submissionId), + CancellationToken.None + ); + + Assert.That(result.Value.Job, Is.Not.Null); + var steps = result.Value.Job!.Steps; + + Assert.Multiple(() => + { + Assert.That(steps[0].Status, Is.EqualTo(AdminSubmissionJobStepStatus.Succeeded)); + Assert.That(steps[1].Status, Is.EqualTo(AdminSubmissionJobStepStatus.Failed)); + Assert.That(steps[2].Status, Is.EqualTo(AdminSubmissionJobStepStatus.Pending)); + Assert.That(steps[1].IsCurrent, Is.True); + Assert.That(steps[0].AttemptCount, Is.EqualTo(1)); + Assert.That(steps[2].AttemptCount, Is.EqualTo(0)); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableHandlerTests.cs b/StackDuel.Application.Tests/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableHandlerTests.cs new file mode 100644 index 0000000..314f026 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableHandlerTests.cs @@ -0,0 +1,61 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Pagination; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using GetAdminSubmissionsPageableHandler = StackDuel.Application.Queries.Submissions.GetAdminSubmissionsPageable.GetAdminSubmissionsPageableHandler; +using GetAdminSubmissionsPageableQuery = StackDuel.Application.Queries.Submissions.GetAdminSubmissionsPageable.GetAdminSubmissionsPageableQuery; + +namespace StackDuel.Application.Tests.Queries.Submissions.GetAdminSubmissionsPageable; + +public class GetAdminSubmissionsPageableHandlerTests +{ + private Mock _submissionReadRepository = null!; + private GetAdminSubmissionsPageableHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _submissionReadRepository = new Mock(); + _handler = new GetAdminSubmissionsPageableHandler(_submissionReadRepository.Object); + } + + [Test] + public async Task Handle_ReturnsPagedResultFromRepository() + { + var pagination = new PaginationRequest { Page = 2, Size = 10 }; + var filterId = Guid.NewGuid(); + var expected = new PageResult + { + Results = [], + Total = 0, + Page = 2, + Size = 10, + }; + + _submissionReadRepository + .Setup(x => x.GetAdminSubmissionsPagedAsync(pagination, filterId, It.IsAny())) + .ReturnsAsync(expected); + + Result> result = await _handler.Handle( + new GetAdminSubmissionsPageableQuery(pagination, filterId), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.SameAs(expected)); + } + + [Test] + public async Task Handle_NullId_PassesNullThrough() + { + var pagination = new PaginationRequest { Page = 1, Size = 25 }; + + await _handler.Handle(new GetAdminSubmissionsPageableQuery(pagination, null), CancellationToken.None); + + _submissionReadRepository.Verify( + x => x.GetAdminSubmissionsPagedAsync(pagination, null, It.IsAny()), + Times.Once + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandlerTests.cs b/StackDuel.Application.Tests/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandlerTests.cs new file mode 100644 index 0000000..9cbb166 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandlerTests.cs @@ -0,0 +1,174 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.ValueObjects; +using StackDuel.Domain.TestSuites; +using GetSubmissionStatusHandler = StackDuel.Application.Queries.Submissions.GetSubmissionStatus.GetSubmissionStatusHandler; +using GetSubmissionStatusQuery = StackDuel.Application.Queries.Submissions.GetSubmissionStatus.GetSubmissionStatusQuery; + +namespace StackDuel.Application.Tests.Queries.Submissions.GetSubmissionStatus; + +public class GetSubmissionStatusHandlerTests +{ + private Mock _submissionRepository = null!; + private Mock _testSuiteRepository = null!; + private Mock _submissionJobRepository = null!; + private Mock _pipelineRepository = null!; + private GetSubmissionStatusHandler _handler = null!; + + private static readonly Guid TestCaseId = Guid.NewGuid(); + + [SetUp] + public void SetUp() + { + _submissionRepository = new Mock(); + _testSuiteRepository = new Mock(); + _submissionJobRepository = new Mock(); + _pipelineRepository = new Mock(); + + _handler = new GetSubmissionStatusHandler( + _submissionRepository.Object, + _testSuiteRepository.Object, + _submissionJobRepository.Object, + _pipelineRepository.Object + ); + + _testSuiteRepository + .Setup(x => + x.FindExpectedOutputsByTestCaseIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([]); + _testSuiteRepository + .Setup(x => x.FindInputsByTestCaseIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + } + + private static Submission CreateSubmission(Guid userId) => + new(userId, Guid.NewGuid(), SubmissionType.Submit, new SourceCode("print(1)"), [TestCaseId]); + + [Test] + public async Task Handle_SubmissionNotFound_ReturnsNotFound() + { + var submissionId = Guid.NewGuid(); + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((Submission?)null); + + var result = await _handler.Handle( + new GetSubmissionStatusQuery(submissionId, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_SubmissionBelongsToDifferentUser_ReturnsNotFound() + { + var owner = Guid.NewGuid(); + var submission = CreateSubmission(owner); + var submissionId = Guid.NewGuid(); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + + var result = await _handler.Handle( + new GetSubmissionStatusQuery(submissionId, Guid.NewGuid()), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_NoJob_ReturnsDtoWithNullCurrentStepName() + { + var userId = Guid.NewGuid(); + var submission = CreateSubmission(userId); + var submissionId = Guid.NewGuid(); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + _submissionJobRepository + .Setup(x => x.FindBySubmissionIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((SubmissionJob?)null); + + var result = await _handler.Handle(new GetSubmissionStatusQuery(submissionId, userId), CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.CurrentStepName, Is.Null); + Assert.That(result.Value.SubmissionId, Is.EqualTo(submission.Id)); + Assert.That(result.Value.Results, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task Handle_WithActiveJob_ResolvesCurrentStepNameFromPipeline() + { + var userId = Guid.NewGuid(); + var submission = CreateSubmission(userId); + var submissionId = Guid.NewGuid(); + + var pipeline = new ExecutionPipeline("standard"); + var step = pipeline.AddStep(ExecutionPipelineStepType.Judge0Poll, 1, 3, 30, true, "polling judge0"); + var job = new SubmissionJob(submission.Id, Guid.NewGuid(), step.Id); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + _submissionJobRepository + .Setup(x => x.FindBySubmissionIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(job); + _pipelineRepository + .Setup(x => x.FindByIdWithStepsAsync(job.PipelineId, It.IsAny())) + .ReturnsAsync(pipeline); + + var result = await _handler.Handle(new GetSubmissionStatusQuery(submissionId, userId), CancellationToken.None); + + Assert.That(result.Value.CurrentStepName, Is.EqualTo("polling judge0")); + } + + [Test] + public async Task Handle_MapsResultInputsAndExpectedOutputsFromTestSuiteRepository() + { + var userId = Guid.NewGuid(); + var submission = CreateSubmission(userId); + submission.UpdateResult(TestCaseId, SubmissionResultStatus.WrongAnswer, actualOutput: "3"); + var submissionId = Guid.NewGuid(); + + _submissionRepository + .Setup(x => x.FindByIdAsync(submissionId, It.IsAny())) + .ReturnsAsync(submission); + _submissionJobRepository + .Setup(x => x.FindBySubmissionIdAsync(submissionId, It.IsAny())) + .ReturnsAsync((SubmissionJob?)null); + _testSuiteRepository + .Setup(x => x.FindInputsByTestCaseIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { [TestCaseId] = "2 3" }); + _testSuiteRepository + .Setup(x => + x.FindExpectedOutputsByTestCaseIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync(new Dictionary { [TestCaseId] = "5" }); + + var result = await _handler.Handle(new GetSubmissionStatusQuery(submissionId, userId), CancellationToken.None); + + var resultDto = result.Value.Results.Single(); + Assert.Multiple(() => + { + Assert.That(resultDto.Input, Is.EqualTo("2 3")); + Assert.That(resultDto.ExpectedOutput, Is.EqualTo("5")); + Assert.That(resultDto.ActualOutput, Is.EqualTo("3")); + Assert.That(resultDto.Status, Is.EqualTo(SubmissionResultStatus.WrongAnswer)); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugHandlerTests.cs b/StackDuel.Application.Tests/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugHandlerTests.cs new file mode 100644 index 0000000..4776244 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugHandlerTests.cs @@ -0,0 +1,104 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using GetSubmissionsByProblemSlugHandler = StackDuel.Application.Queries.Submissions.GetSubmissionsByProblemSlug.GetSubmissionsByProblemSlugHandler; +using GetSubmissionsByProblemSlugQuery = StackDuel.Application.Queries.Submissions.GetSubmissionsByProblemSlug.GetSubmissionsByProblemSlugQuery; + +namespace StackDuel.Application.Tests.Queries.Submissions.GetSubmissionsByProblemSlug; + +public class GetSubmissionsByProblemSlugHandlerTests +{ + private Mock _problemReadRepository = null!; + private Mock _submissionReadRepository = null!; + private GetSubmissionsByProblemSlugHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _problemReadRepository = new Mock(); + _submissionReadRepository = new Mock(); + _handler = new GetSubmissionsByProblemSlugHandler( + _problemReadRepository.Object, + _submissionReadRepository.Object + ); + } + + [Test] + public async Task Handle_SlugNotFound_ReturnsNotFound() + { + _problemReadRepository + .Setup(x => x.GetIdBySlugAsync("missing", It.IsAny())) + .ReturnsAsync((Guid?)null); + + var query = new GetSubmissionsByProblemSlugQuery( + "missing", + new PaginationRequest { Page = 1, Size = 10 }, + null, + SubmissionFilter.MySubmissions, + SubmissionSortOrder.Newest + ); + + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + _submissionReadRepository.Verify( + x => + x.GetProblemSubmissionsPagedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_SlugFound_PassesResolvedProblemIdAndFiltersToRepository() + { + var problemId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var pagination = new PaginationRequest { Page = 2, Size = 20 }; + var expected = new PageResult + { + Results = [], + Total = 0, + Page = 2, + Size = 20, + }; + + _problemReadRepository + .Setup(x => x.GetIdBySlugAsync("two-sum", It.IsAny())) + .ReturnsAsync(problemId); + _submissionReadRepository + .Setup(x => + x.GetProblemSubmissionsPagedAsync( + problemId, + pagination, + userId, + SubmissionFilter.UserSolutions, + SubmissionSortOrder.Oldest, + It.IsAny() + ) + ) + .ReturnsAsync(expected); + + var query = new GetSubmissionsByProblemSlugQuery( + "two-sum", + pagination, + userId, + SubmissionFilter.UserSolutions, + SubmissionSortOrder.Oldest + ); + + Result> result = await _handler.Handle(query, CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.SameAs(expected)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Tracks/GetTracks/GetTracksHandlerTests.cs b/StackDuel.Application.Tests/Queries/Tracks/GetTracks/GetTracksHandlerTests.cs new file mode 100644 index 0000000..1c8b41e --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Tracks/GetTracks/GetTracksHandlerTests.cs @@ -0,0 +1,74 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Languages; +using StackDuel.Application.Tracks; +using StackDuel.Application.Tracks.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Tracks.Entities; +using GetTracksHandler = StackDuel.Application.Queries.Tracks.GetTracks.GetTracksHandler; +using GetTracksQuery = StackDuel.Application.Queries.Tracks.GetTracks.GetTracksQuery; + +namespace StackDuel.Application.Tests.Queries.Tracks.GetTracks; + +public class GetTracksHandlerTests +{ + private Mock _trackReadRepository = null!; + private Mock _languageReadRepository = null!; + private GetTracksHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _trackReadRepository = new Mock(); + _languageReadRepository = new Mock(); + _handler = new GetTracksHandler(_trackReadRepository.Object, _languageReadRepository.Object); + } + + [Test] + public async Task Handle_NoTracks_ReturnsEmptyList() + { + _trackReadRepository.Setup(x => x.GetActiveTracksAsync(It.IsAny())).ReturnsAsync([]); + _languageReadRepository + .Setup(x => + x.GetActiveLanguagesByTrackIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([]); + + Result> result = await _handler.Handle(new GetTracksQuery(), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.Empty); + } + + [Test] + public async Task Handle_Success_GroupsLanguagesUnderTheirOwnTrackOnly() + { + var frontendTrack = new Track("frontend", "Frontend"); + var sqlTrack = new Track("sql", "SQL"); + var react = new Language(new LanguageName("React"), new LanguageSlug("react"), frontendTrack.Id); + var angular = new Language(new LanguageName("Angular"), new LanguageSlug("angular"), frontendTrack.Id); + var sqlite = new Language(new LanguageName("SQLite"), new LanguageSlug("sqlite"), sqlTrack.Id); + + _trackReadRepository + .Setup(x => x.GetActiveTracksAsync(It.IsAny())) + .ReturnsAsync([frontendTrack, sqlTrack]); + _languageReadRepository + .Setup(x => + x.GetActiveLanguagesByTrackIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([react, angular, sqlite]); + + Result> result = await _handler.Handle(new GetTracksQuery(), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + TrackDto frontendDto = result.Value.Single(t => t.Key == "frontend"); + TrackDto sqlDto = result.Value.Single(t => t.Key == "sql"); + + Assert.Multiple(() => + { + Assert.That(frontendDto.Languages.Select(l => l.Name), Is.EquivalentTo(new[] { "React", "Angular" })); + Assert.That(sqlDto.Languages.Select(l => l.Name), Is.EquivalentTo(new[] { "SQLite" })); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Users/GetAdminUserDetail/GetAdminUserDetailHandlerTests.cs b/StackDuel.Application.Tests/Queries/Users/GetAdminUserDetail/GetAdminUserDetailHandlerTests.cs new file mode 100644 index 0000000..7376869 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Users/GetAdminUserDetail/GetAdminUserDetailHandlerTests.cs @@ -0,0 +1,57 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos.Admin; +using GetAdminUserDetailHandler = StackDuel.Application.Queries.Users.GetAdminUserDetail.GetAdminUserDetailHandler; +using GetAdminUserDetailQuery = StackDuel.Application.Queries.Users.GetAdminUserDetail.GetAdminUserDetailQuery; + +namespace StackDuel.Application.Tests.Queries.Users.GetAdminUserDetail; + +public class GetAdminUserDetailHandlerTests +{ + private Mock _userReadRepository = null!; + private GetAdminUserDetailHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userReadRepository = new Mock(); + _handler = new GetAdminUserDetailHandler(_userReadRepository.Object); + } + + [Test] + public async Task Handle_ReturnsUserFromRepository() + { + Guid userId = Guid.NewGuid(); + var expected = new AdminUserDetailDto(userId, "alice", null, null, false, null, DateTime.UtcNow, null, []); + + _userReadRepository + .Setup(x => x.FindAdminUserDetailByIdAsync(userId, It.IsAny())) + .ReturnsAsync(expected); + + Result result = await _handler.Handle( + new GetAdminUserDetailQuery(userId), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.SameAs(expected)); + } + + [Test] + public async Task Handle_ReturnsNotFound_WhenUserDoesNotExist() + { + Guid userId = Guid.NewGuid(); + + _userReadRepository + .Setup(x => x.FindAdminUserDetailByIdAsync(userId, It.IsAny())) + .ReturnsAsync((AdminUserDetailDto?)null); + + Result result = await _handler.Handle( + new GetAdminUserDetailQuery(userId), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableHandlerTests.cs b/StackDuel.Application.Tests/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableHandlerTests.cs new file mode 100644 index 0000000..06622b6 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableHandlerTests.cs @@ -0,0 +1,47 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos.Admin; +using GetAdminUsersPageableHandler = StackDuel.Application.Queries.Users.GetAdminUsersPageable.GetAdminUsersPageableHandler; +using GetAdminUsersPageableQuery = StackDuel.Application.Queries.Users.GetAdminUsersPageable.GetAdminUsersPageableQuery; + +namespace StackDuel.Application.Tests.Queries.Users.GetAdminUsersPageable; + +public class GetAdminUsersPageableHandlerTests +{ + private Mock _userReadRepository = null!; + private GetAdminUsersPageableHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userReadRepository = new Mock(); + _handler = new GetAdminUsersPageableHandler(_userReadRepository.Object); + } + + [Test] + public async Task Handle_ReturnsPagedResultFromRepository() + { + var pagination = new PaginationRequest { Page = 1, Size = 50 }; + var expected = new PageResult + { + Results = [new AdminUserDto(Guid.NewGuid(), "alice", null, null, DateTime.UtcNow, null, [])], + Total = 1, + Page = 1, + Size = 50, + }; + + _userReadRepository + .Setup(x => x.GetAdminUsersPageableAsync(pagination, null, It.IsAny())) + .ReturnsAsync(expected); + + Result> result = await _handler.Handle( + new GetAdminUsersPageableQuery(pagination, null), + CancellationToken.None + ); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.SameAs(expected)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Users/GetUserBySub/GetUserBySubHandlerTests.cs b/StackDuel.Application.Tests/Queries/Users/GetUserBySub/GetUserBySubHandlerTests.cs new file mode 100644 index 0000000..70d6334 --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Users/GetUserBySub/GetUserBySubHandlerTests.cs @@ -0,0 +1,56 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using GetUserBySubHandler = StackDuel.Application.Queries.Users.GetUserBySub.GetUserBySubHandler; +using GetUserBySubQuery = StackDuel.Application.Queries.Users.GetUserBySub.GetUserBySubQuery; + +namespace StackDuel.Application.Tests.Queries.Users.GetUserBySub; + +public class GetUserBySubHandlerTests +{ + private Mock _userReadRepository = null!; + private GetUserBySubHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userReadRepository = new Mock(); + _handler = new GetUserBySubHandler(_userReadRepository.Object); + } + + [Test] + public async Task Handle_UserNotFound_ReturnsNotFound() + { + _userReadRepository + .Setup(x => x.FindBySubAsync("auth0|missing", It.IsAny())) + .ReturnsAsync((UserDto?)null); + + Result result = await _handler.Handle(new GetUserBySubQuery("auth0|missing"), CancellationToken.None); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_UserFound_ReturnsUser() + { + var user = new UserDto( + Guid.NewGuid(), + "auth0|abc", + "alice", + null, + null, + false, + null, + DateTime.UtcNow, + null, + [] + ); + _userReadRepository.Setup(x => x.FindBySubAsync("auth0|abc", It.IsAny())).ReturnsAsync(user); + + Result result = await _handler.Handle(new GetUserBySubQuery("auth0|abc"), CancellationToken.None); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value, Is.SameAs(user)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Users/GetUserGameStats/GetUserGameStatsHandlerTests.cs b/StackDuel.Application.Tests/Queries/Users/GetUserGameStats/GetUserGameStatsHandlerTests.cs new file mode 100644 index 0000000..904493c --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Users/GetUserGameStats/GetUserGameStatsHandlerTests.cs @@ -0,0 +1,104 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Queries.Users.GetUserGameStats; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Tests.Queries.Users.GetUserGameStats; + +public class GetUserGameStatsHandlerTests +{ + private Mock _userReadRepository = null!; + private Mock _gameReadRepository = null!; + private GetUserGameStatsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userReadRepository = new Mock(); + _gameReadRepository = new Mock(); + + _handler = new GetUserGameStatsHandler(_userReadRepository.Object, _gameReadRepository.Object); + + _gameReadRepository + .Setup(x => x.GetActiveGameModesAsync(It.IsAny())) + .ReturnsAsync((IReadOnlyList)[]); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IReadOnlyList)[]); + } + + private static UserProfileDto CreateBaseProfile(Guid userId, bool isPrivate) => + new(userId, "alice", "bio", null, DateTime.UtcNow, isPrivate, false, null, null, null, null); + + [Test] + public async Task Handle_ProfileNotFound_ReturnsNotFound() + { + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("missing", It.IsAny())) + .ReturnsAsync((UserProfileDto?)null); + + Result result = await _handler.Handle( + new GetUserGameStatsQuery("missing", null), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_PrivateProfile_NotOwner_ReturnsNullStatsWithoutFetchingGames() + { + var profile = CreateBaseProfile(Guid.NewGuid(), isPrivate: true); + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + + Result result = await _handler.Handle( + new GetUserGameStatsQuery("alice", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.IsOwnProfile, Is.False); + Assert.That(result.Value.GameModeStats, Is.Null); + }); + _gameReadRepository.Verify( + x => x.GetCompletedGamesForUserAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Test] + public async Task Handle_PublicProfile_ReturnsGameModeStats() + { + var userId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + var mode = new GameMode("duel", "Duel", "Head to head", true, 2, 2, Guid.NewGuid()); + var game = new Game(mode.Id, Guid.NewGuid(), [Guid.NewGuid()], [userId, Guid.NewGuid()], 600); + game.Start(); + game.RecordProblemSolved(userId); + game.Complete(); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _gameReadRepository + .Setup(x => x.GetActiveGameModesAsync(It.IsAny())) + .ReturnsAsync((IReadOnlyList)[mode]); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[game]); + + Result result = await _handler.Handle( + new GetUserGameStatsQuery("alice", userId), + CancellationToken.None + ); + + Assert.That(result.Value.GameModeStats!.Single().GamesPlayed, Is.EqualTo(1)); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameHandlerTests.cs b/StackDuel.Application.Tests/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameHandlerTests.cs new file mode 100644 index 0000000..ef38c4a --- /dev/null +++ b/StackDuel.Application.Tests/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameHandlerTests.cs @@ -0,0 +1,384 @@ +using Ardalis.Result; +using Moq; +using StackDuel.Application.Games; +using StackDuel.Application.Submissions; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using GetUserProfileByUsernameHandler = StackDuel.Application.Queries.Users.GetUserProfileByUsername.GetUserProfileByUsernameHandler; +using GetUserProfileByUsernameQuery = StackDuel.Application.Queries.Users.GetUserProfileByUsername.GetUserProfileByUsernameQuery; + +namespace StackDuel.Application.Tests.Queries.Users.GetUserProfileByUsername; + +public class GetUserProfileByUsernameHandlerTests +{ + private Mock _userReadRepository = null!; + private Mock _gameReadRepository = null!; + private Mock _submissionReadRepository = null!; + private GetUserProfileByUsernameHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _userReadRepository = new Mock(); + _gameReadRepository = new Mock(); + _submissionReadRepository = new Mock(); + + _handler = new GetUserProfileByUsernameHandler( + _userReadRepository.Object, + _gameReadRepository.Object, + _submissionReadRepository.Object + ); + + _gameReadRepository + .Setup(x => x.GetActiveGameModesAsync(It.IsAny())) + .ReturnsAsync((IReadOnlyList)[]); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IReadOnlyList)[]); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IReadOnlyDictionary)new Dictionary()); + _submissionReadRepository + .Setup(x => + x.GetRecentSubmissionsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()) + ) + .ReturnsAsync((IReadOnlyList)[]); + _submissionReadRepository + .Setup(x => + x.GetSubmissionCountsByDayForUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync((IReadOnlyList)[]); + } + + private static UserProfileDto CreateBaseProfile(Guid userId, bool isPrivate) => + new(userId, "alice", "bio", null, DateTime.UtcNow, isPrivate, false, null, null, null, null); + + private static Game CreateCompletedGame(Guid gameModeId, params (Guid UserId, int Score)[] participants) + { + var game = new Game(gameModeId, Guid.NewGuid(), [Guid.NewGuid()], participants.Select(p => p.UserId), 600); + game.Start(); + foreach (var (userId, score) in participants) + for (int i = 0; i < score; i++) + game.RecordProblemSolved(userId); + game.Complete(); + return game; + } + + [Test] + public async Task Handle_ProfileNotFound_ReturnsNotFound() + { + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("missing", It.IsAny())) + .ReturnsAsync((UserProfileDto?)null); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("missing", null), + CancellationToken.None + ); + + Assert.That(result.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task Handle_PrivateProfile_NotOwner_ReturnsMinimalProfileWithoutFetchingGamesOrSubmissions() + { + var profile = CreateBaseProfile(Guid.NewGuid(), isPrivate: true); + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", Guid.NewGuid()), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Value.IsOwnProfile, Is.False); + Assert.That(result.Value.GameModeStats, Is.Null); + Assert.That(result.Value.RecentGames, Is.Null); + Assert.That(result.Value.RecentSubmissions, Is.Null); + Assert.That(result.Value.SubmissionCalendar, Is.Null); + }); + _gameReadRepository.Verify( + x => x.GetCompletedGamesForUserAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + _submissionReadRepository.Verify( + x => x.GetRecentSubmissionsForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never + ); + _submissionReadRepository.Verify( + x => + x.GetSubmissionCountsByDayForUserAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task Handle_PrivateProfile_Owner_FetchesPrivateSections() + { + var profile = CreateBaseProfile(Guid.NewGuid(), isPrivate: true); + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", profile.Id), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.Value.IsOwnProfile, Is.True); + Assert.That(result.Value.GameModeStats, Is.Not.Null); + Assert.That(result.Value.RecentGames, Is.Not.Null); + Assert.That(result.Value.RecentSubmissions, Is.Not.Null); + Assert.That(result.Value.SubmissionCalendar, Is.Not.Null); + }); + } + + [Test] + public async Task Handle_SubmissionCalendar_OnlyIncludesDaysWithSubmissionsAndExposesRange() + { + var userId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _submissionReadRepository + .Setup(x => + x.GetSubmissionCountsByDayForUserAsync(userId, It.IsAny(), It.IsAny()) + ) + .ReturnsAsync((IReadOnlyList)[new SubmissionDayCountDto(today, 3)]); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", userId), + CancellationToken.None + ); + + var calendar = result.Value.SubmissionCalendar!; + Assert.Multiple(() => + { + Assert.That(calendar, Has.Count.EqualTo(1)); + Assert.That(calendar[0].Date, Is.EqualTo(today)); + Assert.That(calendar[0].Count, Is.EqualTo(3)); + Assert.That(result.Value.SubmissionCalendarRangeStart, Is.EqualTo(today.AddMonths(-12))); + Assert.That(result.Value.SubmissionCalendarRangeEnd, Is.EqualTo(today)); + }); + } + + [Test] + public async Task Handle_PublicProfile_NotOwner_StillFetchesSections() + { + var profile = CreateBaseProfile(Guid.NewGuid(), isPrivate: false); + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", null), + CancellationToken.None + ); + + Assert.That(result.Value.GameModeStats, Is.Not.Null); + } + + [Test] + public async Task Handle_MultiplayerMode_ComputesWinsLossesDrawsAndBestScore() + { + var userId = Guid.NewGuid(); + var opponentId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + var mode = new GameMode("duel", "Duel", "Head to head", true, 2, 2, Guid.NewGuid()); + + var loss = CreateCompletedGame(mode.Id, (userId, 3), (opponentId, 5)); + var draw = CreateCompletedGame(mode.Id, (userId, 5), (opponentId, 5)); + var win = CreateCompletedGame(mode.Id, (userId, 5), (opponentId, 2)); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _gameReadRepository + .Setup(x => x.GetActiveGameModesAsync(It.IsAny())) + .ReturnsAsync((IReadOnlyList)[mode]); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[loss, draw, win]); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", userId), + CancellationToken.None + ); + + var stat = result.Value.GameModeStats!.Single(); + Assert.Multiple(() => + { + Assert.That(stat.GamesPlayed, Is.EqualTo(3)); + Assert.That(stat.Wins, Is.EqualTo(1)); + Assert.That(stat.Losses, Is.EqualTo(1)); + Assert.That(stat.Draws, Is.EqualTo(1)); + Assert.That(stat.BestScore, Is.EqualTo(5)); + Assert.That(stat.HasOpponents, Is.True); + }); + } + + [Test] + public async Task Handle_SoloMode_OnlyTracksBestScoreWithoutWinLossDraw() + { + var userId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + var soloMode = new GameMode("solo-rush", "Solo Rush", "Beat your best", true, 1, 1, Guid.NewGuid()); + + var game = CreateCompletedGame(soloMode.Id, (userId, 7)); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _gameReadRepository + .Setup(x => x.GetActiveGameModesAsync(It.IsAny())) + .ReturnsAsync((IReadOnlyList)[soloMode]); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[game]); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", userId), + CancellationToken.None + ); + + var stat = result.Value.GameModeStats!.Single(); + Assert.Multiple(() => + { + Assert.That(stat.HasOpponents, Is.False); + Assert.That(stat.BestScore, Is.EqualTo(7)); + Assert.That(stat.Wins, Is.EqualTo(0)); + Assert.That(stat.Losses, Is.EqualTo(0)); + Assert.That(stat.Draws, Is.EqualTo(0)); + Assert.That(stat.GamesPlayed, Is.EqualTo(1)); + }); + } + + [Test] + public async Task Handle_DeactivatedGameMode_StillIncludedWhenLookupSucceeds() + { + var userId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + var retiredMode = new GameMode("retired", "Retired Mode", "No longer offered", true, 2, 2, Guid.NewGuid()); + retiredMode.Deactivate(); + + var game = CreateCompletedGame(retiredMode.Id, (userId, 4), (Guid.NewGuid(), 1)); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[game]); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(retiredMode.Id, It.IsAny())) + .ReturnsAsync(retiredMode); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", userId), + CancellationToken.None + ); + + Assert.That(result.Value.GameModeStats!.Single().GameModeName, Is.EqualTo("Retired Mode")); + } + + [Test] + public async Task Handle_UnknownGameMode_ExcludedFromStatsAndRecentGames() + { + var userId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + var game = CreateCompletedGame(Guid.NewGuid(), (userId, 4), (Guid.NewGuid(), 1)); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[game]); + _gameReadRepository + .Setup(x => x.FindGameModeByIdIncludingInactiveAsync(game.GameModeId, It.IsAny())) + .ReturnsAsync((GameMode?)null); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", userId), + CancellationToken.None + ); + + Assert.Multiple(() => + { + Assert.That(result.Value.GameModeStats, Is.Empty); + Assert.That(result.Value.RecentGames, Is.Empty); + }); + } + + [Test] + public async Task Handle_RecentGames_MapsParticipantsOrderedByScoreDescendingWithUnknownFallback() + { + var userId = Guid.NewGuid(); + var opponentId = Guid.NewGuid(); + var profile = CreateBaseProfile(userId, isPrivate: false); + var mode = new GameMode("duel", "Duel", "Head to head", true, 2, 2, Guid.NewGuid()); + var game = CreateCompletedGame(mode.Id, (userId, 2), (opponentId, 6)); + + _userReadRepository + .Setup(x => x.FindProfileByUsernameAsync("alice", It.IsAny())) + .ReturnsAsync(profile); + _gameReadRepository + .Setup(x => x.GetActiveGameModesAsync(It.IsAny())) + .ReturnsAsync((IReadOnlyList)[mode]); + _gameReadRepository + .Setup(x => x.GetCompletedGamesForUserAsync(userId, It.IsAny())) + .ReturnsAsync((IReadOnlyList)[game]); + _userReadRepository + .Setup(x => x.FindByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync( + (IReadOnlyDictionary) + new Dictionary + { + [userId] = new UserDto( + userId, + "auth0|a", + "alice", + null, + null, + false, + null, + DateTime.UtcNow, + null, + [] + ), + } + ); + + Result result = await _handler.Handle( + new GetUserProfileByUsernameQuery("alice", userId), + CancellationToken.None + ); + + var recentGame = result.Value.RecentGames!.Single(); + Assert.Multiple(() => + { + Assert.That(recentGame.Participants[0].Username, Is.EqualTo("Unknown")); + Assert.That(recentGame.Participants[0].Score, Is.EqualTo(6)); + Assert.That(recentGame.Participants[1].Username, Is.EqualTo("alice")); + Assert.That(recentGame.Participants[1].Score, Is.EqualTo(2)); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/Services/Users/AvatarRetentionPolicyTests.cs b/StackDuel.Application.Tests/Services/Users/AvatarRetentionPolicyTests.cs new file mode 100644 index 0000000..ef5461e --- /dev/null +++ b/StackDuel.Application.Tests/Services/Users/AvatarRetentionPolicyTests.cs @@ -0,0 +1,67 @@ +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Application.Tests.Services.Users; + +public class AvatarRetentionPolicyTests +{ + [Test] + public void SelectStale_FewerThanLimit_ReturnsNone() + { + var userId = Guid.NewGuid(); + var current = new UserAvatar(userId, new ImageUrl("https://storage.example.com/1.png")); + var other = new UserAvatar(userId, new ImageUrl("https://storage.example.com/2.png")); + + IReadOnlyList stale = AvatarRetentionPolicy.SelectStale([current, other], current.ImageUrl.Value); + + Assert.That(stale, Is.Empty); + } + + [Test] + public void SelectStale_MoreThanLimit_KeepsCurrentAndMostRecentOthers() + { + var userId = Guid.NewGuid(); + var newest = new UserAvatar(userId, new ImageUrl("https://storage.example.com/1.png")); + var second = new UserAvatar(userId, new ImageUrl("https://storage.example.com/2.png")); + var third = new UserAvatar(userId, new ImageUrl("https://storage.example.com/3.png")); + var oldest = new UserAvatar(userId, new ImageUrl("https://storage.example.com/4.png")); + + IReadOnlyList stale = AvatarRetentionPolicy.SelectStale( + [newest, second, third, oldest], + newest.ImageUrl.Value + ); + + Assert.That(stale, Is.EquivalentTo(new[] { oldest })); + } + + [Test] + public void SelectStale_CurrentIsOlderThanTheMostRecentOthers_StillKeepsCurrent() + { + var userId = Guid.NewGuid(); + var newest = new UserAvatar(userId, new ImageUrl("https://storage.example.com/1.png")); + var second = new UserAvatar(userId, new ImageUrl("https://storage.example.com/2.png")); + var third = new UserAvatar(userId, new ImageUrl("https://storage.example.com/3.png")); + var selectedOlder = new UserAvatar(userId, new ImageUrl("https://storage.example.com/4.png")); + + IReadOnlyList stale = AvatarRetentionPolicy.SelectStale( + [newest, second, third, selectedOlder], + selectedOlder.ImageUrl.Value + ); + + Assert.That(stale, Is.EquivalentTo(new[] { third })); + } + + [Test] + public void SelectStale_NoCurrentImageUrl_KeepsMostRecentOthersOnly() + { + var userId = Guid.NewGuid(); + var newest = new UserAvatar(userId, new ImageUrl("https://storage.example.com/1.png")); + var second = new UserAvatar(userId, new ImageUrl("https://storage.example.com/2.png")); + var third = new UserAvatar(userId, new ImageUrl("https://storage.example.com/3.png")); + + IReadOnlyList stale = AvatarRetentionPolicy.SelectStale([newest, second, third], null); + + Assert.That(stale, Is.EquivalentTo(new[] { third })); + } +} \ No newline at end of file diff --git a/StackDuel.Application.Tests/StackDuel.Application.Tests.csproj b/StackDuel.Application.Tests/StackDuel.Application.Tests.csproj index edb299f..e7f3e2e 100644 --- a/StackDuel.Application.Tests/StackDuel.Application.Tests.csproj +++ b/StackDuel.Application.Tests/StackDuel.Application.Tests.csproj @@ -1,7 +1,7 @@ - - + net10.0 + latest enable enable false @@ -10,12 +10,18 @@ - - + + + + - + + - \ No newline at end of file + + + + diff --git a/StackDuel.Application.Tests/UnitTest1.cs b/StackDuel.Application.Tests/UnitTest1.cs deleted file mode 100644 index e11ebba..0000000 --- a/StackDuel.Application.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StackDuel.Application.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/StackDuel.Application/ApplicationServiceRegistration.cs b/StackDuel.Application/ApplicationServiceRegistration.cs new file mode 100644 index 0000000..e5f6510 --- /dev/null +++ b/StackDuel.Application/ApplicationServiceRegistration.cs @@ -0,0 +1,99 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using StackDuel.Application.Behaviors; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Application.Problems; +using StackDuel.Application.Services.Audit; +using StackDuel.Application.Services.Campaigns; +using StackDuel.Application.Services.DailyChallenges; +using StackDuel.Application.Services.Dashboard; +using StackDuel.Application.Services.FeatureFlags; +using StackDuel.Application.Services.Feedback; +using StackDuel.Application.Services.Games; +using StackDuel.Application.Services.Groups; +using StackDuel.Application.Services.Languages; +using StackDuel.Application.Services.Leaderboards; +using StackDuel.Application.Services.Problems; +using StackDuel.Application.Services.Submissions; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.Factories; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Factories; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Factories; + +namespace StackDuel.Application; + +public static class ApplicationServiceRegistration +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(ApplicationServiceRegistration).Assembly); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(AuditBehavior<,>)); + }); + services.AddValidatorsFromAssembly(typeof(ApplicationServiceRegistration).Assembly, includeInternalTypes: true); + services.AddMemoryCache(); + + services.AddFactories(); + services.AddServices(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + private static IServiceCollection AddFactories(this IServiceCollection services) + { + services.AddScoped, UserFactory>(); + services.AddScoped, SubmissionFactory>(); + services.AddScoped< + IAggregateFactory, + FeedbackSubmissionFactory + >(); + + return services; + } + + private static IServiceCollection AddServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + foreach (string gameModeKey in new[] { "solo_rush", "duel", "ffa" }) + { + string key = gameModeKey; + services.AddScoped(sp => new DifficultyRampProblemSelectionStrategy( + key, + sp.GetRequiredService() + )); + } + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Audit/Dtos/AuditLogEntryDto.cs b/StackDuel.Application/Audit/Dtos/AuditLogEntryDto.cs new file mode 100644 index 0000000..aa9800e --- /dev/null +++ b/StackDuel.Application/Audit/Dtos/AuditLogEntryDto.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Application.Audit.Dtos; + +public sealed record AuditLogEntryDto( + Guid Id, + Guid? ActorUserId, + string ActorUsername, + string Action, + string? TargetType, + string? TargetId, + string? DetailsJson, + DateTime CreatedAt +); \ No newline at end of file diff --git a/StackDuel.Application/Audit/IAuditLogReadRepository.cs b/StackDuel.Application/Audit/IAuditLogReadRepository.cs new file mode 100644 index 0000000..6e3c18a --- /dev/null +++ b/StackDuel.Application/Audit/IAuditLogReadRepository.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit.Dtos; +using StackDuel.Application.Pagination; + +namespace StackDuel.Application.Audit; + +public interface IAuditLogReadRepository +{ + Task> GetPageableAsync( + PaginationRequest pagination, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Audit/IAuditLogWriteRepository.cs b/StackDuel.Application/Audit/IAuditLogWriteRepository.cs new file mode 100644 index 0000000..463fe56 --- /dev/null +++ b/StackDuel.Application/Audit/IAuditLogWriteRepository.cs @@ -0,0 +1,8 @@ +using StackDuel.Domain.Audit.Entities; + +namespace StackDuel.Application.Audit; + +public interface IAuditLogWriteRepository +{ + Task AddAsync(AuditLogEntry entry, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Audit/IAuditableCommand.cs b/StackDuel.Application/Audit/IAuditableCommand.cs new file mode 100644 index 0000000..6db1789 --- /dev/null +++ b/StackDuel.Application/Audit/IAuditableCommand.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Application.Audit; + +public interface IAuditableCommand +{ + string AuditAction { get; } + + string? AuditTargetType { get; } + + string? AuditTargetId { get; } + + object? AuditDetails { get; } +} \ No newline at end of file diff --git a/StackDuel.Application/Behavior/AuditBehavior.cs b/StackDuel.Application/Behavior/AuditBehavior.cs new file mode 100644 index 0000000..aadb7af --- /dev/null +++ b/StackDuel.Application/Behavior/AuditBehavior.cs @@ -0,0 +1,43 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Audit; +using StackDuel.Domain.Audit.Entities; +using System.Text.Json; + +namespace StackDuel.Application.Behaviors; + +internal sealed class AuditBehavior( + IAuditLogWriteRepository auditLogWriteRepository, + UserContext userContext +) : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + TResponse response = await next(cancellationToken); + + if (request is IAuditableCommand auditable && response is IResult result && result.Status == ResultStatus.Ok) + { + string? detailsJson = auditable.AuditDetails is null + ? null + : JsonSerializer.Serialize(auditable.AuditDetails); + + AuditLogEntry entry = AuditLogEntry.Create( + userContext.User?.Id, + userContext.User?.Username ?? "unknown", + auditable.AuditAction, + auditable.AuditTargetType, + auditable.AuditTargetId, + detailsJson + ); + + await auditLogWriteRepository.AddAsync(entry, cancellationToken); + } + + return response; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Behavior/LoggingBehavior.cs b/StackDuel.Application/Behavior/LoggingBehavior.cs new file mode 100644 index 0000000..3cdf2a8 --- /dev/null +++ b/StackDuel.Application/Behavior/LoggingBehavior.cs @@ -0,0 +1,83 @@ +using Ardalis.Result; +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace StackDuel.Application.Behaviors; + +/// +/// Logs every command/query that flows through MediatR: when it started, how long it took, +/// and whether it succeeded or failed. This exists so the Application layer (where the actual +/// business logic lives) produces log output without every handler needing its own ILogger. +/// +/// Uses Result's own success/failure state (Ardalis.Result) rather than exceptions, since +/// handlers in this codebase return failures as Result.Error/Invalid/NotFound rather than throwing. +/// +internal sealed partial class LoggingBehavior(ILogger> logger) + : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + string requestName = typeof(TRequest).Name; + var stopwatch = Stopwatch.StartNew(); + + LogRequestStarted(requestName); + + try + { + TResponse response = await next(cancellationToken); + stopwatch.Stop(); + + if (response is IResult result) + { + if (result.Status == ResultStatus.Ok) + { + LogRequestSucceeded(requestName, stopwatch.ElapsedMilliseconds); + } + else + { + LogRequestFailed( + requestName, + stopwatch.ElapsedMilliseconds, + result.Status.ToString(), + string.Join("; ", result.Errors) + ); + } + } + else + { + LogRequestSucceeded(requestName, stopwatch.ElapsedMilliseconds); + } + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + // Rethrown as-is — this only adds visibility, it never changes handler behavior. + // The global exception handler still owns turning this into an HTTP response. + LogRequestThrew(requestName, stopwatch.ElapsedMilliseconds, ex); + throw; + } + } + + [LoggerMessage(Level = LogLevel.Information, Message = "{RequestName} started")] + private partial void LogRequestStarted(string requestName); + + [LoggerMessage(Level = LogLevel.Information, Message = "{RequestName} succeeded in {ElapsedMs}ms")] + private partial void LogRequestSucceeded(string requestName, long elapsedMs); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "{RequestName} failed in {ElapsedMs}ms with status {Status}: {Errors}" + )] + private partial void LogRequestFailed(string requestName, long elapsedMs, string status, string errors); + + [LoggerMessage(Level = LogLevel.Error, Message = "{RequestName} threw after {ElapsedMs}ms")] + private partial void LogRequestThrew(string requestName, long elapsedMs, Exception exception); +} \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/CampaignDtoMapper.cs b/StackDuel.Application/Campaigns/CampaignDtoMapper.cs new file mode 100644 index 0000000..b71da57 --- /dev/null +++ b/StackDuel.Application/Campaigns/CampaignDtoMapper.cs @@ -0,0 +1,79 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Campaigns; + +internal static class CampaignDtoMapper +{ + public static CampaignDto ToDto(Campaign campaign) => + new( + campaign.Id, + campaign.Slug, + campaign.Title, + campaign.Description, + campaign.Difficulty, + campaign.Status, + campaign.IconKey, + campaign.SortOrder, + [.. campaign.Prerequisites.Select(p => p.RequiredCampaignId)], + [ + .. campaign + .Modules.OrderBy(m => m.SortOrder) + .Select(module => new CampaignModuleDto( + module.Id, + module.Title, + module.Description, + module.SortOrder, + [ + .. module + .Units.OrderBy(u => u.SortOrder) + .Select(unit => new CampaignUnitDto( + unit.Id, + unit.Title, + unit.Content, + unit.UnitType, + unit.SortOrder, + unit.EstimatedMinutes, + [ + .. unit + .Problems.OrderBy(p => p.SortOrder) + .Select(p => new CampaignUnitProblemDto(p.ProblemId, p.SortOrder)), + ] + )), + ] + )), + ] + ); + + public static CampaignSummaryDto ToSummaryDto(Campaign campaign) + { + int unitCount = campaign.Modules.SelectMany(m => m.Units).Count(); + int estimatedMinutes = campaign.Modules.SelectMany(m => m.Units).Sum(u => u.EstimatedMinutes); + + return new CampaignSummaryDto( + campaign.Id, + campaign.Slug, + campaign.Title, + campaign.Description, + campaign.Difficulty, + campaign.IconKey, + campaign.SortOrder, + campaign.Modules.Count, + unitCount, + estimatedMinutes, + [.. campaign.Prerequisites.Select(p => p.RequiredCampaignId)] + ); + } + + public static AdminCampaignListItemDto ToAdminListItemDto(Campaign campaign) => + new( + campaign.Id, + campaign.Slug, + campaign.Title, + campaign.Difficulty, + campaign.Status, + campaign.Modules.Count, + campaign.Modules.SelectMany(m => m.Units).Count(), + campaign.SortOrder + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/AdminCampaignListItemDto.cs b/StackDuel.Application/Campaigns/Dtos/AdminCampaignListItemDto.cs new file mode 100644 index 0000000..e8fee1f --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/AdminCampaignListItemDto.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record AdminCampaignListItemDto( + Guid Id, + string Slug, + string Title, + CampaignDifficulty Difficulty, + CampaignStatus Status, + int ModuleCount, + int UnitCount, + int SortOrder +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/CampaignDto.cs b/StackDuel.Application/Campaigns/Dtos/CampaignDto.cs new file mode 100644 index 0000000..693474a --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/CampaignDto.cs @@ -0,0 +1,16 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record CampaignDto( + Guid Id, + string Slug, + string Title, + string Description, + CampaignDifficulty Difficulty, + CampaignStatus Status, + string? IconKey, + int SortOrder, + IReadOnlyList PrerequisiteCampaignIds, + IReadOnlyList Modules +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/CampaignModuleDto.cs b/StackDuel.Application/Campaigns/Dtos/CampaignModuleDto.cs new file mode 100644 index 0000000..f0ed5c1 --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/CampaignModuleDto.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record CampaignModuleDto( + Guid Id, + string Title, + string Description, + int SortOrder, + IReadOnlyList Units +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/CampaignSummaryDto.cs b/StackDuel.Application/Campaigns/Dtos/CampaignSummaryDto.cs new file mode 100644 index 0000000..18f6db4 --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/CampaignSummaryDto.cs @@ -0,0 +1,17 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record CampaignSummaryDto( + Guid Id, + string Slug, + string Title, + string Description, + CampaignDifficulty Difficulty, + string? IconKey, + int SortOrder, + int ModuleCount, + int UnitCount, + int EstimatedMinutes, + IReadOnlyList PrerequisiteCampaignIds +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/CampaignUnitDto.cs b/StackDuel.Application/Campaigns/Dtos/CampaignUnitDto.cs new file mode 100644 index 0000000..49ff29d --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/CampaignUnitDto.cs @@ -0,0 +1,13 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record CampaignUnitDto( + Guid Id, + string Title, + string Content, + UnitType UnitType, + int SortOrder, + int EstimatedMinutes, + IReadOnlyList Problems +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/CampaignUnitProblemDto.cs b/StackDuel.Application/Campaigns/Dtos/CampaignUnitProblemDto.cs new file mode 100644 index 0000000..af72662 --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/CampaignUnitProblemDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record CampaignUnitProblemDto(Guid ProblemId, int SortOrder); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/UserCampaignProgressDto.cs b/StackDuel.Application/Campaigns/Dtos/UserCampaignProgressDto.cs new file mode 100644 index 0000000..d9f8935 --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/UserCampaignProgressDto.cs @@ -0,0 +1,12 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record UserCampaignProgressDto( + Guid CampaignId, + bool Enrolled, + EnrollmentStatus? Status, + int TotalUnits, + int CompletedUnits, + IReadOnlyList CompletedUnitIds +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/Dtos/UserLearningStatsDto.cs b/StackDuel.Application/Campaigns/Dtos/UserLearningStatsDto.cs new file mode 100644 index 0000000..4b4fd3e --- /dev/null +++ b/StackDuel.Application/Campaigns/Dtos/UserLearningStatsDto.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Application.Campaigns.Dtos; + +public sealed record UserLearningStatsDto( + int TotalXp, + int Level, + int XpIntoLevel, + int XpForNextLevel, + int CurrentStreakDays, + int CompletedCampaignsCount +); \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/ICampaignProgressReadRepository.cs b/StackDuel.Application/Campaigns/ICampaignProgressReadRepository.cs new file mode 100644 index 0000000..d6fd048 --- /dev/null +++ b/StackDuel.Application/Campaigns/ICampaignProgressReadRepository.cs @@ -0,0 +1,36 @@ +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns; + +public interface ICampaignProgressReadRepository +{ + Task FindEnrollmentAsync( + Guid userId, + Guid campaignId, + CancellationToken cancellationToken = default + ); + + Task> GetEnrollmentsForUserAsync( + Guid userId, + CancellationToken cancellationToken = default + ); + + Task> GetCompletedUnitIdsAsync( + Guid userId, + Guid campaignId, + CancellationToken cancellationToken = default + ); + + Task HasCompletedUnitAsync(Guid userId, Guid unitId, CancellationToken cancellationToken = default); + + Task> GetCompletedUnitTypesAsync( + Guid userId, + CancellationToken cancellationToken = default + ); + + Task> GetCompletionTimestampsAsync( + Guid userId, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/ICampaignReadRepository.cs b/StackDuel.Application/Campaigns/ICampaignReadRepository.cs new file mode 100644 index 0000000..cd68615 --- /dev/null +++ b/StackDuel.Application/Campaigns/ICampaignReadRepository.cs @@ -0,0 +1,25 @@ +using StackDuel.Application.Pagination; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Campaigns; + +public interface ICampaignReadRepository +{ + Task FindPublishedBySlugAsync(string slug, CancellationToken cancellationToken = default); + + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task FindByUnitIdAsync(Guid unitId, CancellationToken cancellationToken = default); + + Task SlugExistsAsync(string slug, CancellationToken cancellationToken = default); + + Task ExistsAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetPublishedPathAsync(CancellationToken cancellationToken = default); + + Task> GetAdminPagedAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Campaigns/UnitTypeXp.cs b/StackDuel.Application/Campaigns/UnitTypeXp.cs new file mode 100644 index 0000000..8d48010 --- /dev/null +++ b/StackDuel.Application/Campaigns/UnitTypeXp.cs @@ -0,0 +1,15 @@ +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Campaigns; + +internal static class UnitTypeXp +{ + public static int For(UnitType unitType) => + unitType switch + { + UnitType.Lesson => 10, + UnitType.Quiz => 15, + UnitType.Challenge => 25, + _ => 10, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Class1.cs b/StackDuel.Application/Class1.cs deleted file mode 100644 index 0d7893f..0000000 --- a/StackDuel.Application/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StackDuel.Application; - -public class Class1 -{ - -} \ No newline at end of file diff --git a/StackDuel.Application/Commands/AbstractCommandHandler.cs b/StackDuel.Application/Commands/AbstractCommandHandler.cs new file mode 100644 index 0000000..c6c88db --- /dev/null +++ b/StackDuel.Application/Commands/AbstractCommandHandler.cs @@ -0,0 +1,78 @@ +using Ardalis.Result; +using FluentValidation; + +namespace StackDuel.Application.Commands; + +public abstract class AbstractCommandHandler(IValidator? validator = null) + : ICommandHandler + where TCommand : ICommand +{ + private readonly IValidator? _validator = validator; + + public async Task> Handle(TCommand request, CancellationToken cancellationToken) + { + if (_validator is not null) + { + var validationResult = await _validator.ValidateAsync(request, cancellationToken); + if (!validationResult.IsValid) + { + var errors = validationResult + .Errors.Select(e => + { + string identifier = e.FormattedMessagePlaceholderValues.TryGetValue( + "PropertyName", + out object? displayName + ) + ? displayName?.ToString() ?? e.PropertyName + : e.PropertyName; + + return new ValidationError(identifier, e.ErrorMessage); + }) + .ToList(); + + return Result.Invalid(errors); + } + } + + return await HandleValidated(request, cancellationToken); + } + + protected abstract Task> HandleValidated(TCommand request, CancellationToken cancellationToken); +} + +public abstract class AbstractCommandHandler(IValidator? validator = null) + : ICommandHandler + where TCommand : ICommand +{ + private readonly IValidator? _validator = validator; + + public async Task Handle(TCommand request, CancellationToken cancellationToken) + { + if (_validator is not null) + { + var validationResult = await _validator.ValidateAsync(request, cancellationToken); + if (!validationResult.IsValid) + { + var errors = validationResult + .Errors.Select(e => + { + string identifier = e.FormattedMessagePlaceholderValues.TryGetValue( + "PropertyName", + out object? displayName + ) + ? displayName?.ToString() ?? e.PropertyName + : e.PropertyName; + + return new ValidationError(identifier, e.ErrorMessage); + }) + .ToList(); + + return Result.Invalid(errors); + } + } + + return await HandleValidated(request, cancellationToken); + } + + protected abstract Task HandleValidated(TCommand request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleCommand.cs b/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleCommand.cs new file mode 100644 index 0000000..9a5bd90 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.AddCampaignModule; + +public sealed record AddCampaignModuleCommand(Guid CampaignId, string Title, string Description) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleHandler.cs b/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleHandler.cs new file mode 100644 index 0000000..9acfb5b --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.AddCampaignModule; + +internal sealed class AddCampaignModuleHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + AddCampaignModuleCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + CampaignModule module = campaign.AddModule(request.Title, request.Description); + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(module.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleValidator.cs b/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleValidator.cs new file mode 100644 index 0000000..72524ec --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/AddCampaignModule/AddCampaignModuleValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.AddCampaignModule; + +internal sealed class AddCampaignModuleValidator : AbstractValidator +{ + public AddCampaignModuleValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).NotNull().MaximumLength(4000); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitCommand.cs b/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitCommand.cs new file mode 100644 index 0000000..100c695 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitCommand.cs @@ -0,0 +1,13 @@ +using StackDuel.Application.Commands; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Commands.Campaigns.AddCampaignUnit; + +public sealed record AddCampaignUnitCommand( + Guid CampaignId, + Guid ModuleId, + string Title, + string Content, + UnitType UnitType, + int EstimatedMinutes +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitHandler.cs b/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitHandler.cs new file mode 100644 index 0000000..08bf802 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitHandler.cs @@ -0,0 +1,34 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.AddCampaignUnit; + +internal sealed class AddCampaignUnitHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + AddCampaignUnitCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + CampaignModule? module = campaign.Modules.FirstOrDefault(m => m.Id == request.ModuleId); + + if (module is null) + return Result.NotFound(); + + CampaignUnit unit = module.AddUnit(request.Title, request.Content, request.UnitType, request.EstimatedMinutes); + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(unit.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitValidator.cs b/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitValidator.cs new file mode 100644 index 0000000..276afce --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/AddCampaignUnit/AddCampaignUnitValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.AddCampaignUnit; + +internal sealed class AddCampaignUnitValidator : AbstractValidator +{ + public AddCampaignUnitValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Content).NotNull().MaximumLength(20000); + RuleFor(x => x.UnitType).IsInEnum(); + RuleFor(x => x.EstimatedMinutes).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignCommand.cs b/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignCommand.cs new file mode 100644 index 0000000..cd28c1b --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.ArchiveCampaign; + +public sealed record ArchiveCampaignCommand(Guid CampaignId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignHandler.cs b/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignHandler.cs new file mode 100644 index 0000000..6fb1219 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignHandler.cs @@ -0,0 +1,36 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.ArchiveCampaign; + +internal sealed class ArchiveCampaignHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + ArchiveCampaignCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + try + { + campaign.Archive(); + } + catch (InvalidOperationException ex) + { + return Result.Invalid(new ValidationError(nameof(campaign.Status), ex.Message)); + } + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignValidator.cs b/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignValidator.cs new file mode 100644 index 0000000..5277825 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/ArchiveCampaign/ArchiveCampaignValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.ArchiveCampaign; + +internal sealed class ArchiveCampaignValidator : AbstractValidator +{ + public ArchiveCampaignValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitCommand.cs b/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitCommand.cs new file mode 100644 index 0000000..4d74b5a --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.CompleteUnit; + +public sealed record CompleteUnitCommand(Guid UnitId, Guid UserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitHandler.cs b/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitHandler.cs new file mode 100644 index 0000000..2d2fc59 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitHandler.cs @@ -0,0 +1,71 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Campaigns; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Commands.Campaigns.CompleteUnit; + +internal sealed class CompleteUnitHandler( + ICampaignReadRepository campaignReadRepository, + ICampaignProgressReadRepository campaignProgressReadRepository, + ICampaignEnrollmentWriteRepository campaignEnrollmentWriteRepository, + IUnitCompletionWriteRepository unitCompletionWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + CompleteUnitCommand request, + CancellationToken cancellationToken + ) + { + bool alreadyCompleted = await campaignProgressReadRepository.HasCompletedUnitAsync( + request.UserId, + request.UnitId, + cancellationToken + ); + + if (alreadyCompleted) + return Result.Success(); + + Campaign? campaign = await campaignReadRepository.FindByUnitIdAsync(request.UnitId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + UnitCompletion completion = new(request.UserId, request.UnitId); + await unitCompletionWriteRepository.AddAsync(completion, cancellationToken); + + int totalUnits = campaign.Modules.SelectMany(m => m.Units).Count(); + + IReadOnlyList completedUnitIds = await campaignProgressReadRepository.GetCompletedUnitIdsAsync( + request.UserId, + campaign.Id, + cancellationToken + ); + + if (totalUnits == 0 || completedUnitIds.Count < totalUnits) + return Result.Success(); + + CampaignEnrollment? enrollment = await campaignProgressReadRepository.FindEnrollmentAsync( + request.UserId, + campaign.Id, + cancellationToken + ); + + if (enrollment is null) + { + enrollment = new CampaignEnrollment(request.UserId, campaign.Id); + enrollment.Complete(); + await campaignEnrollmentWriteRepository.AddAsync(enrollment, cancellationToken); + } + else if (enrollment.Status != EnrollmentStatus.Completed) + { + enrollment.Complete(); + await campaignEnrollmentWriteRepository.SaveChangesAsync(enrollment, cancellationToken); + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitValidator.cs b/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitValidator.cs new file mode 100644 index 0000000..f0d49fe --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/CompleteUnit/CompleteUnitValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.CompleteUnit; + +internal sealed class CompleteUnitValidator : AbstractValidator +{ + public CompleteUnitValidator() + { + RuleFor(x => x.UnitId).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignCommand.cs b/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignCommand.cs new file mode 100644 index 0000000..93d9b93 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignCommand.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Commands; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Commands.Campaigns.CreateCampaign; + +public sealed record CreateCampaignCommand(string Title, string Description, CampaignDifficulty Difficulty) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignHandler.cs b/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignHandler.cs new file mode 100644 index 0000000..0cc22af --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignHandler.cs @@ -0,0 +1,62 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Campaigns; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using System.Text; + +namespace StackDuel.Application.Commands.Campaigns.CreateCampaign; + +internal sealed class CreateCampaignHandler( + ICampaignReadRepository campaignReadRepository, + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + CreateCampaignCommand request, + CancellationToken cancellationToken + ) + { + string baseSlug = Slugify(request.Title); + string slug = baseSlug; + int suffix = 2; + + while (await campaignReadRepository.SlugExistsAsync(slug, cancellationToken)) + { + slug = $"{baseSlug}-{suffix}"; + suffix++; + } + + Campaign campaign = new(slug, request.Title, request.Description, request.Difficulty); + + await campaignWriteRepository.AddAsync(campaign, cancellationToken); + + return Result.Success(campaign.Id); + } + + private static string Slugify(string title) + { + StringBuilder builder = new(); + bool lastWasHyphen = false; + + foreach (char c in title.Trim().ToLowerInvariant()) + { + if (char.IsLetterOrDigit(c)) + { + builder.Append(c); + lastWasHyphen = false; + } + else if (!lastWasHyphen && builder.Length > 0) + { + builder.Append('-'); + lastWasHyphen = true; + } + } + + if (lastWasHyphen && builder.Length > 0) + builder.Length--; + + return builder.Length > 0 ? builder.ToString() : "campaign"; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignValidator.cs b/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignValidator.cs new file mode 100644 index 0000000..a99f2a8 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/CreateCampaign/CreateCampaignValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.CreateCampaign; + +internal sealed class CreateCampaignValidator : AbstractValidator +{ + public CreateCampaignValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).NotNull().MaximumLength(4000); + RuleFor(x => x.Difficulty).IsInEnum(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignCommand.cs b/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignCommand.cs new file mode 100644 index 0000000..36a006f --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.EnrollInCampaign; + +public sealed record EnrollInCampaignCommand(Guid CampaignId, Guid UserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignHandler.cs b/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignHandler.cs new file mode 100644 index 0000000..867015c --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignHandler.cs @@ -0,0 +1,47 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Campaigns; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Commands.Campaigns.EnrollInCampaign; + +internal sealed class EnrollInCampaignHandler( + ICampaignReadRepository campaignReadRepository, + ICampaignProgressReadRepository campaignProgressReadRepository, + ICampaignEnrollmentWriteRepository campaignEnrollmentWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + EnrollInCampaignCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignReadRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + if (campaign.Status != CampaignStatus.Published) + return Result.Invalid( + new ValidationError(nameof(campaign.Status), "Only published campaigns can be enrolled in.") + ); + + CampaignEnrollment? existingEnrollment = await campaignProgressReadRepository.FindEnrollmentAsync( + request.UserId, + request.CampaignId, + cancellationToken + ); + + if (existingEnrollment is not null) + return Result.Success(existingEnrollment.Id); + + CampaignEnrollment enrollment = new(request.UserId, request.CampaignId); + + await campaignEnrollmentWriteRepository.AddAsync(enrollment, cancellationToken); + + return Result.Success(enrollment.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignValidator.cs b/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignValidator.cs new file mode 100644 index 0000000..5e3f500 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/EnrollInCampaign/EnrollInCampaignValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.EnrollInCampaign; + +internal sealed class EnrollInCampaignValidator : AbstractValidator +{ + public EnrollInCampaignValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignCommand.cs b/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignCommand.cs new file mode 100644 index 0000000..5468787 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.PublishCampaign; + +public sealed record PublishCampaignCommand(Guid CampaignId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignHandler.cs b/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignHandler.cs new file mode 100644 index 0000000..b084e67 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignHandler.cs @@ -0,0 +1,36 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.PublishCampaign; + +internal sealed class PublishCampaignHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + PublishCampaignCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + try + { + campaign.Publish(); + } + catch (InvalidOperationException ex) + { + return Result.Invalid(new ValidationError(nameof(campaign.Status), ex.Message)); + } + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignValidator.cs b/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignValidator.cs new file mode 100644 index 0000000..3635081 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/PublishCampaign/PublishCampaignValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.PublishCampaign; + +internal sealed class PublishCampaignValidator : AbstractValidator +{ + public PublishCampaignValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesCommand.cs b/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesCommand.cs new file mode 100644 index 0000000..9749e55 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesCommand.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.SetCampaignPrerequisites; + +public sealed record SetCampaignPrerequisitesCommand(Guid CampaignId, IReadOnlyList RequiredCampaignIds) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesHandler.cs b/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesHandler.cs new file mode 100644 index 0000000..01cd373 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesHandler.cs @@ -0,0 +1,50 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Campaigns; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.SetCampaignPrerequisites; + +internal sealed class SetCampaignPrerequisitesHandler( + ICampaignReadRepository campaignReadRepository, + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SetCampaignPrerequisitesCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + foreach (Guid requiredCampaignId in request.RequiredCampaignIds.Distinct()) + { + bool exists = await campaignReadRepository.ExistsAsync(requiredCampaignId, cancellationToken); + if (!exists) + return Result.Invalid( + new ValidationError( + nameof(request.RequiredCampaignIds), + $"Campaign '{requiredCampaignId}' was not found." + ) + ); + } + + try + { + campaign.SetPrerequisites(request.RequiredCampaignIds); + } + catch (ArgumentException ex) + { + return Result.Invalid(new ValidationError(nameof(request.RequiredCampaignIds), ex.Message)); + } + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesValidator.cs b/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesValidator.cs new file mode 100644 index 0000000..a47b25d --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/SetCampaignPrerequisites/SetCampaignPrerequisitesValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.SetCampaignPrerequisites; + +internal sealed class SetCampaignPrerequisitesValidator : AbstractValidator +{ + public SetCampaignPrerequisitesValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.RequiredCampaignIds).NotNull(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsCommand.cs b/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsCommand.cs new file mode 100644 index 0000000..a5bc65d --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsCommand.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.SetUnitProblems; + +public sealed record SetUnitProblemsCommand(Guid CampaignId, Guid ModuleId, Guid UnitId, IReadOnlyList ProblemIds) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsHandler.cs b/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsHandler.cs new file mode 100644 index 0000000..deebd6e --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsHandler.cs @@ -0,0 +1,50 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.SetUnitProblems; + +internal sealed class SetUnitProblemsHandler( + ICampaignWriteRepository campaignWriteRepository, + IProblemReadRepository problemReadRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SetUnitProblemsCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + CampaignModule? module = campaign.Modules.FirstOrDefault(m => m.Id == request.ModuleId); + + if (module is null) + return Result.NotFound(); + + CampaignUnit? unit = module.Units.FirstOrDefault(u => u.Id == request.UnitId); + + if (unit is null) + return Result.NotFound(); + + foreach (Guid problemId in request.ProblemIds.Distinct()) + { + bool exists = await problemReadRepository.ExistsForAdminAsync(problemId, cancellationToken); + if (!exists) + return Result.Invalid( + new ValidationError(nameof(request.ProblemIds), $"Problem '{problemId}' was not found.") + ); + } + + unit.SetProblems(request.ProblemIds); + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsValidator.cs b/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsValidator.cs new file mode 100644 index 0000000..7eaa6a4 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/SetUnitProblems/SetUnitProblemsValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.SetUnitProblems; + +internal sealed class SetUnitProblemsValidator : AbstractValidator +{ + public SetUnitProblemsValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.UnitId).NotEmpty(); + RuleFor(x => x.ProblemIds).NotNull(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsCommand.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsCommand.cs new file mode 100644 index 0000000..f90745c --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Commands; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignDetails; + +public sealed record UpdateCampaignDetailsCommand( + Guid CampaignId, + string Title, + string Description, + CampaignDifficulty Difficulty, + string? IconKey +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsHandler.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsHandler.cs new file mode 100644 index 0000000..64e6ba9 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignDetails; + +internal sealed class UpdateCampaignDetailsHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateCampaignDetailsCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + campaign.UpdateDetails(request.Title, request.Description, request.Difficulty, request.IconKey); + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsValidator.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsValidator.cs new file mode 100644 index 0000000..0220797 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignDetails/UpdateCampaignDetailsValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignDetails; + +internal sealed class UpdateCampaignDetailsValidator : AbstractValidator +{ + public UpdateCampaignDetailsValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).NotNull().MaximumLength(4000); + RuleFor(x => x.Difficulty).IsInEnum(); + RuleFor(x => x.IconKey).MaximumLength(200); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleCommand.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleCommand.cs new file mode 100644 index 0000000..bf5911b --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleCommand.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignModule; + +public sealed record UpdateCampaignModuleCommand(Guid CampaignId, Guid ModuleId, string Title, string Description) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleHandler.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleHandler.cs new file mode 100644 index 0000000..3b70b57 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleHandler.cs @@ -0,0 +1,34 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignModule; + +internal sealed class UpdateCampaignModuleHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateCampaignModuleCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + CampaignModule? module = campaign.Modules.FirstOrDefault(m => m.Id == request.ModuleId); + + if (module is null) + return Result.NotFound(); + + module.UpdateDetails(request.Title, request.Description); + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleValidator.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleValidator.cs new file mode 100644 index 0000000..07933cd --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignModule/UpdateCampaignModuleValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignModule; + +internal sealed class UpdateCampaignModuleValidator : AbstractValidator +{ + public UpdateCampaignModuleValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).NotNull().MaximumLength(4000); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitCommand.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitCommand.cs new file mode 100644 index 0000000..fc8587a --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitCommand.cs @@ -0,0 +1,14 @@ +using StackDuel.Application.Commands; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignUnit; + +public sealed record UpdateCampaignUnitCommand( + Guid CampaignId, + Guid ModuleId, + Guid UnitId, + string Title, + string Content, + UnitType UnitType, + int EstimatedMinutes +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitHandler.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitHandler.cs new file mode 100644 index 0000000..8e178a6 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitHandler.cs @@ -0,0 +1,39 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignUnit; + +internal sealed class UpdateCampaignUnitHandler( + ICampaignWriteRepository campaignWriteRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateCampaignUnitCommand request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignWriteRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + CampaignModule? module = campaign.Modules.FirstOrDefault(m => m.Id == request.ModuleId); + + if (module is null) + return Result.NotFound(); + + CampaignUnit? unit = module.Units.FirstOrDefault(u => u.Id == request.UnitId); + + if (unit is null) + return Result.NotFound(); + + unit.UpdateDetails(request.Title, request.Content, request.UnitType, request.EstimatedMinutes); + + await campaignWriteRepository.SaveChangesAsync(campaign, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitValidator.cs b/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitValidator.cs new file mode 100644 index 0000000..b377c99 --- /dev/null +++ b/StackDuel.Application/Commands/Campaigns/UpdateCampaignUnit/UpdateCampaignUnitValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Campaigns.UpdateCampaignUnit; + +internal sealed class UpdateCampaignUnitValidator : AbstractValidator +{ + public UpdateCampaignUnitValidator() + { + RuleFor(x => x.CampaignId).NotEmpty(); + RuleFor(x => x.ModuleId).NotEmpty(); + RuleFor(x => x.UnitId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Content).NotNull().MaximumLength(20000); + RuleFor(x => x.UnitType).IsInEnum(); + RuleFor(x => x.EstimatedMinutes).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeCommand.cs b/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeCommand.cs new file mode 100644 index 0000000..f5a8a94 --- /dev/null +++ b/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge; + +internal sealed record UpdateDailyChallengeCommand(DateOnly Date, Guid ProblemId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeHandler.cs b/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeHandler.cs new file mode 100644 index 0000000..0520cb4 --- /dev/null +++ b/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeHandler.cs @@ -0,0 +1,33 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.Problems; +using StackDuel.Domain.DailyChallenges.Entities; + +namespace StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge; + +internal sealed class UpdateDailyChallengeHandler( + IDailyChallengeRepository dailyChallengeRepository, + IProblemReadRepository problemReadRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateDailyChallengeCommand request, + CancellationToken cancellationToken + ) + { + DailyChallenge? challenge = await dailyChallengeRepository.FindByDateAsync(request.Date, cancellationToken); + if (challenge is null) + return Result.NotFound($"No daily challenge is scheduled for {request.Date}."); + + if (!await problemReadRepository.ExistsForAdminAsync(request.ProblemId, cancellationToken)) + return Result.Invalid(new ValidationError(nameof(request.ProblemId), "Problem was not found.")); + + challenge.UpdateProblem(request.ProblemId); + + await dailyChallengeRepository.UpdateAsync(challenge, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeValidator.cs b/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeValidator.cs new file mode 100644 index 0000000..75841ad --- /dev/null +++ b/StackDuel.Application/Commands/DailyChallenges/UpdateDailyChallenge/UpdateDailyChallengeValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge; + +internal sealed class UpdateDailyChallengeValidator : AbstractValidator +{ + public UpdateDailyChallengeValidator() + { + RuleFor(x => x.Date) + .Must(date => date > DateOnly.FromDateTime(DateTime.UtcNow)) + .WithMessage("Only challenges scheduled after today can be updated."); + + RuleFor(x => x.ProblemId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagCommand.cs b/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagCommand.cs new file mode 100644 index 0000000..044b0b0 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagCommand.cs @@ -0,0 +1,21 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.FeatureFlags.CreateFeatureFlag; + +internal sealed record CreateFeatureFlagCommand(string Key, string Name, string Description, bool DefaultEnabled) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "feature-flag.created"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => Key; + public object? AuditDetails => + new + { + Key, + Name, + Description, + DefaultEnabled, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagHandler.cs b/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagHandler.cs new file mode 100644 index 0000000..b50f8b7 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagHandler.cs @@ -0,0 +1,38 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Application.Commands.FeatureFlags.CreateFeatureFlag; + +internal sealed class CreateFeatureFlagHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + CreateFeatureFlagCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? existing = await writeRepository.FindByKeyAsync(request.Key, cancellationToken); + + if (existing is not null) + return Result.Invalid( + new ValidationError("Key", $"A feature flag with key '{request.Key}' already exists.") + ); + + FeatureFlag flag = FeatureFlag.Create( + new FeatureFlagKey(request.Key), + request.Name, + request.Description, + request.DefaultEnabled + ); + + await writeRepository.AddAsync(flag, cancellationToken); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(flag.Id.Value); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagValidator.cs b/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagValidator.cs new file mode 100644 index 0000000..6b492ff --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/CreateFeatureFlag/CreateFeatureFlagValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Application.Commands.FeatureFlags.CreateFeatureFlag; + +internal sealed class CreateFeatureFlagValidator : AbstractValidator +{ + public CreateFeatureFlagValidator() + { + RuleFor(x => x.Key) + .NotEmpty() + .MaximumLength(FeatureFlagKey.MaxLength) + .Matches("^[a-z0-9]+(-[a-z0-9]+)*$") + .WithMessage("Key must be lowercase kebab-case, e.g. 'leaderboards'."); + + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).NotNull().MaximumLength(1000); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideCommand.cs b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideCommand.cs new file mode 100644 index 0000000..09c8dc9 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagGroupOverride; + +internal sealed record RemoveFeatureFlagGroupOverrideCommand(Guid FlagId, Guid GroupId) : ICommand, IAuditableCommand +{ + public string AuditAction => "feature-flag.group-override-removed"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => FlagId.ToString(); + public object? AuditDetails => new { GroupId }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideHandler.cs b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideHandler.cs new file mode 100644 index 0000000..c75dffd --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; + +namespace StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagGroupOverride; + +internal sealed class RemoveFeatureFlagGroupOverrideHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + RemoveFeatureFlagGroupOverrideCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? flag = await writeRepository.FindByIdAsync(request.FlagId, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + flag.RemoveGroupOverride(new GroupId(request.GroupId)); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideValidator.cs b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideValidator.cs new file mode 100644 index 0000000..ff51289 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagGroupOverride/RemoveFeatureFlagGroupOverrideValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagGroupOverride; + +internal sealed class RemoveFeatureFlagGroupOverrideValidator : AbstractValidator +{ + public RemoveFeatureFlagGroupOverrideValidator() + { + RuleFor(x => x.FlagId).NotEmpty(); + RuleFor(x => x.GroupId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideCommand.cs b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideCommand.cs new file mode 100644 index 0000000..ad91910 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagUserOverride; + +internal sealed record RemoveFeatureFlagUserOverrideCommand(Guid FlagId, Guid UserId) : ICommand, IAuditableCommand +{ + public string AuditAction => "feature-flag.user-override-removed"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => FlagId.ToString(); + public object? AuditDetails => new { UserId }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideHandler.cs b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideHandler.cs new file mode 100644 index 0000000..51597d1 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; + +namespace StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagUserOverride; + +internal sealed class RemoveFeatureFlagUserOverrideHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + RemoveFeatureFlagUserOverrideCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? flag = await writeRepository.FindByIdAsync(request.FlagId, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + flag.RemoveUserOverride(new UserId(request.UserId)); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideValidator.cs b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideValidator.cs new file mode 100644 index 0000000..1f3403e --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/RemoveFeatureFlagUserOverride/RemoveFeatureFlagUserOverrideValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagUserOverride; + +internal sealed class RemoveFeatureFlagUserOverrideValidator : AbstractValidator +{ + public RemoveFeatureFlagUserOverrideValidator() + { + RuleFor(x => x.FlagId).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideCommand.cs b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideCommand.cs new file mode 100644 index 0000000..c529b65 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideCommand.cs @@ -0,0 +1,15 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagGroupOverride; + +internal sealed record SetFeatureFlagGroupOverrideCommand(Guid FlagId, Guid GroupId, DecisionEffect Effect) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "feature-flag.group-override-set"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => FlagId.ToString(); + public object? AuditDetails => new { GroupId, Effect }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideHandler.cs b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideHandler.cs new file mode 100644 index 0000000..6f5be28 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; + +namespace StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagGroupOverride; + +internal sealed class SetFeatureFlagGroupOverrideHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SetFeatureFlagGroupOverrideCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? flag = await writeRepository.FindByIdAsync(request.FlagId, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + flag.SetGroupOverride(new GroupId(request.GroupId), request.Effect); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideValidator.cs b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideValidator.cs new file mode 100644 index 0000000..4c91248 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagGroupOverride/SetFeatureFlagGroupOverrideValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagGroupOverride; + +internal sealed class SetFeatureFlagGroupOverrideValidator : AbstractValidator +{ + public SetFeatureFlagGroupOverrideValidator() + { + RuleFor(x => x.FlagId).NotEmpty(); + RuleFor(x => x.GroupId).NotEmpty(); + RuleFor(x => x.Effect).IsInEnum(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideCommand.cs b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideCommand.cs new file mode 100644 index 0000000..f7fcde2 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideCommand.cs @@ -0,0 +1,15 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagUserOverride; + +internal sealed record SetFeatureFlagUserOverrideCommand(Guid FlagId, Guid UserId, DecisionEffect Effect) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "feature-flag.user-override-set"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => FlagId.ToString(); + public object? AuditDetails => new { UserId, Effect }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideHandler.cs b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideHandler.cs new file mode 100644 index 0000000..12bb9c7 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; + +namespace StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagUserOverride; + +internal sealed class SetFeatureFlagUserOverrideHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SetFeatureFlagUserOverrideCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? flag = await writeRepository.FindByIdAsync(request.FlagId, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + flag.SetUserOverride(new UserId(request.UserId), request.Effect); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideValidator.cs b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideValidator.cs new file mode 100644 index 0000000..570f4f2 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/SetFeatureFlagUserOverride/SetFeatureFlagUserOverrideValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagUserOverride; + +internal sealed class SetFeatureFlagUserOverrideValidator : AbstractValidator +{ + public SetFeatureFlagUserOverrideValidator() + { + RuleFor(x => x.FlagId).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Effect).IsInEnum(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultCommand.cs b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultCommand.cs new file mode 100644 index 0000000..05d0c63 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagDefault; + +internal sealed record UpdateFeatureFlagDefaultCommand(Guid FlagId, bool DefaultEnabled) : ICommand, IAuditableCommand +{ + public string AuditAction => "feature-flag.default-updated"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => FlagId.ToString(); + public object? AuditDetails => new { DefaultEnabled }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultHandler.cs b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultHandler.cs new file mode 100644 index 0000000..5e16b1d --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultHandler.cs @@ -0,0 +1,28 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; + +namespace StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagDefault; + +internal sealed class UpdateFeatureFlagDefaultHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateFeatureFlagDefaultCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? flag = await writeRepository.FindByIdAsync(request.FlagId, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + flag.SetDefaultEnabled(request.DefaultEnabled); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultValidator.cs b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultValidator.cs new file mode 100644 index 0000000..5bd3056 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagDefault/UpdateFeatureFlagDefaultValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagDefault; + +internal sealed class UpdateFeatureFlagDefaultValidator : AbstractValidator +{ + public UpdateFeatureFlagDefaultValidator() + { + RuleFor(x => x.FlagId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutCommand.cs b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutCommand.cs new file mode 100644 index 0000000..2de4145 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagRollout; + +internal sealed record UpdateFeatureFlagRolloutCommand(Guid FlagId, int RolloutPercentage) : ICommand, IAuditableCommand +{ + public string AuditAction => "feature-flag.rollout-updated"; + public string? AuditTargetType => "feature-flag"; + public string? AuditTargetId => FlagId.ToString(); + public object? AuditDetails => new { RolloutPercentage }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutHandler.cs b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutHandler.cs new file mode 100644 index 0000000..a4909f1 --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagRollout; + +internal sealed class UpdateFeatureFlagRolloutHandler( + IValidator validator, + IFeatureFlagWriteRepository writeRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateFeatureFlagRolloutCommand request, + CancellationToken cancellationToken + ) + { + FeatureFlag? flag = await writeRepository.FindByIdAsync(request.FlagId, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + flag.SetRolloutPercentage(new RolloutPercentage(request.RolloutPercentage)); + await writeRepository.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutValidator.cs b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutValidator.cs new file mode 100644 index 0000000..f7e9f6e --- /dev/null +++ b/StackDuel.Application/Commands/FeatureFlags/UpdateFeatureFlagRollout/UpdateFeatureFlagRolloutValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagRollout; + +internal sealed class UpdateFeatureFlagRolloutValidator : AbstractValidator +{ + public UpdateFeatureFlagRolloutValidator() + { + RuleFor(x => x.FlagId).NotEmpty(); + RuleFor(x => x.RolloutPercentage).InclusiveBetween(0, 100); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackCommand.cs b/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackCommand.cs new file mode 100644 index 0000000..44352ec --- /dev/null +++ b/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackCommand.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Commands.Feedback.SubmitFeedback; + +internal sealed record SubmitFeedbackCommand( + Guid UserId, + FeedbackType Type, + string Message, + int? Rating, + FeedbackContextType ContextType, + Guid? ContextEntityId, + string? PageUrl, + string? UserAgent +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackHandler.cs b/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackHandler.cs new file mode 100644 index 0000000..08e08b5 --- /dev/null +++ b/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackHandler.cs @@ -0,0 +1,42 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Domain.Feedback; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.Factories; +using StackDuel.Domain.Feedback.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Application.Commands.Feedback.SubmitFeedback; + +internal sealed class SubmitFeedbackHandler( + IValidator validator, + IAggregateFactory feedbackFactory, + IFeedbackWriteRepository feedbackRepository, + IDomainEventDispatcher domainEventDispatcher +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + SubmitFeedbackCommand request, + CancellationToken cancellationToken + ) + { + var feedback = feedbackFactory.Create( + new CreateFeedbackSubmissionParams( + request.UserId, + request.Type, + new FeedbackMessage(request.Message), + request.Rating is int rating ? new FeedbackRating(rating) : null, + request.ContextType, + request.ContextEntityId, + request.PageUrl, + request.UserAgent + ) + ); + + await feedbackRepository.AddAsync(feedback, cancellationToken); + await domainEventDispatcher.DispatchAsync(feedback.PopDomainEvents(), cancellationToken); + + return Result.Success(feedback.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackValidator.cs b/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackValidator.cs new file mode 100644 index 0000000..d187921 --- /dev/null +++ b/StackDuel.Application/Commands/Feedback/SubmitFeedback/SubmitFeedbackValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.Feedback.ValueObjects; + +namespace StackDuel.Application.Commands.Feedback.SubmitFeedback; + +internal sealed class SubmitFeedbackValidator : AbstractValidator +{ + public SubmitFeedbackValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Message).NotEmpty().MaximumLength(FeedbackMessage.MaxLength); + + RuleFor(x => x.Rating) + .InclusiveBetween(FeedbackRating.MinValue, FeedbackRating.MaxValue) + .When(x => x.Rating.HasValue); + + RuleFor(x => x.ContextEntityId) + .NotEmpty() + .When(x => x.ContextType != FeedbackContextType.None) + .WithMessage("Context entity id is required when a context type is set."); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusCommand.cs b/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusCommand.cs new file mode 100644 index 0000000..9feca17 --- /dev/null +++ b/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusCommand.cs @@ -0,0 +1,14 @@ +using StackDuel.Application.Audit; +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Commands.Feedback.UpdateFeedbackStatus; + +internal sealed record UpdateFeedbackStatusCommand(Guid FeedbackId, FeedbackStatus Status, string? AdminNote) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "feedback.status-updated"; + public string? AuditTargetType => "feedback"; + public string? AuditTargetId => FeedbackId.ToString(); + public object? AuditDetails => new { Status, AdminNote }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusHandler.cs b/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusHandler.cs new file mode 100644 index 0000000..dcb1fc8 --- /dev/null +++ b/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusHandler.cs @@ -0,0 +1,28 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Feedback; +using StackDuel.Domain.Feedback.Entities; + +namespace StackDuel.Application.Commands.Feedback.UpdateFeedbackStatus; + +internal sealed class UpdateFeedbackStatusHandler( + IValidator validator, + IFeedbackWriteRepository feedbackRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateFeedbackStatusCommand request, + CancellationToken cancellationToken + ) + { + FeedbackSubmission? feedback = await feedbackRepository.FindByIdAsync(request.FeedbackId, cancellationToken); + + if (feedback is null) + return Result.NotFound(); + + feedback.UpdateStatus(request.Status, request.AdminNote); + await feedbackRepository.UpdateAsync(feedback, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusValidator.cs b/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusValidator.cs new file mode 100644 index 0000000..5294515 --- /dev/null +++ b/StackDuel.Application/Commands/Feedback/UpdateFeedbackStatus/UpdateFeedbackStatusValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Feedback.UpdateFeedbackStatus; + +internal sealed class UpdateFeedbackStatusValidator : AbstractValidator +{ + public UpdateFeedbackStatusValidator() + { + RuleFor(x => x.FeedbackId).NotEmpty(); + RuleFor(x => x.Status).IsInEnum(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyCommand.cs b/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyCommand.cs new file mode 100644 index 0000000..d1fd4ac --- /dev/null +++ b/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyCommand.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Commands.Games.CloseLobby; + +internal sealed record CloseLobbyCommand(Guid GameId, Guid RequestedByUserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyHandler.cs b/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyHandler.cs new file mode 100644 index 0000000..3650811 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyHandler.cs @@ -0,0 +1,47 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.CloseLobby; + +internal sealed class CloseLobbyHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IDomainEventDispatcher domainEventDispatcher, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + CloseLobbyCommand request, + CancellationToken cancellationToken + ) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + bool isHost = game.HostUserId == request.RequestedByUserId; + if (!isHost) + return Result.Forbidden(); + + if (game.Status != GameStatus.Pending) + return Result.Invalid( + new ValidationError( + nameof(game.Status), + "Only a pending lobby can be closed — once the game has started, forfeit instead." + ) + ); + + game.CloseLobby(); + + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyValidator.cs b/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyValidator.cs new file mode 100644 index 0000000..a41cbc4 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CloseLobby/CloseLobbyValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.CloseLobby; + +internal sealed class CloseLobbyValidator : AbstractValidator +{ + public CloseLobbyValidator() + { + RuleFor(x => x.GameId).NotEmpty(); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameCommand.cs b/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameCommand.cs new file mode 100644 index 0000000..5f08fac --- /dev/null +++ b/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameCommand.cs @@ -0,0 +1,22 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.CompleteExpiredGame; + +/// +/// Triggered by the GameTimeExpiredMessage consumer (or the sweep job) — not by a player +/// action, so there's no RequestedByUserId/permission check here. +/// +public sealed record CompleteExpiredGameCommand(Guid GameId, DateTime ExpectedStartedAt, int RescheduleCount) + : ICommand; + +public enum CompleteExpiredGameOutcome +{ + Completed, + NotYetExpired_Rescheduled, + NotYetExpired_RescheduleLimitReached_CompletedAnyway, + NotYetExpired_ImplausibleDelay_Dropped, + Stale_Dropped, + AlreadyFinalized_NoOp, +} + +public sealed record CompleteExpiredGameResult(CompleteExpiredGameOutcome Outcome, DateTime? RescheduleForUtc = null); \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameHandler.cs b/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameHandler.cs new file mode 100644 index 0000000..0003812 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameHandler.cs @@ -0,0 +1,118 @@ +using Ardalis.Result; +using FluentValidation; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.CompleteExpiredGame; + +internal sealed partial class CompleteExpiredGameHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IValidator validator, + IDomainEventDispatcher domainEventDispatcher, + ILogger logger +) : AbstractCommandHandler(validator) +{ + private static readonly TimeSpan MaxPlausibleDelay = TimeSpan.FromHours(6); + + protected override async Task> HandleValidated( + CompleteExpiredGameCommand request, + CancellationToken cancellationToken + ) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.Success( + new CompleteExpiredGameResult(CompleteExpiredGameOutcome.Stale_Dropped) + ); + + if (game.Status != GameStatus.Running) + { + LogAlreadyFinalized(game.Id, game.Status); + return Result.Success( + new CompleteExpiredGameResult(CompleteExpiredGameOutcome.AlreadyFinalized_NoOp) + ); + } + + if (game.StartedAt is null || game.StartedAt.Value != request.ExpectedStartedAt) + { + LogStale(game.Id, request.ExpectedStartedAt, game.StartedAt); + return Result.Success( + new CompleteExpiredGameResult(CompleteExpiredGameOutcome.Stale_Dropped) + ); + } + + DateTime actualEndTimeUtc = game.StartedAt.Value.AddSeconds(game.TimeLimitInSeconds); + DateTime nowUtc = DateTime.UtcNow; + + if (nowUtc < actualEndTimeUtc) + { + TimeSpan remaining = actualEndTimeUtc - nowUtc; + + if (request.RescheduleCount >= Messaging.Messages.GameTimeExpiredMessage.MaxReschedules) + { + LogRescheduleLimitReached(game.Id, request.RescheduleCount); + game.Complete(); + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + return Result.Success( + new CompleteExpiredGameResult( + CompleteExpiredGameOutcome.NotYetExpired_RescheduleLimitReached_CompletedAnyway + ) + ); + } + + if (remaining > MaxPlausibleDelay) + { + LogImplausibleDelay(game.Id, remaining); + return Result.Success( + new CompleteExpiredGameResult(CompleteExpiredGameOutcome.NotYetExpired_ImplausibleDelay_Dropped) + ); + } + + return Result.Success( + new CompleteExpiredGameResult( + CompleteExpiredGameOutcome.NotYetExpired_Rescheduled, + RescheduleForUtc: actualEndTimeUtc + ) + ); + } + + game.Complete(); + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success( + new CompleteExpiredGameResult(CompleteExpiredGameOutcome.Completed) + ); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Game {GameId} already {Status}; GameTimeExpiredMessage is a no-op." + )] + private partial void LogAlreadyFinalized(Guid gameId, GameStatus status); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Game {GameId} StartedAt has changed since this message was scheduled (expected {ExpectedStartedAt}, actual {ActualStartedAt}); dropping stale message." + )] + private partial void LogStale(Guid gameId, DateTime expectedStartedAt, DateTime? actualStartedAt); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Game {GameId} reached reschedule limit ({RescheduleCount}); completing anyway instead of rescheduling again." + )] + private partial void LogRescheduleLimitReached(Guid gameId, int rescheduleCount); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Game {GameId} implausible remaining delay ({Remaining}) — StartedAt/TimeLimitInSeconds may be corrupted. Refusing to reschedule." + )] + private partial void LogImplausibleDelay(Guid gameId, TimeSpan remaining); +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameValidator.cs b/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameValidator.cs new file mode 100644 index 0000000..1afe0e9 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CompleteExpiredGame/CompleteExpiredGameValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.CompleteExpiredGame; + +internal sealed class CompleteExpiredGameValidator : AbstractValidator +{ + public CompleteExpiredGameValidator() + { + RuleFor(x => x.GameId).NotEmpty().WithMessage("Game ID is required."); + + RuleFor(x => x.ExpectedStartedAt).NotEqual(default(DateTime)).WithMessage("ExpectedStartedAt is required."); + + RuleFor(x => x.RescheduleCount).GreaterThanOrEqualTo(0).WithMessage("RescheduleCount cannot be negative."); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemCommand.cs b/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemCommand.cs new file mode 100644 index 0000000..ff07c67 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemCommand.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.CompleteProblem; + +public sealed record CompleteProblemResultDto(int NewScore, Guid? NextProblemId); + +internal sealed record CompleteProblemCommand(Guid GameId, Guid ProblemId, Guid SubmissionId, Guid RequestedByUserId) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemHandler.cs b/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemHandler.cs new file mode 100644 index 0000000..d88bfdb --- /dev/null +++ b/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemHandler.cs @@ -0,0 +1,146 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Commands.Games.CompleteProblem; + +internal sealed class CompleteProblemHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IDomainEventDispatcher domainEventDispatcher, + IGameProblemAdvancer gameProblemAdvancer, + ISubmissionWriteRepository submissionRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + CompleteProblemCommand request, + CancellationToken cancellationToken + ) + { + // Retried once: GetOrGenerateProblemAsync (via gameProblemAdvancer) can race another + // participant who's also first to reach this position (see GameProblemConfiguration's + // (game_id, position) unique index). The loser gets GameProblemPositionConflictException + // out of SaveChangesAsync — re-fetching from scratch picks up the winner's already-persisted + // problem via Game.ProblemIdAtPosition instead of generating a conflicting second one. + int saveAttempt = 0; + while (true) + { + var game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + var attempt = game.EvaluateProblemAttempt( + request.RequestedByUserId, + request.ProblemId, + ParticipantAction.CompleteProblem, + DateTime.UtcNow + ); + + await PersistExpiryIfNeededAsync(game, attempt, cancellationToken); + + if (!attempt.IsEligible) + return GameProblemAttemptResult.ToFailure( + attempt, + ParticipantAction.CompleteProblem + ); + + var participant = attempt.Participant!; + + var (submissionFailure, _) = await ValidateSubmissionAsync(participant, request, cancellationToken); + if (submissionFailure is not null) + return submissionFailure; + + // Resolved before any state mutation: a game mode that no longer exists is a genuine + // system error and should bail out cleanly, not after the participant's score has + // already been bumped in memory for a solve that then can't be persisted. + var gameMode = await gameReadRepository.FindGameModeByIdAsync(game.GameModeId, cancellationToken); + if (gameMode is null) + return Result.NotFound("Game references a mode that no longer exists."); + + game.RecordProblemSolved(request.RequestedByUserId); + + Guid? nextProblemId = await gameProblemAdvancer.AdvanceAsync( + game, + participant, + gameMode.Key, + onAdvance: participant.AdvanceProblem, + onFinish: null, + cancellationToken + ); + + try + { + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + } + catch (GameProblemPositionConflictException) when (saveAttempt == 0) + { + saveAttempt++; + continue; + } + + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success( + new CompleteProblemResultDto(participant.Score, nextProblemId) + ); + } + } + + // The expiry check inside EvaluateProblemAttempt may have just completed the game — that + // must be persisted regardless of whether this request itself goes on to succeed. + private async Task PersistExpiryIfNeededAsync( + Game game, + GameProblemAttempt attempt, + CancellationToken cancellationToken + ) + { + if (!attempt.GameJustExpired) + return; + + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + } + + // Verifies the submission was created through the game's own submit endpoint, belongs to the + // requester, and was accepted. Returns the failing Result on the left when invalid, or the + // validated submission on the right when it's safe to proceed. + private async Task<(Result? Failure, Submission? Submission)> ValidateSubmissionAsync( + GameParticipant participant, + CompleteProblemCommand request, + CancellationToken cancellationToken + ) + { + if (!participant.ProblemSession!.HasActiveSubmission(request.SubmissionId)) + return ( + Result.Invalid( + new ValidationError(nameof(request.SubmissionId), "Submission was not created through the game.") + ), + null + ); + + var submission = await submissionRepository.FindByIdAsync(request.SubmissionId, cancellationToken); + if (submission is null || submission.UserId != request.RequestedByUserId) + return (Result.NotFound("Submission not found."), null); + + if (submission.Status != SubmissionStatus.Accepted) + return ( + Result.Invalid( + new ValidationError( + nameof(request.SubmissionId), + "Only an accepted submission can be used to complete a problem." + ) + ), + null + ); + + return (null, submission); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemValidator.cs b/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemValidator.cs new file mode 100644 index 0000000..36dde70 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CompleteProblem/CompleteProblemValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.CompleteProblem; + +internal sealed class CompleteProblemValidator : AbstractValidator +{ + public CompleteProblemValidator() + { + RuleFor(x => x.GameId).NotEmpty().WithMessage("Game ID is required."); + RuleFor(x => x.ProblemId).NotEmpty().WithMessage("Problem ID is required."); + RuleFor(x => x.SubmissionId).NotEmpty().WithMessage("Submission ID is required."); + RuleFor(x => x.RequestedByUserId).NotEmpty().WithMessage("User ID is required."); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CreateGame/CreateGameCommand.cs b/StackDuel.Application/Commands/Games/CreateGame/CreateGameCommand.cs new file mode 100644 index 0000000..033766f --- /dev/null +++ b/StackDuel.Application/Commands/Games/CreateGame/CreateGameCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using StackDuel.Application.Commands; +using StackDuel.Application.Games; + +namespace StackDuel.Application.Commands.Games.CreateGame; + +internal sealed record CreateGameCommand( + string GameModeKey, + IReadOnlyList TrackSelections, + int TimeLimitInSeconds, + Guid CreatedByUserId, + bool SkipsEnabled = true +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CreateGame/CreateGameHandler.cs b/StackDuel.Application/Commands/Games/CreateGame/CreateGameHandler.cs new file mode 100644 index 0000000..d07d332 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CreateGame/CreateGameHandler.cs @@ -0,0 +1,113 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Games; +using StackDuel.Application.Languages; +using StackDuel.Application.Tracks; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Commands.Games.CreateGame; + +internal sealed class CreateGameHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + ITrackReadRepository trackReadRepository, + ILanguageReadRepository languageReadRepository, + UserContext userContext, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + CreateGameCommand request, + CancellationToken cancellationToken + ) + { + GameMode? gameMode = await gameReadRepository.FindGameModeByKeyAsync(request.GameModeKey, cancellationToken); + + if (gameMode is null) + return Result.NotFound($"Game mode '{request.GameModeKey}' was not found or is not active."); + + string[] requestedTrackKeys = [.. request.TrackSelections.Select(s => s.TrackKey).Distinct()]; + IReadOnlyList tracks = await trackReadRepository.FindByKeysAsync(requestedTrackKeys, cancellationToken); + + if (tracks.Count != requestedTrackKeys.Length) + { + string[] missingKeys = requestedTrackKeys.Except(tracks.Select(t => t.Key)).ToArray(); + return Result.NotFound( + $"Track(s) '{string.Join(", ", missingKeys)}' were not found or are not active." + ); + } + + Dictionary trackByKey = tracks.ToDictionary(t => t.Key); + + IReadOnlyList availableLanguages = await languageReadRepository.GetActiveLanguagesByTrackIdsAsync( + tracks.Select(t => t.Id), + cancellationToken + ); + ILookup languagesByTrackId = availableLanguages.ToLookup(l => l.TrackId); + + var trackLanguageIds = new Dictionary>(); + + foreach (TrackLanguageSelection selection in request.TrackSelections) + { + Track track = trackByKey[selection.TrackKey]; + Guid[] requestedLanguageIds = [.. selection.LanguageIds.Distinct()]; + HashSet validLanguageIds = [.. languagesByTrackId[track.Id].Select(l => l.Id)]; + + Guid[] invalidLanguageIds = [.. requestedLanguageIds.Where(id => !validLanguageIds.Contains(id))]; + if (invalidLanguageIds.Length > 0) + { + return Result.NotFound( + $"Language(s) '{string.Join(", ", invalidLanguageIds)}' were not found or are not active for track '{track.Key}'." + ); + } + + // Tracks that disallow language selection don't offer a picker client-side — the UI + // always submits every active language for them. Reject a partial set explicitly + // rather than silently honoring it, so a client bypassing the UI can't narrow a + // player's language options on a track that isn't supposed to allow that choice. + if (!track.AllowsLanguageSelection && requestedLanguageIds.Length != validLanguageIds.Count) + { + return Result.Invalid( + new ValidationError( + nameof(request.TrackSelections), + $"Track '{track.Key}' does not allow selecting individual languages; all active languages must be included." + ) + ); + } + + trackLanguageIds[track.Id] = requestedLanguageIds; + } + + string? requiredPermission = GameModePermissions.For(gameMode.Key); + bool hasAccess = requiredPermission is not null && userContext.HasPermission(requiredPermission); + + if (!hasAccess) + return Result.Forbidden(); + + bool validDuration = gameMode.TimeOptions.Any(o => o.DurationSeconds == request.TimeLimitInSeconds); + if (!validDuration) + return Result.Invalid( + new ValidationError( + nameof(request.TimeLimitInSeconds), + $"'{request.TimeLimitInSeconds}' is not a valid time option for this game mode." + ) + ); + + Game game = new( + gameMode.Id, + gameMode.DefaultPoolId, + tracks.Select(t => t.Id), + [request.CreatedByUserId], + request.TimeLimitInSeconds, + trackLanguageIds: trackLanguageIds, + skipsEnabled: request.SkipsEnabled + ); + + await gameWriteRepository.AddAsync(game, cancellationToken); + + return Result.Success(game.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/CreateGame/CreateGameValidator.cs b/StackDuel.Application/Commands/Games/CreateGame/CreateGameValidator.cs new file mode 100644 index 0000000..5b83b72 --- /dev/null +++ b/StackDuel.Application/Commands/Games/CreateGame/CreateGameValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.CreateGame; + +internal sealed class CreateGameValidator : AbstractValidator +{ + public CreateGameValidator() + { + RuleFor(x => x.GameModeKey).NotEmpty(); + RuleFor(x => x.TrackSelections).NotEmpty(); + RuleForEach(x => x.TrackSelections) + .ChildRules(selection => + { + selection.RuleFor(s => s.TrackKey).NotEmpty(); + selection.RuleFor(s => s.LanguageIds).NotEmpty(); + }); + RuleFor(x => x.TimeLimitInSeconds).GreaterThan(0); + RuleFor(x => x.CreatedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameCommand.cs b/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameCommand.cs new file mode 100644 index 0000000..9d84d5b --- /dev/null +++ b/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameCommand.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Commands.Games.ForfeitGame; + +internal sealed record ForfeitGameCommand(Guid GameId, Guid RequestedByUserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameHandler.cs b/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameHandler.cs new file mode 100644 index 0000000..bb9d4db --- /dev/null +++ b/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameHandler.cs @@ -0,0 +1,59 @@ +using Ardalis.Result; +using FluentValidation; +using MediatR; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.ForfeitGame; + +internal sealed class ForfeitGameHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IGameExpiryCanceller gameExpiryCanceller, + IValidator validator, + IDomainEventDispatcher domainEventDispatcher +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + ForfeitGameCommand request, + CancellationToken cancellationToken + ) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + GameParticipant? participant = game.Participants.FirstOrDefault(p => p.UserId == request.RequestedByUserId); + if (participant is null) + return Result.Forbidden(); + + if (game.Status != GameStatus.Running) + return Result.Invalid(new ValidationError(nameof(game.Status), "Only running games can be forfeited.")); + + if (!participant.CanPerform(ParticipantAction.Forfeit)) + return Result.Invalid( + new ValidationError( + nameof(request.RequestedByUserId), + participant.HasForfeited + ? "You have already forfeited this game." + : "You have already completed all available problems." + ) + ); + + game.Forfeit(request.RequestedByUserId); + + // Only the actual game-ending case (every participant has now stopped playing) needs the + // scheduled expiry cancelled — while other participants are still playing, the timer is + // still needed to end the game normally for them when time runs out. + await gameExpiryCanceller.CancelIfScheduledAsync(game, cancellationToken); + + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameValidator.cs b/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameValidator.cs new file mode 100644 index 0000000..3ebb7e0 --- /dev/null +++ b/StackDuel.Application/Commands/Games/ForfeitGame/ForfeitGameValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.ForfeitGame; + +internal sealed class ForfeitGameValidator : AbstractValidator +{ + public ForfeitGameValidator() + { + RuleFor(x => x.GameId).NotEmpty(); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/JoinGame/JoinGameCommand.cs b/StackDuel.Application/Commands/Games/JoinGame/JoinGameCommand.cs new file mode 100644 index 0000000..8aecdd4 --- /dev/null +++ b/StackDuel.Application/Commands/Games/JoinGame/JoinGameCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.JoinGame; + +internal sealed record JoinGameCommand(Guid GameId, Guid RequestedByUserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/JoinGame/JoinGameHandler.cs b/StackDuel.Application/Commands/Games/JoinGame/JoinGameHandler.cs new file mode 100644 index 0000000..f813cdf --- /dev/null +++ b/StackDuel.Application/Commands/Games/JoinGame/JoinGameHandler.cs @@ -0,0 +1,54 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.JoinGame; + +internal sealed class JoinGameHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + UserContext userContext, + IDomainEventDispatcher domainEventDispatcher, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated(JoinGameCommand request, CancellationToken cancellationToken) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + if (game.Status != GameStatus.Pending) + return Result.Invalid(new ValidationError(nameof(game.Status), "Only pending games can be joined.")); + + GameMode? gameMode = await gameReadRepository.FindGameModeByIdAsync(game.GameModeId, cancellationToken); + if (gameMode is null) + return Result.NotFound($"Game mode '{game.GameModeId}' was not found."); + + string? requiredPermission = GameModePermissions.For(gameMode.Key); + bool hasAccess = requiredPermission is not null && userContext.HasPermission(requiredPermission); + + if (!hasAccess) + return Result.Forbidden(); + + if (game.Participants.Any(p => p.UserId == request.RequestedByUserId)) + return Result.Invalid( + new ValidationError(nameof(request.RequestedByUserId), "You have already joined this game.") + ); + + if (game.Participants.Count >= gameMode.MaxPlayers) + return Result.Invalid(new ValidationError(nameof(gameMode.MaxPlayers), "This game is already full.")); + + game.Join(request.RequestedByUserId, gameMode.MaxPlayers); + + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/JoinGame/JoinGameValidator.cs b/StackDuel.Application/Commands/Games/JoinGame/JoinGameValidator.cs new file mode 100644 index 0000000..843fd30 --- /dev/null +++ b/StackDuel.Application/Commands/Games/JoinGame/JoinGameValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.JoinGame; + +internal sealed class JoinGameValidator : AbstractValidator +{ + public JoinGameValidator() + { + RuleFor(x => x.GameId).NotEmpty(); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeCommand.cs b/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeCommand.cs new file mode 100644 index 0000000..f04b0f8 --- /dev/null +++ b/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.JoinGameByCode; + +internal sealed record JoinGameByCodeCommand(string JoinCode, Guid RequestedByUserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeHandler.cs b/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeHandler.cs new file mode 100644 index 0000000..dfd1ef2 --- /dev/null +++ b/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeHandler.cs @@ -0,0 +1,48 @@ +using Ardalis.Result; +using FluentValidation; +using MediatR; +using StackDuel.Application.Commands.Games.JoinGame; +using StackDuel.Application.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.JoinGameByCode; + +internal sealed class JoinGameByCodeHandler( + IGameReadRepository gameReadRepository, + IMediator mediator, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + JoinGameByCodeCommand request, + CancellationToken cancellationToken + ) + { + Game? game = await gameReadRepository.FindGameByJoinCodeAsync(request.JoinCode, cancellationToken); + + if (game is null) + return Result.NotFound("Invalid or expired code."); + + // Already in this game (e.g. the host re-using their own invite, or someone + // revisiting a link they already joined from) — send them back in rather than + // erroring, regardless of the game's current status. + if (game.Participants.Any(p => p.UserId == request.RequestedByUserId)) + return Result.Success(game.Id); + + if (game.Status != GameStatus.Pending) + return Result.Invalid( + new ValidationError(nameof(game.Status), "This game is no longer accepting new players.") + ); + + Result joinResult = await mediator.Send( + new JoinGameCommand(game.Id, request.RequestedByUserId), + cancellationToken + ); + + if (!joinResult.IsSuccess) + return joinResult; + + return Result.Success(game.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeValidator.cs b/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeValidator.cs new file mode 100644 index 0000000..33d928a --- /dev/null +++ b/StackDuel.Application/Commands/Games/JoinGameByCode/JoinGameByCodeValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.JoinGameByCode; + +internal sealed class JoinGameByCodeValidator : AbstractValidator +{ + public JoinGameByCodeValidator() + { + RuleFor(x => x.JoinCode).NotEmpty().Length(7); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameCommand.cs b/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameCommand.cs new file mode 100644 index 0000000..3de56b6 --- /dev/null +++ b/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameCommand.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Commands.Games.LeaveGame; + +internal sealed record LeaveGameCommand(Guid GameId, Guid RequestedByUserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameHandler.cs b/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameHandler.cs new file mode 100644 index 0000000..1401d37 --- /dev/null +++ b/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameHandler.cs @@ -0,0 +1,44 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.LeaveGame; + +internal sealed class LeaveGameHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IDomainEventDispatcher domainEventDispatcher, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated(LeaveGameCommand request, CancellationToken cancellationToken) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + bool isParticipant = game.Participants.Any(p => p.UserId == request.RequestedByUserId); + if (!isParticipant) + return Result.Forbidden(); + + if (game.Status != GameStatus.Pending) + return Result.Invalid( + new ValidationError( + nameof(game.Status), + "Only a pending lobby can be left — once a game has started, forfeit instead." + ) + ); + + game.Leave(request.RequestedByUserId); + + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameValidator.cs b/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameValidator.cs new file mode 100644 index 0000000..13fc561 --- /dev/null +++ b/StackDuel.Application/Commands/Games/LeaveGame/LeaveGameValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.LeaveGame; + +internal sealed class LeaveGameValidator : AbstractValidator +{ + public LeaveGameValidator() + { + RuleFor(x => x.GameId).NotEmpty(); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemCommand.cs b/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemCommand.cs new file mode 100644 index 0000000..6d762da --- /dev/null +++ b/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemCommand.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.SkipProblem; + +public sealed record SkipProblemResultDto(int SkipsRemaining, Guid? NextProblemId); + +internal sealed record SkipProblemCommand(Guid GameId, Guid ProblemId, Guid RequestedByUserId) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemHandler.cs b/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemHandler.cs new file mode 100644 index 0000000..b422ef8 --- /dev/null +++ b/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemHandler.cs @@ -0,0 +1,95 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.SkipProblem; + +internal sealed class SkipProblemHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IDomainEventDispatcher domainEventDispatcher, + IGameProblemAdvancer gameProblemAdvancer, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + SkipProblemCommand request, + CancellationToken cancellationToken + ) + { + // Retried once: GetOrGenerateProblemAsync (via gameProblemAdvancer) can race another + // participant who's also first to reach this position (see GameProblemConfiguration's + // (game_id, position) unique index). The loser gets GameProblemPositionConflictException + // out of SaveChangesAsync — re-fetching from scratch picks up the winner's already-persisted + // problem via Game.ProblemIdAtPosition instead of generating a conflicting second one. + int saveAttempt = 0; + while (true) + { + var game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + var attempt = game.EvaluateProblemAttempt( + request.RequestedByUserId, + request.ProblemId, + ParticipantAction.SkipProblem, + DateTime.UtcNow + ); + + // The expiry check inside EvaluateProblemAttempt may have just completed the game — + // that must be persisted regardless of whether this request itself goes on to succeed. + if (attempt.GameJustExpired) + { + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + } + + if (!attempt.IsEligible) + return GameProblemAttemptResult.ToFailure(attempt, ParticipantAction.SkipProblem); + + var participant = attempt.Participant!; + + if (!game.SkipsEnabled) + return Result.Invalid( + new ValidationError(nameof(game.SkipsEnabled), "Skips are not enabled for this game.") + ); + + if (participant.SkipsRemaining <= 0) + return Result.Invalid( + new ValidationError(nameof(participant.SkipsRemaining), "No skips remaining.") + ); + + var gameMode = await gameReadRepository.FindGameModeByIdAsync(game.GameModeId, cancellationToken); + if (gameMode is null) + return Result.NotFound("Game references a mode that no longer exists."); + + Guid? nextProblemId = await gameProblemAdvancer.AdvanceAsync( + game, + participant, + gameMode.Key, + onAdvance: participant.SkipToProblem, + onFinish: participant.UseSkip, + cancellationToken + ); + + try + { + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + } + catch (GameProblemPositionConflictException) when (saveAttempt == 0) + { + saveAttempt++; + continue; + } + + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success( + new SkipProblemResultDto(participant.SkipsRemaining, nextProblemId) + ); + } + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemValidator.cs b/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemValidator.cs new file mode 100644 index 0000000..456e10e --- /dev/null +++ b/StackDuel.Application/Commands/Games/SkipProblem/SkipProblemValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.SkipProblem; + +internal sealed class SkipProblemValidator : AbstractValidator +{ + public SkipProblemValidator() + { + RuleFor(x => x.GameId).NotEmpty().WithMessage("Game ID is required."); + RuleFor(x => x.ProblemId).NotEmpty().WithMessage("Problem ID is required."); + RuleFor(x => x.RequestedByUserId).NotEmpty().WithMessage("User ID is required."); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/StartGame/StartGameCommand.cs b/StackDuel.Application/Commands/Games/StartGame/StartGameCommand.cs new file mode 100644 index 0000000..900c2a5 --- /dev/null +++ b/StackDuel.Application/Commands/Games/StartGame/StartGameCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.StartGame; + +internal sealed record StartGameCommand(Guid GameId, Guid RequestedByUserId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/StartGame/StartGameHandler.cs b/StackDuel.Application/Commands/Games/StartGame/StartGameHandler.cs new file mode 100644 index 0000000..3846fe5 --- /dev/null +++ b/StackDuel.Application/Commands/Games/StartGame/StartGameHandler.cs @@ -0,0 +1,76 @@ +using Ardalis.Result; +using FluentValidation; +using MediatR; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Commands.Games.StartGame; + +internal sealed class StartGameHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IProblemSelectionStrategyResolver problemSelectionStrategyResolver, + IGameProblemSequencer gameProblemSequencer, + IDomainEventDispatcher domainEventDispatcher, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated(StartGameCommand request, CancellationToken cancellationToken) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + bool isHost = game.HostUserId == request.RequestedByUserId; + if (!isHost) + return Result.Forbidden(); + + if (game.Status != GameStatus.Pending) + return Result.Invalid(new ValidationError(nameof(game.Status), "Only pending games can be started.")); + + var gameMode = await gameReadRepository.FindGameModeByIdAsync(game.GameModeId, cancellationToken); + if (gameMode is null) + return Result.NotFound($"Game mode '{game.GameModeId}' was not found."); + + if (game.Participants.Count < gameMode.MinPlayers) + return Result.Invalid( + new ValidationError( + nameof(gameMode.MinPlayers), + $"At least {gameMode.MinPlayers} players are required to start this game." + ) + ); + + game.Start(GameModeStartCountdown.SecondsFor(gameMode.Key)); + + var problemSelectionStrategy = problemSelectionStrategyResolver.Resolve(gameMode.Key); + if (problemSelectionStrategy is null) + return Result.NotFound($"Problem selection strategy for '{gameMode.Key}' was not found."); + + Guid? initialProblemId = await gameProblemSequencer.GetOrGenerateProblemAsync( + game, + 0, + gameMode.Key, + problemSelectionStrategy, + cancellationToken + ); + + if (initialProblemId is null) + return Result.Invalid( + new ValidationError(nameof(initialProblemId), "Could not select an initial problem.") + ); + + foreach (var participant in game.Participants) + { + participant.InitializeProblemSession(initialProblemId.Value); + } + + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/StartGame/StartGameValidator.cs b/StackDuel.Application/Commands/Games/StartGame/StartGameValidator.cs new file mode 100644 index 0000000..4fde1bd --- /dev/null +++ b/StackDuel.Application/Commands/Games/StartGame/StartGameValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.StartGame; + +internal sealed class StartGameValidator : AbstractValidator +{ + public StartGameValidator() + { + RuleFor(x => x.GameId).NotEmpty(); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemCommand.cs b/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemCommand.cs new file mode 100644 index 0000000..be26508 --- /dev/null +++ b/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemCommand.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Games.SubmitGameProblem; + +internal sealed record SubmitGameProblemCommand( + Guid GameId, + Guid ProblemId, + Guid ProblemSetupId, + string Code, + Guid RequestedByUserId +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemHandler.cs b/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemHandler.cs new file mode 100644 index 0000000..bbe5887 --- /dev/null +++ b/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemHandler.cs @@ -0,0 +1,90 @@ +using Ardalis.Result; +using FluentValidation; +using MediatR; +using StackDuel.Application.Commands.Submissions.CreateSubmission; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Commands.Games.SubmitGameProblem; + +internal sealed class SubmitGameProblemHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IDomainEventDispatcher domainEventDispatcher, + IMediator mediator, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + SubmitGameProblemCommand request, + CancellationToken cancellationToken + ) + { + var game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + if (game is null) + return Result.NotFound($"Game '{request.GameId}' was not found."); + + var participant = game.Participants.FirstOrDefault(p => p.UserId == request.RequestedByUserId); + if (participant is null) + return Result.Forbidden(); + + if (game.CompleteIfExpired(DateTime.UtcNow)) + { + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + } + + if (game.Status != GameStatus.Running) + return Result.Invalid( + new ValidationError(nameof(game.Status), "Only running games can accept submissions.") + ); + + if (participant.HasStoppedPlaying) + return Result.Invalid( + new ValidationError( + nameof(participant.HasStoppedPlaying), + participant.HasForfeited + ? "You have forfeited this game." + : "You have already completed all available problems." + ) + ); + + if (participant.ProblemSession is null) + return Result.Invalid( + new ValidationError(nameof(participant.ProblemSession), "Problem session not initialized.") + ); + + if (participant.ProblemSession.CurrentProblemId != request.ProblemId) + return Result.Invalid( + new ValidationError( + nameof(request.ProblemId), + "The specified problem is not the participant's current problem." + ) + ); + + // Create the grade submission through the existing submission pipeline. + var submissionResult = await mediator.Send( + new CreateSubmissionCommand( + request.ProblemSetupId, + SubmissionType.Submit, + request.Code, + request.RequestedByUserId, + null, + GameId: request.GameId + ), + cancellationToken + ); + + if (!submissionResult.IsSuccess) + return Result.Error("Failed to create submission."); + + // Stamp the submission ID on the session so completeProblem can verify it came from here. + participant.SetActiveSubmission(submissionResult.Value); + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + + return Result.Success(submissionResult.Value); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemValidator.cs b/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemValidator.cs new file mode 100644 index 0000000..f581f8d --- /dev/null +++ b/StackDuel.Application/Commands/Games/SubmitGameProblem/SubmitGameProblemValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Games.SubmitGameProblem; + +internal sealed class SubmitGameProblemValidator : AbstractValidator +{ + public SubmitGameProblemValidator() + { + RuleFor(x => x.GameId).NotEmpty(); + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.ProblemSetupId).NotEmpty(); + RuleFor(x => x.Code).NotEmpty(); + RuleFor(x => x.RequestedByUserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ICommand.cs b/StackDuel.Application/Commands/ICommand.cs new file mode 100644 index 0000000..b1d7490 --- /dev/null +++ b/StackDuel.Application/Commands/ICommand.cs @@ -0,0 +1,8 @@ +using Ardalis.Result; +using MediatR; + +namespace StackDuel.Application.Commands; + +public interface ICommand : IRequest> { } + +public interface ICommand : IRequest { } \ No newline at end of file diff --git a/StackDuel.Application/Commands/ICommandHandler.cs b/StackDuel.Application/Commands/ICommandHandler.cs new file mode 100644 index 0000000..37e62d4 --- /dev/null +++ b/StackDuel.Application/Commands/ICommandHandler.cs @@ -0,0 +1,12 @@ +using Ardalis.Result; +using MediatR; + +namespace StackDuel.Application.Commands; + +public interface ICommandHandler : IRequestHandler> + where TCommand : ICommand +{ } + +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ } \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolCommand.cs b/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolCommand.cs new file mode 100644 index 0000000..023b256 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.ProblemPools.AddProblemToPool; + +internal sealed record AddProblemToPoolCommand(string PoolKey, Guid ProblemId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolHandler.cs b/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolHandler.cs new file mode 100644 index 0000000..3a3c15c --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolHandler.cs @@ -0,0 +1,33 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.ProblemPools.AddProblemToPool; + +internal sealed class AddProblemToPoolHandler( + IProblemPoolRepository problemPoolRepository, + IProblemReadRepository problemReadRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + AddProblemToPoolCommand request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + bool problemExists = await problemReadRepository.ExistsForAdminAsync(request.ProblemId, cancellationToken); + if (!problemExists) + return Result.NotFound($"Problem '{request.ProblemId}' was not found."); + + pool.AddProblem(request.ProblemId); + + await problemPoolRepository.UpdateAsync(pool, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolValidator.cs b/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolValidator.cs new file mode 100644 index 0000000..6a994ca --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/AddProblemToPool/AddProblemToPoolValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.ProblemPools.AddProblemToPool; + +internal sealed class AddProblemToPoolValidator : AbstractValidator +{ + public AddProblemToPoolValidator() + { + RuleFor(x => x.PoolKey).NotEmpty(); + RuleFor(x => x.ProblemId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolCommand.cs b/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolCommand.cs new file mode 100644 index 0000000..6f8cde0 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolCommand.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.ProblemPools.AddProblemsToPool; + +internal sealed record AddProblemsToPoolCommand( + string PoolKey, + IReadOnlyList ProblemIds, + bool SelectAllMatching, + string? Search, + IReadOnlyList ExcludedProblemIds +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolHandler.cs b/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolHandler.cs new file mode 100644 index 0000000..15cce9f --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolHandler.cs @@ -0,0 +1,47 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.ProblemPools.AddProblemsToPool; + +internal sealed class AddProblemsToPoolHandler( + IProblemPoolRepository problemPoolRepository, + IProblemReadRepository problemReadRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + AddProblemsToPoolCommand request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + IReadOnlyList idsToAdd; + if (request.SelectAllMatching) + { + IReadOnlyList matchingIds = await problemReadRepository.GetAdminProblemIdsMatchingAsync( + request.Search, + cancellationToken + ); + HashSet excluded = [.. request.ExcludedProblemIds]; + idsToAdd = [.. matchingIds.Where(id => !excluded.Contains(id))]; + } + else + { + var existing = await problemReadRepository.FindByIdsAsync(request.ProblemIds, cancellationToken); + idsToAdd = [.. existing.Select(p => p.Id)]; + } + + int before = pool.ProblemIds.Count; + foreach (Guid problemId in idsToAdd) + pool.AddProblem(problemId); + + await problemPoolRepository.UpdateAsync(pool, cancellationToken); + + return Result.Success(pool.ProblemIds.Count - before); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolValidator.cs b/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolValidator.cs new file mode 100644 index 0000000..d29d1a9 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/AddProblemsToPool/AddProblemsToPoolValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.ProblemPools.AddProblemsToPool; + +internal sealed class AddProblemsToPoolValidator : AbstractValidator +{ + public AddProblemsToPoolValidator() + { + RuleFor(x => x.PoolKey).NotEmpty(); + RuleFor(x => x.ProblemIds).NotEmpty().When(x => !x.SelectAllMatching); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolCommand.cs b/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolCommand.cs new file mode 100644 index 0000000..2aa6fa6 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.ProblemPools.CreateProblemPool; + +internal sealed record CreateProblemPoolCommand(string Key, string Name, string? Description) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolHandler.cs b/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolHandler.cs new file mode 100644 index 0000000..8c100cc --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolHandler.cs @@ -0,0 +1,32 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.ProblemPools.CreateProblemPool; + +internal sealed class CreateProblemPoolHandler( + IProblemPoolRepository problemPoolRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + CreateProblemPoolCommand request, + CancellationToken cancellationToken + ) + { + string normalizedKey = request.Key.Trim().ToLowerInvariant(); + + bool exists = await problemPoolRepository.ExistsByKeyAsync(normalizedKey, cancellationToken); + if (exists) + return Result.Invalid( + new ValidationError(nameof(request.Key), $"A pool with key '{normalizedKey}' already exists.") + ); + + ProblemPool pool = new(normalizedKey, request.Name, request.Description); + + await problemPoolRepository.AddAsync(pool, cancellationToken); + + return Result.Success(pool.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolValidator.cs b/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolValidator.cs new file mode 100644 index 0000000..17821a7 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/CreateProblemPool/CreateProblemPoolValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.ProblemPools.CreateProblemPool; + +internal sealed class CreateProblemPoolValidator : AbstractValidator +{ + public CreateProblemPoolValidator() + { + RuleFor(x => x.Key).NotEmpty().MaximumLength(ProblemPool.MaxKeyLength); + + RuleFor(x => x.Name).NotEmpty().MaximumLength(ProblemPool.MaxNameLength); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolCommand.cs b/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolCommand.cs new file mode 100644 index 0000000..59911c3 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.ProblemPools.RemoveProblemFromPool; + +internal sealed record RemoveProblemFromPoolCommand(string PoolKey, Guid ProblemId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolHandler.cs b/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolHandler.cs new file mode 100644 index 0000000..8e0cb44 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolHandler.cs @@ -0,0 +1,28 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.ProblemPools.RemoveProblemFromPool; + +internal sealed class RemoveProblemFromPoolHandler( + IProblemPoolRepository problemPoolRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + RemoveProblemFromPoolCommand request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + pool.RemoveProblem(request.ProblemId); + + await problemPoolRepository.UpdateAsync(pool, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolValidator.cs b/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolValidator.cs new file mode 100644 index 0000000..fb612e2 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/RemoveProblemFromPool/RemoveProblemFromPoolValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.ProblemPools.RemoveProblemFromPool; + +internal sealed class RemoveProblemFromPoolValidator : AbstractValidator +{ + public RemoveProblemFromPoolValidator() + { + RuleFor(x => x.PoolKey).NotEmpty(); + RuleFor(x => x.ProblemId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolCommand.cs b/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolCommand.cs new file mode 100644 index 0000000..ed5e3f5 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.ProblemPools.ReorderProblemPool; + +internal sealed record ReorderProblemPoolCommand(string PoolKey, IReadOnlyList ProblemIds) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolHandler.cs b/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolHandler.cs new file mode 100644 index 0000000..9cbe806 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolHandler.cs @@ -0,0 +1,41 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.ProblemPools.ReorderProblemPool; + +internal sealed class ReorderProblemPoolHandler( + IProblemPoolRepository problemPoolRepository, + IValidator validator +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + ReorderProblemPoolCommand request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + HashSet currentMemberIds = [.. pool.ProblemIds]; + HashSet requestedIds = [.. request.ProblemIds]; + + if (requestedIds.Count != request.ProblemIds.Count || !currentMemberIds.SetEquals(requestedIds)) + { + return Result.Invalid( + new ValidationError( + nameof(request.ProblemIds), + "The given order must include every current pool member exactly once." + ) + ); + } + + pool.Reorder(request.ProblemIds); + + await problemPoolRepository.UpdateAsync(pool, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolValidator.cs b/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolValidator.cs new file mode 100644 index 0000000..ca4efa1 --- /dev/null +++ b/StackDuel.Application/Commands/ProblemPools/ReorderProblemPool/ReorderProblemPoolValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.ProblemPools.ReorderProblemPool; + +internal sealed class ReorderProblemPoolValidator : AbstractValidator +{ + public ReorderProblemPoolValidator() + { + RuleFor(x => x.PoolKey).NotEmpty(); + RuleFor(x => x.ProblemIds).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupCommand.cs b/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupCommand.cs new file mode 100644 index 0000000..b3864e9 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupCommand.cs @@ -0,0 +1,14 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.AddProblemSetup; + +internal sealed record AddProblemSetupCommand(Guid ProblemId, Guid LanguageVersionId) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "problem.setup-added"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => ProblemId.ToString(); + public object? AuditDetails => new { LanguageVersionId }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupHandler.cs b/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupHandler.cs new file mode 100644 index 0000000..77371bf --- /dev/null +++ b/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupHandler.cs @@ -0,0 +1,57 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Commands.Problems.AddProblemSetup; + +internal sealed class AddProblemSetupHandler( + IValidator validator, + IProblemRepository problemRepository, + IExecutionPipelineRepository executionPipelineRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + AddProblemSetupCommand request, + CancellationToken cancellationToken + ) + { + Problem? problem = await problemRepository.FindByIdAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + if (problem.Status == ProblemStatus.Pending) + return Result.Invalid( + new ValidationError("Status", "Cannot edit a problem while validation is in progress.") + ); + + if (problem.FindSetupByLanguageVersionId(request.LanguageVersionId) is not null) + return Result.Invalid( + new ValidationError("LanguageVersionId", "A setup for this language already exists.") + ); + + Guid? pipelineId = await executionPipelineRepository.FindIdByNameAsync( + WellKnownExecutionPipelines.DefaultName, + cancellationToken + ); + + if (pipelineId is null) + return Result.Error("No execution pipeline is configured."); + + ProblemSetup setup = problem.AddSetup( + request.LanguageVersionId, + PlaceholderInitialCode, + functionName: null, + pipelineId.Value + ); + + await problemRepository.UpdateAsync(problem, cancellationToken); + + return Result.Success(setup.Id); + } + + private const string PlaceholderInitialCode = "// TODO: starter code"; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupValidator.cs b/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupValidator.cs new file mode 100644 index 0000000..3c5e1ca --- /dev/null +++ b/StackDuel.Application/Commands/Problems/AddProblemSetup/AddProblemSetupValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.AddProblemSetup; + +internal sealed class AddProblemSetupValidator : AbstractValidator +{ + public AddProblemSetupValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.LanguageVersionId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftCommand.cs b/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftCommand.cs new file mode 100644 index 0000000..6cf1d67 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftCommand.cs @@ -0,0 +1,29 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.CreateProblemDraft; + +internal sealed record CreateProblemDraftCommand( + string Title, + string Question, + int Difficulty, + int TimeLimitMs, + int MemoryLimitMb, + IReadOnlyCollection Tags, + Guid TrackId +) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem.draft-created"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => null; + public object? AuditDetails => + new + { + Title, + Difficulty, + TimeLimitMs, + MemoryLimitMb, + Tags, + TrackId, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftHandler.cs b/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftHandler.cs new file mode 100644 index 0000000..db7a9ae --- /dev/null +++ b/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftHandler.cs @@ -0,0 +1,115 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Tracks; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.RequiredLanguages; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Commands.Problems.CreateProblemDraft; + +internal sealed class CreateProblemDraftHandler( + IValidator validator, + IProblemRepository problemRepository, + IExecutionPipelineRepository executionPipelineRepository, + IRequiredProblemLanguageRepository requiredProblemLanguageRepository, + ITrackReadRepository trackReadRepository, + UserContext userContext +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + CreateProblemDraftCommand request, + CancellationToken cancellationToken + ) + { + Title title; + Question question; + Difficulty difficulty; + TimeLimit timeLimit; + MemoryLimit memoryLimit; + List desiredTagNames; + + try + { + title = new Title(request.Title); + question = new Question(request.Question); + difficulty = new Difficulty(request.Difficulty); + timeLimit = new TimeLimit(request.TimeLimitMs); + memoryLimit = new MemoryLimit(request.MemoryLimitMb); + desiredTagNames = + [ + .. request.Tags.Select(t => t.Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct(), + ]; + + foreach (string name in desiredTagNames) + _ = new Tag(name); + } + catch (Exception ex) + when (ex + is InvalidTitleException + or InvalidQuestionException + or InvalidDifficultyException + or InvalidTimeLimitException + or InvalidMemoryLimitException + or InvalidTagException + ) + { + return Result.Invalid(new ValidationError(ex.Message)); + } + + Guid? pipelineId = await executionPipelineRepository.FindIdByNameAsync( + WellKnownExecutionPipelines.DefaultName, + cancellationToken + ); + + if (pipelineId is null) + return Result.Error("No execution pipeline is configured."); + + Track? track = await trackReadRepository.FindByIdAsync(request.TrackId, cancellationToken); + + if (track is null || !track.IsActive) + return Result.Invalid(new ValidationError("Track is invalid or inactive.")); + + Slug baseSlug = Slug.FromTitle(title); + Slug slug = baseSlug; + int suffix = 2; + + while (await problemRepository.FindBySlugAsync(slug, cancellationToken) is not null) + { + slug = new Slug($"{baseSlug.Value}-{suffix}"); + suffix++; + } + + Problem problem = new(slug, title, question, difficulty, timeLimit, memoryLimit); + problem.SetTrack(track.Id); + + if (userContext.User is not null) + problem.SetCreatedBy(userContext.User.Id); + + if (desiredTagNames.Count > 0) + { + IReadOnlyCollection tags = await problemRepository.FindOrCreateTagsAsync( + desiredTagNames, + cancellationToken + ); + foreach (ProblemTag tag in tags) + problem.AddTag(tag); + } + + IReadOnlyList requiredLanguages = + await requiredProblemLanguageRepository.GetAllOrderedForTrackAsync(track.Id, cancellationToken); + + foreach (RequiredProblemLanguage required in requiredLanguages) + problem.AddSetup(required.LanguageVersionId, PlaceholderInitialCode, functionName: null, pipelineId.Value); + + await problemRepository.AddAsync(problem, cancellationToken); + + return Result.Success(problem.Id); + } + + private const string PlaceholderInitialCode = "// TODO: starter code"; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftValidator.cs b/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftValidator.cs new file mode 100644 index 0000000..75fbfd2 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/CreateProblemDraft/CreateProblemDraftValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Application.Commands.Problems.CreateProblemDraft; + +internal sealed class CreateProblemDraftValidator : AbstractValidator +{ + public CreateProblemDraftValidator() + { + RuleFor(x => x.Title).NotEmpty().Length(Title.MinLength, Title.MaxLength); + RuleFor(x => x.Question).NotEmpty().Length(Question.MinLength, Question.MaxLength); + RuleFor(x => x.Difficulty).GreaterThanOrEqualTo(Difficulty.MinValue); + RuleFor(x => x.TimeLimitMs).InclusiveBetween(TimeLimit.MinMilliseconds, TimeLimit.MaxMilliseconds); + RuleFor(x => x.MemoryLimitMb).InclusiveBetween(MemoryLimit.MinMegabytes, MemoryLimit.MaxMegabytes); + RuleFor(x => x.Tags).NotNull(); + RuleForEach(x => x.Tags).NotEmpty().Length(Tag.MinLength, Tag.MaxLength); + RuleFor(x => x.TrackId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageCommand.cs b/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageCommand.cs new file mode 100644 index 0000000..564e971 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.RequiredLanguages.AddRequiredProblemLanguage; + +internal sealed record AddRequiredProblemLanguageCommand(Guid LanguageVersionId) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem-required-language.added"; + public string? AuditTargetType => "required-problem-language"; + public string? AuditTargetId => LanguageVersionId.ToString(); + public object? AuditDetails => new { LanguageVersionId }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageHandler.cs b/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageHandler.cs new file mode 100644 index 0000000..e5bdd04 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageHandler.cs @@ -0,0 +1,34 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Problems.RequiredLanguages; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; + +namespace StackDuel.Application.Commands.Problems.RequiredLanguages.AddRequiredProblemLanguage; + +internal sealed class AddRequiredProblemLanguageHandler( + IValidator validator, + IRequiredProblemLanguageRepository repository +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + AddRequiredProblemLanguageCommand request, + CancellationToken cancellationToken + ) + { + RequiredProblemLanguage? existing = await repository.FindByLanguageVersionIdAsync( + request.LanguageVersionId, + cancellationToken + ); + + if (existing is not null) + return Result.Invalid(new ValidationError("LanguageVersionId", "This language is already required.")); + + IReadOnlyList all = await repository.GetAllOrderedAsync(cancellationToken); + + RequiredProblemLanguage required = RequiredProblemLanguage.Create(request.LanguageVersionId, all.Count); + + await repository.AddAsync(required, cancellationToken); + + return Result.Success(required.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageValidator.cs b/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageValidator.cs new file mode 100644 index 0000000..de081df --- /dev/null +++ b/StackDuel.Application/Commands/Problems/RequiredLanguages/AddRequiredProblemLanguage/AddRequiredProblemLanguageValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.RequiredLanguages.AddRequiredProblemLanguage; + +internal sealed class AddRequiredProblemLanguageValidator : AbstractValidator +{ + public AddRequiredProblemLanguageValidator() + { + RuleFor(x => x.LanguageVersionId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageCommand.cs b/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageCommand.cs new file mode 100644 index 0000000..32acdaf --- /dev/null +++ b/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.RequiredLanguages.RemoveRequiredProblemLanguage; + +internal sealed record RemoveRequiredProblemLanguageCommand(Guid Id) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem-required-language.removed"; + public string? AuditTargetType => "required-problem-language"; + public string? AuditTargetId => Id.ToString(); + public object? AuditDetails => null; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageHandler.cs b/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageHandler.cs new file mode 100644 index 0000000..6059a9c --- /dev/null +++ b/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageHandler.cs @@ -0,0 +1,27 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Problems.RequiredLanguages; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; + +namespace StackDuel.Application.Commands.Problems.RequiredLanguages.RemoveRequiredProblemLanguage; + +internal sealed class RemoveRequiredProblemLanguageHandler( + IValidator validator, + IRequiredProblemLanguageRepository repository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + RemoveRequiredProblemLanguageCommand request, + CancellationToken cancellationToken + ) + { + RequiredProblemLanguage? existing = await repository.FindByIdAsync(request.Id, cancellationToken); + + if (existing is null) + return Result.NotFound(); + + await repository.RemoveAsync(request.Id, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageValidator.cs b/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageValidator.cs new file mode 100644 index 0000000..15282f4 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/RequiredLanguages/RemoveRequiredProblemLanguage/RemoveRequiredProblemLanguageValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.RequiredLanguages.RemoveRequiredProblemLanguage; + +internal sealed class RemoveRequiredProblemLanguageValidator : AbstractValidator +{ + public RemoveRequiredProblemLanguageValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersCommand.cs b/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersCommand.cs new file mode 100644 index 0000000..8a4108b --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersCommand.cs @@ -0,0 +1,26 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Application.Commands.Problems.SetProblemGenerationParameters; + +internal sealed record SetProblemGenerationParametersCommand( + Guid ProblemId, + IReadOnlyList Parameters, + string OutputValueType, + int TargetCaseCount, + int Seed +) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem.generation-parameters-set"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => ProblemId.ToString(); + public object? AuditDetails => + new + { + OutputValueType, + TargetCaseCount, + Seed, + ParameterCount = Parameters.Count, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersHandler.cs b/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersHandler.cs new file mode 100644 index 0000000..67897f0 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersHandler.cs @@ -0,0 +1,40 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Commands.Problems.SetProblemGenerationParameters; + +internal sealed class SetProblemGenerationParametersHandler( + IValidator validator, + IProblemRepository problemRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SetProblemGenerationParametersCommand request, + CancellationToken cancellationToken + ) + { + Problem? problem = await problemRepository.FindByIdAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + if (problem.Status == ProblemStatus.Pending) + return Result.Invalid( + new ValidationError("Status", "Cannot edit a problem while validation is in progress.") + ); + + problem.SetGenerationParameters( + request.Parameters, + request.OutputValueType, + request.TargetCaseCount, + request.Seed + ); + + await problemRepository.UpdateAsync(problem, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersValidator.cs b/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersValidator.cs new file mode 100644 index 0000000..25711cb --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemGenerationParameters/SetProblemGenerationParametersValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.SetProblemGenerationParameters; + +internal sealed class SetProblemGenerationParametersValidator : AbstractValidator +{ + public SetProblemGenerationParametersValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.Parameters).NotEmpty(); + RuleFor(x => x.OutputValueType).NotEmpty().MaximumLength(100); + RuleFor(x => x.TargetCaseCount).GreaterThan(0); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionCommand.cs b/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionCommand.cs new file mode 100644 index 0000000..2c3ff10 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionCommand.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Commands.Problems.SetProblemReaction; + +internal sealed record SetProblemReactionCommand(Guid ProblemId, Guid UserId, string ReactionTypeKey) + : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionHandler.cs b/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionHandler.cs new file mode 100644 index 0000000..13ba621 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionHandler.cs @@ -0,0 +1,87 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Commands.Problems.SetProblemReaction; + +internal sealed class SetProblemReactionHandler( + IValidator validator, + IProblemReadRepository problemReadRepository, + IProblemReactionReadRepository problemReactionReadRepository, + IProblemReactionWriteRepository problemReactionWriteRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + SetProblemReactionCommand request, + CancellationToken cancellationToken + ) + { + bool problemExists = await problemReadRepository.ExistsAsync(request.ProblemId, cancellationToken); + if (!problemExists) + return Result.NotFound($"Problem '{request.ProblemId}' was not found."); + + ProblemReactionType? reactionType = await problemReactionReadRepository.FindReactionTypeByKeyAsync( + request.ReactionTypeKey, + cancellationToken + ); + + if (reactionType is null || !reactionType.IsEnabled) + return Result.NotFound( + $"Reaction type '{request.ReactionTypeKey}' was not found." + ); + + ProblemReaction? targetReaction = await problemReactionWriteRepository.FindByProblemUserAndTypeAsync( + request.ProblemId, + request.UserId, + reactionType.Id, + cancellationToken + ); + + if (targetReaction is not null && targetReaction.IsActive) + { + // Same reaction clicked again — unlike/undislike. Only one reaction was ever active, + // so nothing else needs to be touched. + targetReaction.Deactivate(); + await problemReactionWriteRepository.UpdateAsync(targetReaction, cancellationToken); + } + else + { + // Activating a new or previously-unreacted kind. Clear whatever else is currently + // active first so the DB's "one active reaction per user per problem" constraint is + // never violated at any point either write commits. + ProblemReaction? currentlyActive = await problemReactionWriteRepository.FindActiveByProblemAndUserAsync( + request.ProblemId, + request.UserId, + cancellationToken + ); + + if (currentlyActive is not null && currentlyActive.Id != targetReaction?.Id) + { + currentlyActive.Deactivate(); + await problemReactionWriteRepository.UpdateAsync(currentlyActive, cancellationToken); + } + + if (targetReaction is null) + { + targetReaction = new ProblemReaction(request.ProblemId, request.UserId, reactionType.Id); + await problemReactionWriteRepository.AddAsync(targetReaction, cancellationToken); + } + else + { + targetReaction.Activate(); + await problemReactionWriteRepository.UpdateAsync(targetReaction, cancellationToken); + } + } + + var summary = await problemReactionReadRepository.GetSummaryAsync( + request.ProblemId, + request.UserId, + cancellationToken + ); + + return Result.Success(summary); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionValidator.cs b/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionValidator.cs new file mode 100644 index 0000000..d6d4f5b --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemReaction/SetProblemReactionValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.SetProblemReaction; + +internal sealed class SetProblemReactionValidator : AbstractValidator +{ + public SetProblemReactionValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.ReactionTypeKey).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesCommand.cs b/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesCommand.cs new file mode 100644 index 0000000..f9b0781 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesCommand.cs @@ -0,0 +1,23 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.SetProblemSampleTestCases; + +public sealed record SampleTestCaseInputDto(string Value, string ValueType); + +public sealed record SampleTestCaseDto( + string? Name, + IReadOnlyList Inputs, + string ExpectedOutputValue, + string ExpectedOutputValueType +); + +internal sealed record SetProblemSampleTestCasesCommand(Guid ProblemId, IReadOnlyList TestCases) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "problem.sample-test-cases-set"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => ProblemId.ToString(); + public object? AuditDetails => new { TestCaseCount = TestCases.Count }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesHandler.cs b/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesHandler.cs new file mode 100644 index 0000000..ffd814e --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesHandler.cs @@ -0,0 +1,55 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.TestSuites; +using StackDuel.Domain.TestSuites.ValueObjects; + +namespace StackDuel.Application.Commands.Problems.SetProblemSampleTestCases; + +internal sealed class SetProblemSampleTestCasesHandler( + IValidator validator, + IProblemRepository problemRepository, + ITestSuiteWriteRepository testSuiteWriteRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SetProblemSampleTestCasesCommand request, + CancellationToken cancellationToken + ) + { + Problem? problem = await problemRepository.FindByIdAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + if (problem.Status == ProblemStatus.Pending) + return Result.Invalid( + new ValidationError("Status", "Cannot edit a problem while validation is in progress.") + ); + + List setupIds = [.. problem.Setups.Select(s => s.Id)]; + + if (setupIds.Count == 0) + return Result.Invalid( + new ValidationError("TestCases", "Add at least one language setup before authoring sample test cases.") + ); + + List specs = + [ + .. request.TestCases.Select(tc => new AuthoredTestCaseSpec( + tc.Name, + [.. tc.Inputs.Select(i => (i.Value, i.ValueType))], + tc.ExpectedOutputValue, + tc.ExpectedOutputValueType + )), + ]; + + string suiteName = $"{problem.Slug.Value} - Sample Cases"; + + await testSuiteWriteRepository.ReplaceSampleTestCasesAsync(setupIds, suiteName, specs, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesValidator.cs b/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesValidator.cs new file mode 100644 index 0000000..8d7e72e --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SetProblemSampleTestCases/SetProblemSampleTestCasesValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.SetProblemSampleTestCases; + +internal sealed class SetProblemSampleTestCasesValidator : AbstractValidator +{ + public SetProblemSampleTestCasesValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.TestCases).NotEmpty(); + + RuleForEach(x => x.TestCases) + .ChildRules(testCase => + { + testCase.RuleFor(x => x.Inputs).NotEmpty(); + testCase + .RuleForEach(x => x.Inputs) + .ChildRules(input => + { + input.RuleFor(x => x.Value).NotEmpty(); + input.RuleFor(x => x.ValueType).NotEmpty(); + }); + testCase.RuleFor(x => x.ExpectedOutputValue).NotEmpty(); + testCase.RuleFor(x => x.ExpectedOutputValueType).NotEmpty(); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationCommand.cs b/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationCommand.cs new file mode 100644 index 0000000..fa4384a --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationCommand.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.SubmitProblemForValidation; + +internal sealed record SubmitProblemForValidationCommand(Guid ProblemId) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem.submitted-for-validation"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => ProblemId.ToString(); + public object? AuditDetails => null; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationHandler.cs b/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationHandler.cs new file mode 100644 index 0000000..d6c1961 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationHandler.cs @@ -0,0 +1,72 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.ProblemValidation; +using StackDuel.Domain.ProblemValidation.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Application.Commands.Problems.SubmitProblemForValidation; + +internal sealed class SubmitProblemForValidationHandler( + IValidator validator, + IProblemRepository problemRepository, + IProblemValidationJobRepository problemValidationJobRepository, + IMessagePublisher messagePublisher +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SubmitProblemForValidationCommand request, + CancellationToken cancellationToken + ) + { + Problem? problem = await problemRepository.FindByIdAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + if (problem.Status is not (ProblemStatus.Draft or ProblemStatus.Failed)) + return Result.Invalid( + new ValidationError("Status", $"Cannot submit a problem for validation from status {problem.Status}.") + ); + + List errors = []; + + if (problem.Setups.Count == 0) + errors.Add("At least one language setup is required."); + + foreach (ProblemSetup setup in problem.Setups.Where(s => string.IsNullOrWhiteSpace(s.ReferenceSolutionCode))) + errors.Add($"A reference solution is required for setup {setup.Id}."); + + bool hasSampleTestCases = problem + .Setups.SelectMany(s => s.TestSuites) + .Where(ts => ts.Type == TestSuiteType.Sample) + .SelectMany(ts => ts.TestCases) + .Any(tc => tc.Source == TestCaseSource.Authored && tc.RetiredAt is null); + + if (!hasSampleTestCases) + errors.Add("At least one sample test case is required."); + + if (problem.GenerationSpec is null) + errors.Add("Generation parameters are required before submitting for validation."); + + if (errors.Count > 0) + { + List validationErrors = [.. errors.Select(e => new ValidationError(e))]; + return Result.Invalid(validationErrors); + } + + problem.SubmitForValidation(); + await problemRepository.UpdateAsync(problem, cancellationToken); + + ProblemValidationJob job = new(problem.Id); + await problemValidationJobRepository.AddAsync(job, cancellationToken); + + await messagePublisher.PublishAsync(new ProblemValidationJobContinuationMessage(job.Id), cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationValidator.cs b/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationValidator.cs new file mode 100644 index 0000000..897ca88 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/SubmitProblemForValidation/SubmitProblemForValidationValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.SubmitProblemForValidation; + +internal sealed class SubmitProblemForValidationValidator : AbstractValidator +{ + public SubmitProblemForValidationValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemCommand.cs b/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemCommand.cs new file mode 100644 index 0000000..c7ff24f --- /dev/null +++ b/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemCommand.cs @@ -0,0 +1,31 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Commands.Problems.UpdateProblem; + +internal sealed record UpdateProblemCommand( + Guid ProblemId, + string Title, + string Question, + int Difficulty, + int TimeLimitMs, + int MemoryLimitMb, + IReadOnlyCollection Tags, + ProblemStatus? Status +) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem.updated"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => ProblemId.ToString(); + public object? AuditDetails => + new + { + Title, + Difficulty, + TimeLimitMs, + MemoryLimitMb, + Tags, + Status, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemHandler.cs b/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemHandler.cs new file mode 100644 index 0000000..316c2ca --- /dev/null +++ b/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemHandler.cs @@ -0,0 +1,151 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Application.Commands.Problems.UpdateProblem; + +internal sealed class UpdateProblemHandler( + IValidator validator, + IProblemRepository problemRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateProblemCommand request, + CancellationToken cancellationToken + ) + { + Problem? problem = await problemRepository.FindByIdAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + if (problem.Status == ProblemStatus.Pending) + return Result.Invalid( + new ValidationError("Status", "Cannot edit a problem while validation is in progress.") + ); + + var (parseFailure, content) = ParseContent(request); + if (parseFailure is not null) + return parseFailure; + + Result? transitionFailure = ValidateStatusTransition(problem.Status, request.Status); + if (transitionFailure is not null) + return transitionFailure; + + problem.UpdateContent( + content!.Title, + content.Question, + content.Difficulty, + content.TimeLimit, + content.MemoryLimit + ); + + await SyncTagsAsync(problem, content.TagNames, cancellationToken); + + ApplyStatusTransition(problem, request.Status); + + await problemRepository.UpdateAsync(problem, cancellationToken); + + return Result.Success(); + } + + private static (Result? Failure, ParsedContent? Content) ParseContent(UpdateProblemCommand request) + { + try + { + List desiredTagNames = + [ + .. request.Tags.Select(t => t.Trim().ToLowerInvariant()).Where(t => t.Length > 0).Distinct(), + ]; + + // Validate tag formatting up front so a bad tag doesn't fail after the content + // update below has already mutated the tracked entity. + foreach (string name in desiredTagNames) + _ = new Tag(name); + + return ( + null, + new ParsedContent( + new Title(request.Title), + new Question(request.Question), + new Difficulty(request.Difficulty), + new TimeLimit(request.TimeLimitMs), + new MemoryLimit(request.MemoryLimitMb), + desiredTagNames + ) + ); + } + catch (Exception ex) + when (ex + is InvalidTitleException + or InvalidQuestionException + or InvalidDifficultyException + or InvalidTimeLimitException + or InvalidMemoryLimitException + or InvalidTagException + ) + { + return (Result.Invalid(new ValidationError(ex.Message)), null); + } + } + + private static Result? ValidateStatusTransition(ProblemStatus currentStatus, ProblemStatus? targetStatus) + { + if (targetStatus is not { } status || status == currentStatus) + return null; + + bool isValidTransition = + (currentStatus == ProblemStatus.Draft && status == ProblemStatus.Published) + || (currentStatus is ProblemStatus.Draft or ProblemStatus.Published && status == ProblemStatus.Archived); + + return isValidTransition + ? null + : Result.Invalid( + new ValidationError("Status", $"Cannot transition a problem from {currentStatus} to {status}.") + ); + } + + private async Task SyncTagsAsync(Problem problem, List desiredTagNames, CancellationToken cancellationToken) + { + HashSet currentTagNames = [.. problem.Tags.Select(t => t.Name.Value)]; + + List tagsToRemove = [.. problem.Tags.Where(t => !desiredTagNames.Contains(t.Name.Value))]; + foreach (ProblemTag tag in tagsToRemove) + problem.RemoveTag(tag); + + List tagNamesToAdd = [.. desiredTagNames.Where(name => !currentTagNames.Contains(name))]; + if (tagNamesToAdd.Count > 0) + { + IReadOnlyCollection tags = await problemRepository.FindOrCreateTagsAsync( + tagNamesToAdd, + cancellationToken + ); + foreach (ProblemTag tag in tags) + problem.AddTag(tag); + } + } + + private static void ApplyStatusTransition(Problem problem, ProblemStatus? newStatus) + { + if (newStatus is not { } status || status == problem.Status) + return; + + if (status == ProblemStatus.Published) + problem.Publish(); + else if (status == ProblemStatus.Archived) + problem.Archive(); + } + + private sealed record ParsedContent( + Title Title, + Question Question, + Difficulty Difficulty, + TimeLimit TimeLimit, + MemoryLimit MemoryLimit, + List TagNames + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemValidator.cs b/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemValidator.cs new file mode 100644 index 0000000..0405d50 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/UpdateProblem/UpdateProblemValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Application.Commands.Problems.UpdateProblem; + +internal sealed class UpdateProblemValidator : AbstractValidator +{ + public UpdateProblemValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().Length(Title.MinLength, Title.MaxLength); + RuleFor(x => x.Question).NotEmpty().Length(Question.MinLength, Question.MaxLength); + RuleFor(x => x.Difficulty).GreaterThanOrEqualTo(Difficulty.MinValue); + RuleFor(x => x.TimeLimitMs).InclusiveBetween(TimeLimit.MinMilliseconds, TimeLimit.MaxMilliseconds); + RuleFor(x => x.MemoryLimitMb).InclusiveBetween(MemoryLimit.MinMegabytes, MemoryLimit.MaxMegabytes); + RuleFor(x => x.Tags).NotNull(); + RuleForEach(x => x.Tags).NotEmpty().Length(Tag.MinLength, Tag.MaxLength); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionCommand.cs b/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionCommand.cs new file mode 100644 index 0000000..e58f083 --- /dev/null +++ b/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionCommand.cs @@ -0,0 +1,18 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Problems.UpsertProblemSetupReferenceSolution; + +internal sealed record UpsertProblemSetupReferenceSolutionCommand( + Guid ProblemId, + Guid LanguageVersionId, + string InitialCode, + string? FunctionName, + string ReferenceSolutionCode +) : ICommand, IAuditableCommand +{ + public string AuditAction => "problem.setup-reference-solution-upserted"; + public string? AuditTargetType => "problem"; + public string? AuditTargetId => ProblemId.ToString(); + public object? AuditDetails => new { LanguageVersionId, FunctionName }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionHandler.cs b/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionHandler.cs new file mode 100644 index 0000000..9d274aa --- /dev/null +++ b/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionHandler.cs @@ -0,0 +1,62 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Commands.Problems.UpsertProblemSetupReferenceSolution; + +internal sealed class UpsertProblemSetupReferenceSolutionHandler( + IValidator validator, + IProblemRepository problemRepository, + IExecutionPipelineRepository executionPipelineRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task> HandleValidated( + UpsertProblemSetupReferenceSolutionCommand request, + CancellationToken cancellationToken + ) + { + Problem? problem = await problemRepository.FindByIdAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + if (problem.Status == ProblemStatus.Pending) + return Result.Invalid( + new ValidationError("Status", "Cannot edit a problem while validation is in progress.") + ); + + ProblemSetup? setup = problem.FindSetupByLanguageVersionId(request.LanguageVersionId); + + if (setup is null) + { + Guid? pipelineId = await executionPipelineRepository.FindIdByNameAsync( + WellKnownExecutionPipelines.DefaultName, + cancellationToken + ); + + if (pipelineId is null) + return Result.Error("No execution pipeline is configured."); + + setup = problem.AddSetup( + request.LanguageVersionId, + request.InitialCode, + request.FunctionName, + pipelineId.Value + ); + } + else + { + problem.UpdateSetupInitialCode(setup.Id, request.InitialCode); + problem.UpdateSetupFunctionName(setup.Id, request.FunctionName); + } + + problem.SetReferenceSolution(setup.Id, request.ReferenceSolutionCode); + + await problemRepository.UpdateAsync(problem, cancellationToken); + + return Result.Success(setup.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionValidator.cs b/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionValidator.cs new file mode 100644 index 0000000..4be8a0a --- /dev/null +++ b/StackDuel.Application/Commands/Problems/UpsertProblemSetupReferenceSolution/UpsertProblemSetupReferenceSolutionValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Problems.UpsertProblemSetupReferenceSolution; + +internal sealed class UpsertProblemSetupReferenceSolutionValidator + : AbstractValidator +{ + public UpsertProblemSetupReferenceSolutionValidator() + { + RuleFor(x => x.ProblemId).NotEmpty(); + RuleFor(x => x.LanguageVersionId).NotEmpty(); + RuleFor(x => x.InitialCode).NotEmpty().MaximumLength(20_000); + RuleFor(x => x.FunctionName).MaximumLength(200).When(x => x.FunctionName is not null); + RuleFor(x => x.ReferenceSolutionCode).NotEmpty().MaximumLength(20_000); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs b/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs new file mode 100644 index 0000000..b967fc8 --- /dev/null +++ b/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionCommand.cs @@ -0,0 +1,15 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Commands.Submissions.CreateSubmission; + +using StackDuel.Application.Submissions.Dtos; + +internal sealed record CreateSubmissionCommand( + Guid ProblemSetupId, + SubmissionType Type, + string Code, + Guid CreatedById, + IReadOnlyCollection? CustomTestCases, + IReadOnlyCollection? AdditionalFiles = null, + Guid? GameId = null +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs b/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs new file mode 100644 index 0000000..4dbbd6f --- /dev/null +++ b/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionHandler.cs @@ -0,0 +1,105 @@ +using Ardalis.Result; +using FluentValidation; +using MediatR; +using StackDuel.Application.Events; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.Factories; +using StackDuel.Domain.Submissions.ValueObjects; +using StackDuel.Domain.TestSuites; +using System.Linq; + +namespace StackDuel.Application.Commands.Submissions.CreateSubmission; + +internal sealed partial class CreateSubmissionHandler( + IValidator validator, + IAggregateFactory submissionFactory, + ISubmissionWriteRepository submissionRepository, + ISubmissionJobRepository submissionJobRepository, + IExecutionPipelineRepository pipelineRepository, + ITestSuiteWriteRepository testSuiteRepository, + IDomainEventDispatcher domainEventDispatcher +) : AbstractCommandHandler(validator) +{ + /// + /// Target total test case count for grading — not purely "random cases": public and + /// hand-authored hidden cases always run, this only bounds how many random generated + /// cases get added on top to reach it (see FindGradingTestCaseIdsByProblemSetupIdAsync). + /// + private const int OfficialGradingCaseCount = 20; + + protected override async Task> HandleValidated( + CreateSubmissionCommand request, + CancellationToken cancellationToken + ) + { + IReadOnlyList testCaseIds; + if (request.Type == SubmissionType.Run) + { + var publicCaseIds = await testSuiteRepository.FindPublicTestCaseIdsByProblemSetupIdAsync( + request.ProblemSetupId, + cancellationToken + ); + + var customCaseIds = await testSuiteRepository.CreateAdHocTestCasesAsync( + request.ProblemSetupId, + request.CustomTestCases?.Select(tc => (IReadOnlyCollection)tc.Inputs).ToArray() ?? [], + cancellationToken + ); + + testCaseIds = [.. publicCaseIds, .. customCaseIds]; + } + else + { + testCaseIds = await testSuiteRepository.FindGradingTestCaseIdsByProblemSetupIdAsync( + request.ProblemSetupId, + OfficialGradingCaseCount, + cancellationToken + ); + } + + if (testCaseIds.Count == 0) + return Result.Error("No test cases are available for this submission mode."); + + var pipelineId = await testSuiteRepository.FindPipelineIdByProblemSetupIdAsync( + request.ProblemSetupId, + cancellationToken + ); + + if (pipelineId is null) + return Result.NotFound("Submission pipeline is not available."); + + var pipeline = await pipelineRepository.FindByIdWithStepsAsync(pipelineId.Value, cancellationToken); + if (pipeline is null) + return Result.NotFound("Submission pipeline is not available."); + + var firstStep = pipeline.FirstStep(); + if (firstStep is null) + return Result.Error("Submission pipeline is temporarily unavailable. Please try again later."); + + var submission = submissionFactory.Create( + new CreateSubmissionParams( + request.CreatedById, + request.ProblemSetupId, + request.Type, + new SourceCode(request.Code), + testCaseIds, + request.AdditionalFiles?.Select(f => new SubmissionSourceFile(f.Path, f.Content)), + request.GameId + ) + ); + + await submissionRepository.AddAsync(submission, cancellationToken); + + var job = new SubmissionJob(submission.Id, pipeline.Id, firstStep.Id); + await submissionJobRepository.AddAsync(job, cancellationToken); + + await domainEventDispatcher.DispatchAsync(submission.PopDomainEvents(), cancellationToken); + + return Result.Success(submission.Id); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs b/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs new file mode 100644 index 0000000..300ec30 --- /dev/null +++ b/StackDuel.Application/Commands/Submissions/CreateSubmission/CreateSubmissionValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.ValueObjects; + +namespace StackDuel.Application.Commands.Submissions.CreateSubmission; + +internal sealed class CreateSubmissionValidator : AbstractValidator +{ + public CreateSubmissionValidator() + { + RuleFor(x => x.ProblemSetupId).NotEmpty(); + RuleFor(x => x.Code).NotEmpty().MaximumLength(SourceCode.MaxLength); + RuleFor(x => x.CreatedById).NotEmpty(); + + RuleForEach(x => x.CustomTestCases!) + .Must(tc => tc.Inputs.Count > 0) + .WithMessage("Custom test case inputs must contain at least one value.") + .When(x => x.Type == SubmissionType.Run && x.CustomTestCases is not null); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackCommand.cs b/StackDuel.Application/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackCommand.cs new file mode 100644 index 0000000..3710f08 --- /dev/null +++ b/StackDuel.Application/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Submissions.ReceiveJudge0Callback; + +public sealed record ReceiveJudge0CallbackCommand(string? Key, Guid SubmissionId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackHandler.cs b/StackDuel.Application/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackHandler.cs new file mode 100644 index 0000000..0540747 --- /dev/null +++ b/StackDuel.Application/Commands/Submissions/ReceiveJudge0Callback/ReceiveJudge0CallbackHandler.cs @@ -0,0 +1,40 @@ +using Ardalis.Result; +using StackDuel.Application.Commands; +using StackDuel.Application.Configuration; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using System.Security.Cryptography; +using System.Text; + +namespace StackDuel.Application.Commands.Submissions.ReceiveJudge0Callback; + +internal sealed class ReceiveJudge0CallbackHandler(IMessagePublisher messagePublisher, Judge0Options judge0Options) + : ICommandHandler +{ + public async Task Handle(ReceiveJudge0CallbackCommand request, CancellationToken cancellationToken) + { + if (!judge0Options.UseCallback || string.IsNullOrEmpty(judge0Options.CallbackSecret)) + return Result.Unauthorized(); + + if (!IsValidKey(request.Key, judge0Options.CallbackSecret)) + return Result.Unauthorized(); + + await messagePublisher.PublishAsync( + new SubmissionJobContinuationMessage(request.SubmissionId), + cancellationToken + ); + + return Result.Success(); + } + + private static bool IsValidKey(string? providedKey, string expectedKey) + { + if (providedKey is null) + return false; + + byte[] provided = Encoding.UTF8.GetBytes(providedKey); + byte[] expected = Encoding.UTF8.GetBytes(expectedKey); + + return provided.Length == expected.Length && CryptographicOperations.FixedTimeEquals(provided, expected); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarCommand.cs b/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarCommand.cs new file mode 100644 index 0000000..9fda307 --- /dev/null +++ b/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Users.SelectUserAvatar; + +public sealed record SelectUserAvatarCommand(Guid UserId, Guid AvatarId) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarHandler.cs b/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarHandler.cs new file mode 100644 index 0000000..54fd7fe --- /dev/null +++ b/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarHandler.cs @@ -0,0 +1,38 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Application.Commands.Users.SelectUserAvatar; + +internal sealed class SelectUserAvatarHandler( + IValidator validator, + IUserWriteRepository userRepository, + IUserAvatarWriteRepository userAvatarRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + SelectUserAvatarCommand request, + CancellationToken cancellationToken + ) + { + User? user = await userRepository.FindByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.NotFound(); + + UserAvatar? avatar = await userAvatarRepository.FindByIdForUserAsync( + request.AvatarId, + request.UserId, + cancellationToken + ); + + if (avatar is null) + return Result.NotFound(); + + user.UpdateImageUrl(avatar.ImageUrl); + await userRepository.UpdateAsync(user, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarValidator.cs b/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarValidator.cs new file mode 100644 index 0000000..56a98a1 --- /dev/null +++ b/StackDuel.Application/Commands/Users/SelectUserAvatar/SelectUserAvatarValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Users.SelectUserAvatar; + +internal sealed class SelectUserAvatarValidator : AbstractValidator +{ + public SelectUserAvatarValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.AvatarId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyCommand.cs b/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyCommand.cs new file mode 100644 index 0000000..342b2cc --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Users.UpdateProfilePrivacy; + +internal sealed record UpdateProfilePrivacyCommand(Guid UserId, bool IsPrivate) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyHandler.cs b/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyHandler.cs new file mode 100644 index 0000000..8db05ba --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyHandler.cs @@ -0,0 +1,28 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Application.Commands.Users.UpdateProfilePrivacy; + +internal sealed partial class UpdateProfilePrivacyHandler( + IValidator validator, + IUserWriteRepository userWriteRepository +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateProfilePrivacyCommand request, + CancellationToken cancellationToken + ) + { + User? user = await userWriteRepository.FindByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.NotFound(); + + user.SetPrivate(request.IsPrivate); + await userWriteRepository.UpdateAsync(user, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyValidator.cs b/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyValidator.cs new file mode 100644 index 0000000..d74bb75 --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpdateProfilePrivacy/UpdateProfilePrivacyValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Users.UpdateProfilePrivacy; + +internal sealed class UpdateProfilePrivacyValidator : AbstractValidator +{ + public UpdateProfilePrivacyValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsCommand.cs b/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsCommand.cs new file mode 100644 index 0000000..9ebc70f --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsCommand.cs @@ -0,0 +1,14 @@ +using StackDuel.Application.Audit; +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Users.UpdateUserGroups; + +internal sealed record UpdateUserGroupsCommand(Guid UserId, IReadOnlyCollection GroupIds) + : ICommand, + IAuditableCommand +{ + public string AuditAction => "user.groups.updated"; + public string? AuditTargetType => "user"; + public string? AuditTargetId => UserId.ToString(); + public object? AuditDetails => new { GroupIds }; +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsHandler.cs b/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsHandler.cs new file mode 100644 index 0000000..3619968 --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsHandler.cs @@ -0,0 +1,66 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Groups; +using StackDuel.Application.Groups.Dtos; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Events; + +namespace StackDuel.Application.Commands.Users.UpdateUserGroups; + +internal sealed partial class UpdateUserGroupsHandler( + IValidator validator, + IUserWriteRepository userWriteRepository, + IGroupReadRepository groupReadRepository, + IDomainEventDispatcher domainEventDispatcher, + UserContext userContext +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpdateUserGroupsCommand request, + CancellationToken cancellationToken + ) + { + User? user = await userWriteRepository.FindByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.NotFound(); + + IReadOnlyList groups = await groupReadRepository.GetAllAsync(cancellationToken); + HashSet validGroupIds = [.. groups.Select(g => g.Id)]; + + Guid[] unknownGroupIds = [.. request.GroupIds.Where(id => !validGroupIds.Contains(id)).Distinct()]; + + if (unknownGroupIds.Length > 0) + return Result.Invalid( + new ValidationError("GroupIds", $"Unknown group id(s): {string.Join(", ", unknownGroupIds)}") + ); + + GroupDto? adminGroup = groups.FirstOrDefault(g => g.Name == WellKnownAuthorization.AdminGroup); + + if (adminGroup is not null && userContext.User?.Id != request.UserId) + { + IReadOnlyList currentGroupIds = await userWriteRepository.GetGroupIdsAsync( + request.UserId, + cancellationToken + ); + + bool targetIsAdmin = currentGroupIds.Contains(adminGroup.Id); + bool wouldRemainAdmin = request.GroupIds.Contains(adminGroup.Id); + + if (targetIsAdmin && !wouldRemainAdmin) + return Result.Forbidden(); + } + + await userWriteRepository.SetGroupsAsync(request.UserId, request.GroupIds, cancellationToken); + + await domainEventDispatcher.DispatchAsync( + [new UserAccessContextChangedDomainEvent(user.Sub)], + cancellationToken + ); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsValidator.cs b/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsValidator.cs new file mode 100644 index 0000000..bd2de1d --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpdateUserGroups/UpdateUserGroupsValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace StackDuel.Application.Commands.Users.UpdateUserGroups; + +internal sealed class UpdateUserGroupsValidator : AbstractValidator +{ + public UpdateUserGroupsValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.GroupIds).NotNull(); + RuleForEach(x => x.GroupIds).NotEmpty(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarCommand.cs b/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarCommand.cs new file mode 100644 index 0000000..0dc72bb --- /dev/null +++ b/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace StackDuel.Application.Commands.Users.UploadUserAvatar; + +public sealed record UploadUserAvatarCommand(Guid UserId, byte[] Content) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarHandler.cs b/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarHandler.cs new file mode 100644 index 0000000..141efe7 --- /dev/null +++ b/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarHandler.cs @@ -0,0 +1,65 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Images; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Application.Commands.Users.UploadUserAvatar; + +internal sealed class UploadUserAvatarHandler( + IValidator validator, + IUserWriteRepository userRepository, + IUserAvatarWriteRepository userAvatarRepository, + IAvatarBlobStorage avatarBlobStorage +) : AbstractCommandHandler(validator) +{ + private const int MaxAvatarsToKeep = 3; + + protected override async Task> HandleValidated( + UploadUserAvatarCommand request, + CancellationToken cancellationToken + ) + { + User? user = await userRepository.FindByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.NotFound(); + + ImageFormat format = ImageSignature.Detect(request.Content)!; + + var avatarId = Guid.NewGuid(); + string blobUrl = await avatarBlobStorage.UploadAsync( + request.UserId, + avatarId, + request.Content, + format.ContentType, + cancellationToken + ); + + var avatar = new UserAvatar(request.UserId, new ImageUrl(blobUrl)); + await userAvatarRepository.AddAsync(avatar, cancellationToken); + + user.UpdateImageUrl(new ImageUrl(blobUrl)); + await userRepository.UpdateAsync(user, cancellationToken); + + await PruneOldAvatarsAsync(request.UserId, cancellationToken); + + return Result.Success(blobUrl); + } + + private async Task PruneOldAvatarsAsync(Guid userId, CancellationToken cancellationToken) + { + IReadOnlyList avatars = await userAvatarRepository.GetAllByUserIdNewestFirstAsync( + userId, + cancellationToken + ); + + foreach (UserAvatar stale in avatars.Skip(MaxAvatarsToKeep)) + { + await avatarBlobStorage.DeleteAsync(stale.ImageUrl.Value, cancellationToken); + await userAvatarRepository.DeleteAsync(stale, cancellationToken); + } + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarValidator.cs b/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarValidator.cs new file mode 100644 index 0000000..70e77a4 --- /dev/null +++ b/StackDuel.Application/Commands/Users/UploadUserAvatar/UploadUserAvatarValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using StackDuel.Application.Images; + +namespace StackDuel.Application.Commands.Users.UploadUserAvatar; + +internal sealed class UploadUserAvatarValidator : AbstractValidator +{ + public static readonly int MaxContentBytes = 5 * 1024 * 1024; + + public UploadUserAvatarValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.Content) + .NotEmpty() + .Must(content => content.Length <= MaxContentBytes) + .WithMessage($"Image must be {MaxContentBytes / (1024 * 1024)} MB or smaller.") + .Must(content => ImageSignature.Detect(content) is not null) + .WithMessage( + $"Image must be one of: {string.Join(", ", ImageSignature.SupportedFormats.Select(f => f.ContentType))}." + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserCommand.cs b/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserCommand.cs new file mode 100644 index 0000000..5a70e2b --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserCommand.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Application.Commands.Users.UpsertUser; + +internal sealed record UpsertUserCommand( + string Sub, + string? Username, + string? ImageUrl, + string? Bio, + IReadOnlyList? LanguageIds = null, + string? Tenant = null +) : ICommand; \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserHandler.cs b/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserHandler.cs new file mode 100644 index 0000000..b545d02 --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserHandler.cs @@ -0,0 +1,99 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Events; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.Factories; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Application.Commands.Users.UpsertUser; + +internal sealed partial class UpsertUserHandler( + IValidator validator, + IAggregateFactory userFactory, + IUsernameGeneratorService usernameGenerator, + IUserWriteRepository userRepository, + IDomainEventDispatcher domainEventDispatcher, + UserContext userContext +) : AbstractCommandHandler(validator) +{ + protected override async Task HandleValidated( + UpsertUserCommand request, + CancellationToken cancellationToken + ) + { + if (userContext.User is null) + return await CreateUserAsync(request, cancellationToken); + + User? user = await userRepository.FindBySubAsync(request.Sub, cancellationToken); + + return user is null + ? await CreateUserAsync(request, cancellationToken) + : await UpdateUserAsync(user, request, cancellationToken); + } + + private async Task CreateUserAsync(UpsertUserCommand request, CancellationToken cancellationToken) + { + string username = string.IsNullOrWhiteSpace(request.Username) + ? usernameGenerator.Generate() + : request.Username; + + User newUser = userFactory.Create( + new CreateUserParams(username, request.Sub, request.ImageUrl, request.Tenant) + ); + newUser.UpdateBio(!string.IsNullOrWhiteSpace(request.Bio) ? new Bio(request.Bio) : null); + + if (!string.IsNullOrWhiteSpace(request.Username)) + newUser.CompleteSetup(); + + await userRepository.AddAsync(newUser, cancellationToken); + await domainEventDispatcher.DispatchAsync(newUser.PopDomainEvents(), cancellationToken); + + if (request.LanguageIds is not null) + await userRepository.SetLanguagePreferencesAsync(newUser.Id, request.LanguageIds, cancellationToken); + + return Result.Success(); + } + + private async Task UpdateUserAsync( + User user, + UpsertUserCommand request, + CancellationToken cancellationToken + ) + { + bool usernameChanged = + !string.IsNullOrWhiteSpace(request.Username) && request.Username != user.Username.Value; + + if (usernameChanged) + { + if ( + user.UsernameLastChangedAt.HasValue + && DateTime.UtcNow - user.UsernameLastChangedAt.Value + < TimeSpan.FromDays(User.MaxDaysUntilUsernameChange) + ) + return Result.Invalid( + new ValidationError("Username", "Username can only be changed once every 30 days.") + ); + + user.ChangeUsername(new Username(request.Username!)); + } + + if (user.SetupCompletedAt is null && !string.IsNullOrWhiteSpace(request.Username)) + user.CompleteSetup(); + + if (request.Bio is not null) + user.UpdateBio(!string.IsNullOrWhiteSpace(request.Bio) ? new Bio(request.Bio) : null); + + if (user.ImageUrl is null && !string.IsNullOrWhiteSpace(request.ImageUrl)) + user.UpdateImageUrl(new ImageUrl(request.ImageUrl)); + + await userRepository.UpdateAsync(user, cancellationToken); + + if (request.LanguageIds is not null) + await userRepository.SetLanguagePreferencesAsync(user.Id, request.LanguageIds, cancellationToken); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserValidator.cs b/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserValidator.cs new file mode 100644 index 0000000..3d2603b --- /dev/null +++ b/StackDuel.Application/Commands/Users/UpsertUser/UpsertUserValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Application.Commands.Users.UpsertUser; + +internal sealed class UpsertUserValidator : AbstractValidator +{ + public UpsertUserValidator() + { + RuleFor(x => x.Username).MaximumLength(Username.MaxLength).When(x => x.Username is not null); + RuleFor(x => x.ImageUrl).MaximumLength(ImageUrl.MaxLength); + RuleFor(x => x.Bio).MaximumLength(Bio.MaxLength); + RuleFor(x => x.LanguageIds) + .Must(ids => ids is null || ids.Distinct().Count() == ids.Count) + .WithMessage("Language preferences cannot contain duplicates."); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/AzureStorageOptions.cs b/StackDuel.Application/Configuration/AzureStorageOptions.cs new file mode 100644 index 0000000..4c3475c --- /dev/null +++ b/StackDuel.Application/Configuration/AzureStorageOptions.cs @@ -0,0 +1,14 @@ +using StackDuel.Application.Settings; + +namespace StackDuel.Application.Configuration; + +public sealed class AzureStorageOptions : IOption +{ + public static string SectionName => "AzureStorage"; + + public string ConnectionString { get; init; } = string.Empty; + + public string AvatarContainerName { get; init; } = "avatars"; + + public bool AutoCreateContainer { get; init; } = false; +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/ConnectionStringOptions.cs b/StackDuel.Application/Configuration/ConnectionStringOptions.cs new file mode 100644 index 0000000..726067d --- /dev/null +++ b/StackDuel.Application/Configuration/ConnectionStringOptions.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Application.Settings; + +public sealed class ConnectionStringOptions : IOption +{ + public static string SectionName => "ConnectionStrings"; + + public required string DefaultConnection { get; init; } +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/ExecutionEngineOptions.cs b/StackDuel.Application/Configuration/ExecutionEngineOptions.cs new file mode 100644 index 0000000..27892ac --- /dev/null +++ b/StackDuel.Application/Configuration/ExecutionEngineOptions.cs @@ -0,0 +1,33 @@ +using StackDuel.Application.Settings; + +namespace StackDuel.Application.Configuration; + +public sealed class ExecutionEngineOptions : IOption +{ + public static string SectionName => "ExecutionEngines"; + + public Judge0Options Judge0 { get; init; } = new(); + + public JsxTranspilerOptions JsxTranspiler { get; init; } = new(); +} + +public sealed class JsxTranspilerOptions +{ + public string EsbuildPath { get; init; } = "esbuild"; + public int TimeoutSeconds { get; init; } = 10; +} + +public sealed class Judge0Options +{ + public bool Enabled { get; init; } = true; + public bool RunWorker { get; init; } = true; + public string BaseUrl { get; init; } = string.Empty; + public string ApiKey { get; init; } = string.Empty; + public string Host { get; init; } = string.Empty; + public bool ShouldWait { get; init; } = false; + public bool IsEncoded { get; init; } = true; + public int DefaultTimeoutInSeconds { get; init; } = 10; + public bool UseCallback { get; init; } = false; + public string CallbackSecret { get; init; } = string.Empty; + public string PublicBaseUrl { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/LanguageServerOptions.cs b/StackDuel.Application/Configuration/LanguageServerOptions.cs new file mode 100644 index 0000000..196f710 --- /dev/null +++ b/StackDuel.Application/Configuration/LanguageServerOptions.cs @@ -0,0 +1,21 @@ +using StackDuel.Application.Settings; + +namespace StackDuel.Application.Configuration; + +public sealed class LanguageServerOptions : IOption +{ + public static string SectionName => "LanguageServer"; + + public bool Enabled { get; init; } = false; + public int MaxConcurrentSessions { get; init; } = 10; + public int IdleTimeoutMinutes { get; init; } = 10; + public JavaLanguageServerOptions Java { get; init; } = new(); +} + +public sealed class JavaLanguageServerOptions +{ + public string JdtlsLauncherJarPath { get; init; } = string.Empty; + public string JdtlsConfigDirectory { get; init; } = string.Empty; + public string JdtlsRuntimeJavaHome { get; init; } = string.Empty; + public string JavaHome { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/MessageBusOptions.cs b/StackDuel.Application/Configuration/MessageBusOptions.cs new file mode 100644 index 0000000..afde0e1 --- /dev/null +++ b/StackDuel.Application/Configuration/MessageBusOptions.cs @@ -0,0 +1,29 @@ +using StackDuel.Application.Settings; + +namespace StackDuel.Application.Configuration; + +public sealed class MessageBusOptions : IOption +{ + public static string SectionName => "MessageBus"; + + public string Transport { get; init; } = "RabbitMQ"; + + public RabbitMqOptions RabbitMQ { get; init; } = new(); + + public AzureServiceBusOptions AzureServiceBus { get; init; } = new(); +} + +public sealed class RabbitMqOptions +{ + public string Host { get; init; } = "localhost"; + public string VirtualHost { get; init; } = "/"; + public string Username { get; init; } = "guest"; + public string Password { get; init; } = "guest"; + public int ConsumerConcurrency { get; init; } = 2; +} + +public sealed class AzureServiceBusOptions +{ + public string ConnectionString { get; init; } = string.Empty; + public int MaxConcurrentCalls { get; init; } = 2; +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/Option.cs b/StackDuel.Application/Configuration/Option.cs new file mode 100644 index 0000000..e919d02 --- /dev/null +++ b/StackDuel.Application/Configuration/Option.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Settings; + +public interface IOption +{ + static abstract string SectionName { get; } +} \ No newline at end of file diff --git a/StackDuel.Application/Configuration/QuartzOptions.cs b/StackDuel.Application/Configuration/QuartzOptions.cs new file mode 100644 index 0000000..c0a2036 --- /dev/null +++ b/StackDuel.Application/Configuration/QuartzOptions.cs @@ -0,0 +1,88 @@ +namespace StackDuel.Application.Configuration; + +using StackDuel.Application.Settings; + +public sealed class JobScheduleOptions : IOption +{ + public static string SectionName => "Quartz"; + + public GameExpirySweepJobOptions GameExpirySweepJob { get; init; } = new(); + + public SubmissionCleanupJobOptions SubmissionCleanupJob { get; init; } = new(); + public SubmissionJobProcessorJobOptions SubmissionJobProcessorJob { get; init; } = new(); + public TestCaseGenerationJobProcessorJobOptions TestCaseGenerationJobProcessorJob { get; init; } = new(); + public TestCaseRefreshJobOptions TestCaseRefreshJob { get; init; } = new(); + public ProblemValidationJobProcessorJobOptions ProblemValidationJobProcessorJob { get; init; } = new(); + public AvatarCleanupJobOptions AvatarCleanupJob { get; init; } = new(); + public DailyChallengeAssignmentJobOptions DailyChallengeAssignmentJob { get; init; } = new(); +} + +public sealed class AvatarCleanupJobOptions +{ + public string CronExpression { get; init; } = "0 0 4 * * ?"; +} + +public sealed class DailyChallengeAssignmentJobOptions +{ + public string CronExpression { get; init; } = "0 0 0 * * ?"; +} + +public sealed class SubmissionCleanupJobOptions +{ + /// + /// Quartz cron expression for the scheduled run. Defaults to the top of every hour. + /// + public string CronExpression { get; init; } = "0 0 * * * ?"; +} + +public sealed class SubmissionJobProcessorJobOptions +{ + /// + /// Quartz cron expression. Defaults to every 30 seconds as a fallback for missed queue messages. + /// + public string CronExpression { get; init; } = "0/30 * * * * ?"; +} + +public sealed class TestCaseGenerationJobProcessorJobOptions +{ + /// + /// Quartz cron expression. Pending jobs are normally picked up immediately via a published + /// TestCaseGenerationJobContinuationMessage (see RabbitMqConsumerService/AzureServiceBusConsumerService); + /// this sweep only exists as a backstop for a message that was lost or never published. + /// Defaults to every 2 minutes. + /// + public string CronExpression { get; init; } = "0 */2 * * * ?"; +} + +public sealed class TestCaseRefreshJobOptions +{ + /// + /// Quartz cron expression for the weekly re-roll of every published problem's generated + /// hidden test case pool. Defaults to Friday 11:30pm (server local time, no explicit time + /// zone is configured on the trigger) — late enough to be off-hours, and deliberately + /// ahead of the weekend rather than Sunday night, so a bad regeneration surfaces with two + /// days to fix it before Monday traffic instead of a few hours. + /// + public string CronExpression { get; init; } = "0 30 23 ? * FRI"; +} + +public sealed class ProblemValidationJobProcessorJobOptions +{ + /// + /// Quartz cron expression. Pending/AwaitingGeneration jobs are normally advanced immediately + /// via published ProblemValidationJobContinuationMessage/TestCaseGenerationJobCompletedMessage + /// messages; this sweep only exists as a backstop for a message that was lost or never published. + /// Defaults to every 2 minutes. + /// + public string CronExpression { get; init; } = "0 */2 * * * ?"; +} + +public sealed class GameExpirySweepJobOptions +{ + /// + /// Quartz cron expression for the game-expiry sweep backstop. This only exists to catch + /// games whose GameTimeExpiredMessage was lost or never scheduled — the normal path is + /// timely on its own, so this is intentionally infrequent. Defaults to every 10 minutes. + /// + public string CronExpression { get; init; } = "0 */10 * * * ?"; +} \ No newline at end of file diff --git a/StackDuel.Application/DailyChallenges/Dtos/TodaysDailyChallengeDto.cs b/StackDuel.Application/DailyChallenges/Dtos/TodaysDailyChallengeDto.cs new file mode 100644 index 0000000..0fd622a --- /dev/null +++ b/StackDuel.Application/DailyChallenges/Dtos/TodaysDailyChallengeDto.cs @@ -0,0 +1,13 @@ +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.DailyChallenges.Dtos; + +public sealed record TodaysDailyChallengeDto( + Guid ProblemId, + string Slug, + string Title, + DifficultyTier DifficultyTier, + bool SolvedByCurrentUser, + int CurrentStreak, + int LongestStreak +); \ No newline at end of file diff --git a/StackDuel.Application/DailyChallenges/Dtos/UpcomingDailyChallengeDto.cs b/StackDuel.Application/DailyChallenges/Dtos/UpcomingDailyChallengeDto.cs new file mode 100644 index 0000000..ad3b208 --- /dev/null +++ b/StackDuel.Application/DailyChallenges/Dtos/UpcomingDailyChallengeDto.cs @@ -0,0 +1,12 @@ +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.DailyChallenges.Dtos; + +public sealed record UpcomingDailyChallengeDto( + DateOnly Date, + Guid ProblemId, + string ProblemSlug, + string ProblemTitle, + DifficultyTier DifficultyTier, + ProblemStatus Status +); \ No newline at end of file diff --git a/StackDuel.Application/DailyChallenges/IDailyChallengeRepository.cs b/StackDuel.Application/DailyChallenges/IDailyChallengeRepository.cs new file mode 100644 index 0000000..d6c9c7d --- /dev/null +++ b/StackDuel.Application/DailyChallenges/IDailyChallengeRepository.cs @@ -0,0 +1,21 @@ +using StackDuel.Domain.DailyChallenges.Entities; + +namespace StackDuel.Application.DailyChallenges; + +public interface IDailyChallengeRepository +{ + Task FindByDateAsync(DateOnly date, CancellationToken cancellationToken = default); + + Task> GetAllOrderedByDateDescendingAsync( + CancellationToken cancellationToken = default + ); + + Task> GetUpcomingAsync( + DateOnly afterDate, + CancellationToken cancellationToken = default + ); + + Task AddAsync(DailyChallenge challenge, CancellationToken cancellationToken = default); + + Task UpdateAsync(DailyChallenge challenge, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Dashboard/Dtos/AdminDashboardStatsDto.cs b/StackDuel.Application/Dashboard/Dtos/AdminDashboardStatsDto.cs new file mode 100644 index 0000000..709861e --- /dev/null +++ b/StackDuel.Application/Dashboard/Dtos/AdminDashboardStatsDto.cs @@ -0,0 +1,16 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Dashboard.Dtos; + +public sealed record DailyUserCountDto(DateOnly Date, int Count); + +public sealed record FeedbackStatusCountDto(FeedbackStatus Status, int Count); + +public sealed record AdminDashboardStatsDto( + int TotalUsers, + int TotalProblems, + int TotalGames, + int TotalSubmissions, + IReadOnlyList NewUsersByDay, + IReadOnlyList FeedbackByStatus +); \ No newline at end of file diff --git a/StackDuel.Application/Dashboard/IDashboardReadRepository.cs b/StackDuel.Application/Dashboard/IDashboardReadRepository.cs new file mode 100644 index 0000000..b2266a0 --- /dev/null +++ b/StackDuel.Application/Dashboard/IDashboardReadRepository.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Dashboard.Dtos; + +namespace StackDuel.Application.Dashboard; + +public interface IDashboardReadRepository +{ + Task GetAdminDashboardStatsAsync( + int newUsersDays, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/DomainEventNotification.cs b/StackDuel.Application/Events/DomainEventNotification.cs new file mode 100644 index 0000000..a3e65e9 --- /dev/null +++ b/StackDuel.Application/Events/DomainEventNotification.cs @@ -0,0 +1,7 @@ +using MediatR; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Application.Events; + +public sealed record DomainEventNotification(T DomainEvent) : INotification + where T : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Application/Events/Game/GameCompletedDomainEventHandler.cs b/StackDuel.Application/Events/Game/GameCompletedDomainEventHandler.cs new file mode 100644 index 0000000..d677418 --- /dev/null +++ b/StackDuel.Application/Events/Game/GameCompletedDomainEventHandler.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Notifications; +using StackDuel.Domain.Games.Events; + +namespace StackDuel.Application.Events.Games; + +internal sealed partial class GameCompletedDomainEventHandler( + IGameNotificationService gameNotificationService, + ILogger logger +) : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + var e = notification.DomainEvent; + + try + { + await gameNotificationService.NotifyGameCompletedAsync(e.GameId, e.Status, e.EndedAt, cancellationToken); + } + catch (Exception ex) + { + // Push is purely a latency optimization over the client's own polling/re-fetch, so a + // failure here must never fault the command that finalized the game. + LogNotifyFailed(ex, e.GameId); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to push game-completed notification for game {GameId}; clients will pick up the new state on their next poll/fetch." + )] + private partial void LogNotifyFailed(Exception ex, Guid gameId); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Game/GameLobbyUpdatedDomainEventHandler.cs b/StackDuel.Application/Events/Game/GameLobbyUpdatedDomainEventHandler.cs new file mode 100644 index 0000000..dd74d1f --- /dev/null +++ b/StackDuel.Application/Events/Game/GameLobbyUpdatedDomainEventHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Notifications; +using StackDuel.Domain.Games.Events; + +namespace StackDuel.Application.Events.Games; + +internal sealed partial class GameLobbyUpdatedDomainEventHandler( + IGameNotificationService gameNotificationService, + ILogger logger +) : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + var e = notification.DomainEvent; + + try + { + await gameNotificationService.NotifyGameLobbyUpdatedAsync(e.GameId, cancellationToken); + } + catch (Exception ex) + { + LogNotifyFailed(ex, e.GameId); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to push lobby-updated notification for game {GameId}; clients will pick up the new roster on their next poll/fetch." + )] + private partial void LogNotifyFailed(Exception ex, Guid gameId); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Game/GameProgressUpdatedDomainEventHandler.cs b/StackDuel.Application/Events/Game/GameProgressUpdatedDomainEventHandler.cs new file mode 100644 index 0000000..cf2b441 --- /dev/null +++ b/StackDuel.Application/Events/Game/GameProgressUpdatedDomainEventHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Notifications; +using StackDuel.Domain.Games.Events; + +namespace StackDuel.Application.Events.Games; + +internal sealed partial class GameProgressUpdatedDomainEventHandler( + IGameNotificationService gameNotificationService, + ILogger logger +) : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + var e = notification.DomainEvent; + + try + { + await gameNotificationService.NotifyGameProgressUpdatedAsync(e.GameId, cancellationToken); + } + catch (Exception ex) + { + LogNotifyFailed(ex, e.GameId); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to push progress-updated notification for game {GameId}; clients will pick up the new score on their next poll/fetch." + )] + private partial void LogNotifyFailed(Exception ex, Guid gameId); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Game/GameStartedDomainEventHandler.cs b/StackDuel.Application/Events/Game/GameStartedDomainEventHandler.cs new file mode 100644 index 0000000..5c8303d --- /dev/null +++ b/StackDuel.Application/Events/Game/GameStartedDomainEventHandler.cs @@ -0,0 +1,63 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Games; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Events; + +namespace StackDuel.Application.Events.Games; + +internal sealed partial class GameStartedDomainEventHandler( + IMessagePublisher messagePublisher, + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + ILogger logger +) : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + var e = notification.DomainEvent; + + DateTimeOffset scheduledEnqueueTimeUtc = e.StartedAt.AddSeconds(e.TimeLimitInSeconds); + + long sequenceNumber; + try + { + sequenceNumber = await messagePublisher.PublishScheduledAsync( + new GameTimeExpiredMessage(e.GameId, e.StartedAt, RescheduleCount: 0), + scheduledEnqueueTimeUtc, + cancellationToken + ); + } + catch (Exception ex) + { + // If scheduling fails, the game still gets finalized eventually by the sweep job + // backstop — log loudly so it's visible, but don't fault game-start over it. + LogScheduleFailed(ex, e.GameId); + return; + } + + // 0 is the RabbitMQ placeholder (no real handle exists there) — only persist a + // meaningful handle for transports that can actually use it to cancel later. + if (sequenceNumber == 0) + return; + + Game? game = await gameReadRepository.FindGameByIdAsync(e.GameId, cancellationToken); + if (game is null) + return; + + game.AttachScheduledExpiry(sequenceNumber); + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + } + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Failed to schedule GameTimeExpiredMessage for game {GameId}; the sweep job will finalize it if scheduling never recovers." + )] + private partial void LogScheduleFailed(Exception ex, Guid gameId); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Game/RecordLeaderboardScoresOnGameCompletedHandler.cs b/StackDuel.Application/Events/Game/RecordLeaderboardScoresOnGameCompletedHandler.cs new file mode 100644 index 0000000..03ae829 --- /dev/null +++ b/StackDuel.Application/Events/Game/RecordLeaderboardScoresOnGameCompletedHandler.cs @@ -0,0 +1,69 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Games; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Events; + +namespace StackDuel.Application.Events.Games; + +/// +/// Feeds every completed game's final scores into the (gameModeId, timeLimitInSeconds) +/// leaderboard, regardless of which path finalized the game (normal expiry, sweep-job backstop, +/// or forfeit) — mirrors how GameCompletedDomainEventHandler uses GameCompletedDomainEvent as the +/// single trigger point for the notification side. +/// +internal sealed partial class RecordLeaderboardScoresOnGameCompletedHandler( + IGameReadRepository gameReadRepository, + ILeaderboardWriteRepository leaderboardWriteRepository, + ILogger logger +) : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + var e = notification.DomainEvent; + + try + { + Game? game = await gameReadRepository.FindGameByIdAsync(e.GameId, cancellationToken); + if (game is null) + return; + + Leaderboard? leaderboard = await leaderboardWriteRepository.FindByGameModeAndTimeLimitAsync( + game.GameModeId, + game.TimeLimitInSeconds, + cancellationToken + ); + + if (leaderboard is null) + { + leaderboard = new Leaderboard(game.GameModeId, game.TimeLimitInSeconds); + foreach (GameParticipant participant in game.Participants) + leaderboard.RecordScore(participant.UserId, participant.Score); + + await leaderboardWriteRepository.AddAsync(leaderboard, cancellationToken); + return; + } + + foreach (GameParticipant participant in game.Participants) + leaderboard.RecordScore(participant.UserId, participant.Score); + + await leaderboardWriteRepository.SaveChangesAsync(leaderboard, cancellationToken); + } + catch (Exception ex) + { + // Leaderboard standings are a derived projection, not the source of truth for the + // game itself — a failure here must never fault the command that finalized the game. + LogUpdateFailed(ex, e.GameId); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to update leaderboard for completed game {GameId}; standings may be stale until the next completed game in that mode." + )] + private partial void LogUpdateFailed(Exception ex, Guid gameId); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/IDomainEventDispatcher.cs b/StackDuel.Application/Events/IDomainEventDispatcher.cs new file mode 100644 index 0000000..eed346b --- /dev/null +++ b/StackDuel.Application/Events/IDomainEventDispatcher.cs @@ -0,0 +1,8 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Application.Events; + +public interface IDomainEventDispatcher +{ + Task DispatchAsync(IEnumerable domainEvents, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/MediatRDomainEventDispatcher.cs b/StackDuel.Application/Events/MediatRDomainEventDispatcher.cs new file mode 100644 index 0000000..cd090ac --- /dev/null +++ b/StackDuel.Application/Events/MediatRDomainEventDispatcher.cs @@ -0,0 +1,20 @@ +using MediatR; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Application.Events; + +internal sealed class MediatRDomainEventDispatcher(IPublisher publisher) : IDomainEventDispatcher +{ + public async Task DispatchAsync( + IEnumerable domainEvents, + CancellationToken cancellationToken = default + ) + { + foreach (var domainEvent in domainEvents) + { + var notificationType = typeof(DomainEventNotification<>).MakeGenericType(domainEvent.GetType()); + var notification = (INotification)Activator.CreateInstance(notificationType, domainEvent)!; + await publisher.Publish(notification, cancellationToken); + } + } +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Submissions/SubmissionCompletedDomainEventHandler.cs b/StackDuel.Application/Events/Submissions/SubmissionCompletedDomainEventHandler.cs new file mode 100644 index 0000000..d7eb72c --- /dev/null +++ b/StackDuel.Application/Events/Submissions/SubmissionCompletedDomainEventHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Notifications; +using StackDuel.Domain.Submissions.Events; + +namespace StackDuel.Application.Events.Submissions; + +internal sealed partial class SubmissionCompletedDomainEventHandler( + ISubmissionNotificationService submissionNotificationService, + IGameNotificationService gameNotificationService, + ILogger logger +) : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + var e = notification.DomainEvent; + try + { + await submissionNotificationService.NotifySubmissionCompletedAsync( + e.UserId, + e.SubmissionId, + cancellationToken + ); + } + catch (Exception ex) + { + LogNotifyFailed(ex, e.SubmissionId); + } + + if (e.GameId is not { } gameId) + return; + + try + { + await gameNotificationService.NotifyGameParticipantAttemptedAsync( + gameId, + e.UserId, + e.Status, + DateTime.UtcNow, + cancellationToken + ); + } + catch (Exception ex) + when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + LogNotifyGameAttemptFailed(ex, gameId, e.SubmissionId); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to push submission-completed notification for submission {SubmissionId}; clients will pick up the new state on their next poll." + )] + private partial void LogNotifyFailed(Exception ex, Guid submissionId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to push game-participant-attempted notification for game {GameId}, submission {SubmissionId}; the live activity feed will just miss this entry." + )] + private partial void LogNotifyGameAttemptFailed(Exception ex, Guid gameId, Guid submissionId); +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Submissions/SubmissionCreatedDomainEventHandler.cs b/StackDuel.Application/Events/Submissions/SubmissionCreatedDomainEventHandler.cs new file mode 100644 index 0000000..3c6e07d --- /dev/null +++ b/StackDuel.Application/Events/Submissions/SubmissionCreatedDomainEventHandler.cs @@ -0,0 +1,21 @@ +using MediatR; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Domain.Submissions.Events; + +namespace StackDuel.Application.Events.Submissions; + +internal sealed class SubmissionCreatedDomainEventHandler(IMessagePublisher messagePublisher) + : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + await messagePublisher.PublishAsync( + new SubmissionCreatedMessage(notification.DomainEvent.SubmissionId), + cancellationToken + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Users/UserAccessContextChangedDomainEventHandler.cs b/StackDuel.Application/Events/Users/UserAccessContextChangedDomainEventHandler.cs new file mode 100644 index 0000000..15db662 --- /dev/null +++ b/StackDuel.Application/Events/Users/UserAccessContextChangedDomainEventHandler.cs @@ -0,0 +1,18 @@ +using MediatR; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Users.Events; + +namespace StackDuel.Application.Events.Users; + +internal sealed class UserAccessContextChangedDomainEventHandler(IUserService userService) + : INotificationHandler> +{ + public Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + userService.InvalidateAccessContext(notification.DomainEvent.Sub); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Events/Users/UserCreatedDomainEventHandler.cs b/StackDuel.Application/Events/Users/UserCreatedDomainEventHandler.cs new file mode 100644 index 0000000..cf3feaa --- /dev/null +++ b/StackDuel.Application/Events/Users/UserCreatedDomainEventHandler.cs @@ -0,0 +1,22 @@ +using MediatR; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Events; + +namespace StackDuel.Application.Events.Users; + +internal sealed class UserCreatedDomainEventHandler(IUserWriteRepository userWriteRepository) + : INotificationHandler> +{ + public async Task Handle( + DomainEventNotification notification, + CancellationToken cancellationToken + ) + { + await userWriteRepository.AddToGroupAsync( + notification.DomainEvent.UserId, + WellKnownAuthorization.DefaultUserGroup, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionAssets/AdditionalFileBundleReadRepository.cs b/StackDuel.Application/ExecutionAssets/AdditionalFileBundleReadRepository.cs new file mode 100644 index 0000000..d4a1375 --- /dev/null +++ b/StackDuel.Application/ExecutionAssets/AdditionalFileBundleReadRepository.cs @@ -0,0 +1,8 @@ +using StackDuel.Domain.ExecutionAssets.Entities; + +namespace StackDuel.Application.ExecutionAssets; + +public interface IAdditionalFileBundleReadRepository +{ + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/IBatchCodeTemplateStrategy.cs b/StackDuel.Application/ExecutionEngine/IBatchCodeTemplateStrategy.cs new file mode 100644 index 0000000..53776c2 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/IBatchCodeTemplateStrategy.cs @@ -0,0 +1,56 @@ +namespace StackDuel.Application.ExecutionEngine; + +/// Strategy used to compare a case's actual output against its expected one in a batched run. +public enum BatchAssertStrategy +{ + ExactMatch, + FloatTolerance, + SetEquality, +} + +public sealed record BatchTestCase( + Guid TestCaseId, + IReadOnlyList Inputs, + string ExpectedOutput, + BatchAssertStrategy AssertStrategy, + decimal? Tolerance = null, + bool CaseSensitive = true +); + +public enum BatchCaseStatus +{ + Accepted, + WrongAnswer, + TimedOut, + RuntimeError, +} + +public sealed record BatchCaseResult( + Guid TestCaseId, + BatchCaseStatus Status, + string? ActualOutput, + string? ErrorMessage, + int? RuntimeMs = null +); + +/// +/// Renders every test case for one submission into a single source file that runs under the +/// language's native test framework (e.g. Vitest for JavaScript) and parses that framework's +/// own result report back into a per-case verdict — one Judge0 submission covers every case +/// instead of one submission each. Only used for a whose +/// language version opted into . +/// +public interface IBatchCodeTemplateStrategy +{ + string LanguageName { get; } + + string RenderBatch( + string userCode, + string? functionName, + IReadOnlyList cases, + int perCaseTimeoutMs, + IReadOnlyList? additionalFiles = null + ); + + IReadOnlyList ParseBatchOutput(string? stdout, IReadOnlyList orderedTestCaseIds); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/IBatchCodeTemplateStrategyResolver.cs b/StackDuel.Application/ExecutionEngine/IBatchCodeTemplateStrategyResolver.cs new file mode 100644 index 0000000..eb3c7e1 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/IBatchCodeTemplateStrategyResolver.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.ExecutionEngine; + +public interface IBatchCodeTemplateStrategyResolver +{ + IBatchCodeTemplateStrategy Resolve(string languageName); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/ICodeTemplateStrategy.cs b/StackDuel.Application/ExecutionEngine/ICodeTemplateStrategy.cs new file mode 100644 index 0000000..7fb4231 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/ICodeTemplateStrategy.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace StackDuel.Application.ExecutionEngine; + +public sealed record CodeTemplateSourceFile(string Path, string Content); + +public sealed record CodeTemplateContext( + string UserCode, + string? FunctionName, + IReadOnlyList Inputs, + IReadOnlyList? AdditionalFiles = null +); + +public sealed record CodeTemplateInput(string Value, string ValueType); + +public sealed record ParsedExecutionOutput(string? UserLogs, string? ActualResult); + +public interface ICodeTemplateStrategy +{ + string LanguageName { get; } + + string Render(CodeTemplateContext context); + + byte[]? BuildAdditionalFiles(CodeTemplateContext context) => null; + + ParsedExecutionOutput ParseOutput(string? stdout) + { + if (string.IsNullOrEmpty(stdout)) + return new ParsedExecutionOutput(UserLogs: null, ActualResult: null); + + string[] lines = stdout.TrimEnd('\r', '\n').Split(["\r\n", "\r", "\n"], StringSplitOptions.None); + + if (lines.Length == 1) + return new ParsedExecutionOutput(UserLogs: null, ActualResult: lines[0]); + + string actualResult = lines[^1]; + string[] logLines = lines[..^1]; + + string? userLogs = logLines.Length == 0 ? null : string.Join(",", logLines); + + return new ParsedExecutionOutput( + UserLogs: userLogs, + ActualResult: string.IsNullOrWhiteSpace(actualResult) ? null : actualResult + ); + } + + string BuildStdin(IReadOnlyList inputs) + { + var values = inputs.Select(ToJsonElement).ToArray(); + return JsonSerializer.Serialize(values); + } + + private static JsonElement ToJsonElement(CodeTemplateInput input) + { + try + { + return JsonSerializer.Deserialize(input.Value); + } + catch (JsonException) + { + return JsonSerializer.SerializeToElement(input.Value); + } + } +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/ICodeTemplateStrategyResolver.cs b/StackDuel.Application/ExecutionEngine/ICodeTemplateStrategyResolver.cs new file mode 100644 index 0000000..0e12a65 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/ICodeTemplateStrategyResolver.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.ExecutionEngine; + +public interface ICodeTemplateStrategyResolver +{ + ICodeTemplateStrategy Resolve(string languageName); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/IExecutionEngineStrategy.cs b/StackDuel.Application/ExecutionEngine/IExecutionEngineStrategy.cs new file mode 100644 index 0000000..7c9d6d4 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/IExecutionEngineStrategy.cs @@ -0,0 +1,46 @@ +namespace StackDuel.Application.ExecutionEngine; + +public sealed record ExecutionEngineSubmission( + string SourceCode, + int LanguageId, + string? Stdin, + int? TimeLimitMs, + int? MemoryLimitKb, + byte[]? AdditionalFiles = null +); + +public sealed record ExecutionEngineResult( + string Token, + string? Stdout, + string? Stderr, + string? CompileOutput, + int? RuntimeMs, + int? MemoryUsedKb, + ExecutionEngineResultStatus Status +); + +public enum ExecutionEngineResultStatus +{ + Queued = 1, + Processing = 2, + Accepted = 3, + WrongAnswer = 4, + TimeLimitExceeded = 5, + CompilationError = 6, + RuntimeError = 7, + InternalError = 8, +} + +public interface IExecutionEngineStrategy +{ + Task> SubmitBatchAsync( + IReadOnlyList submissions, + Guid? submissionId = null, + CancellationToken cancellationToken = default + ); + + Task> PollBatchAsync( + IReadOnlyList tokens, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/IJsxTranspiler.cs b/StackDuel.Application/ExecutionEngine/IJsxTranspiler.cs new file mode 100644 index 0000000..9bebe5f --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/IJsxTranspiler.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.ExecutionEngine; + +public interface IJsxTranspiler +{ + string Transpile(string source); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/IStepHandler.cs b/StackDuel.Application/ExecutionEngine/IStepHandler.cs new file mode 100644 index 0000000..a7ca183 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/IStepHandler.cs @@ -0,0 +1,29 @@ +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.SubmissionJobs.Entities; + +namespace StackDuel.Application.ExecutionEngine; + +public sealed record StepHandlerContext( + SubmissionJob Job, + ExecutionPipelineStep Step, + SubmissionJobAttempt Attempt, + CancellationToken CancellationToken +); + +/// Whether the step completed successfully. +/// Error message when is false. +/// Optional JSON payload to persist as the attempt request. +/// Optional JSON payload to persist as the attempt response. +public sealed record StepHandlerResult( + bool Succeeded, + string? Error = null, + string? RequestPayload = null, + string? ResponsePayload = null +); + +public interface IStepHandler +{ + bool CanHandle(Domain.ExecutionPipelines.Enums.ExecutionPipelineStepType stepType); + Task ExecuteAsync(StepHandlerContext context); +} \ No newline at end of file diff --git a/StackDuel.Application/ExecutionEngine/IStepHandlerRegistry.cs b/StackDuel.Application/ExecutionEngine/IStepHandlerRegistry.cs new file mode 100644 index 0000000..7c167e0 --- /dev/null +++ b/StackDuel.Application/ExecutionEngine/IStepHandlerRegistry.cs @@ -0,0 +1,8 @@ +using StackDuel.Domain.ExecutionPipelines.Enums; + +namespace StackDuel.Application.ExecutionEngine; + +public interface IStepHandlerRegistry +{ + IStepHandler Resolve(ExecutionPipelineStepType stepType); +} \ No newline at end of file diff --git a/StackDuel.Application/FeatureFlags/Dtos/FeatureFlagAdminDto.cs b/StackDuel.Application/FeatureFlags/Dtos/FeatureFlagAdminDto.cs new file mode 100644 index 0000000..953d4fe --- /dev/null +++ b/StackDuel.Application/FeatureFlags/Dtos/FeatureFlagAdminDto.cs @@ -0,0 +1,20 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Application.FeatureFlags.Dtos; + +public sealed record FeatureFlagAdminDto( + Guid Id, + string Key, + string Name, + string Description, + bool DefaultEnabled, + int RolloutPercentage, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + IReadOnlyList UserOverrides, + IReadOnlyList GroupOverrides +); + +public sealed record FeatureFlagUserOverrideDto(Guid UserId, DecisionEffect Effect); + +public sealed record FeatureFlagGroupOverrideDto(Guid GroupId, DecisionEffect Effect); \ No newline at end of file diff --git a/StackDuel.Application/FeatureFlags/FeatureFlagBucketing.cs b/StackDuel.Application/FeatureFlags/FeatureFlagBucketing.cs new file mode 100644 index 0000000..7a9676c --- /dev/null +++ b/StackDuel.Application/FeatureFlags/FeatureFlagBucketing.cs @@ -0,0 +1,31 @@ +using System.Text; + +namespace StackDuel.Application.FeatureFlags; + +public static class FeatureFlagBucketing +{ + private const ulong FnvOffsetBasis = 14695981039346656037UL; + private const ulong FnvPrime = 1099511628211UL; + + public static int GetBucket(string flagKey, Guid userId) + { + ulong hash = FnvOffsetBasis; + foreach (byte b in Encoding.UTF8.GetBytes($"{flagKey}:{userId:D}")) + { + hash ^= b; + hash *= FnvPrime; + } + + return (int)(hash % 100); + } + + public static bool IsInRollout(string flagKey, Guid userId, int rolloutPercentage) + { + if (rolloutPercentage <= 0) + return false; + if (rolloutPercentage >= 100) + return true; + + return GetBucket(flagKey, userId) < rolloutPercentage; + } +} \ No newline at end of file diff --git a/StackDuel.Application/FeatureFlags/FeatureFlagEvaluator.cs b/StackDuel.Application/FeatureFlags/FeatureFlagEvaluator.cs new file mode 100644 index 0000000..19eed14 --- /dev/null +++ b/StackDuel.Application/FeatureFlags/FeatureFlagEvaluator.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.FeatureFlags; + +namespace StackDuel.Application.FeatureFlags; + +public static class FeatureFlagEvaluator +{ + public static bool Evaluate(FeatureFlagEvaluationData data, Guid? userId) + { + if (data.UserOverrideEffect == DecisionEffect.Deny) + return false; + if (data.UserOverrideEffect == DecisionEffect.Allow) + return true; + + if (data.GroupOverrideEffect == DecisionEffect.Deny) + return false; + if (data.GroupOverrideEffect == DecisionEffect.Allow) + return true; + + if (userId is Guid uid && FeatureFlagBucketing.IsInRollout(data.Key, uid, data.RolloutPercentage)) + return true; + + return data.DefaultEnabled; + } +} \ No newline at end of file diff --git a/StackDuel.Application/FeatureFlags/IFeatureFlagAdminReadRepository.cs b/StackDuel.Application/FeatureFlags/IFeatureFlagAdminReadRepository.cs new file mode 100644 index 0000000..408778a --- /dev/null +++ b/StackDuel.Application/FeatureFlags/IFeatureFlagAdminReadRepository.cs @@ -0,0 +1,10 @@ +using StackDuel.Application.FeatureFlags.Dtos; + +namespace StackDuel.Application.FeatureFlags; + +public interface IFeatureFlagAdminReadRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByKeyAsync(string key, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Feedback/Dtos/AdminFeedbackDetailDto.cs b/StackDuel.Application/Feedback/Dtos/AdminFeedbackDetailDto.cs new file mode 100644 index 0000000..96d0ee9 --- /dev/null +++ b/StackDuel.Application/Feedback/Dtos/AdminFeedbackDetailDto.cs @@ -0,0 +1,19 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Feedback.Dtos; + +public sealed record AdminFeedbackDetailDto( + Guid Id, + FeedbackType Type, + FeedbackStatus Status, + string Message, + int? Rating, + FeedbackContextType ContextType, + Guid? ContextEntityId, + string? AdminNote, + string? PageUrl, + string? UserAgent, + FeedbackUserDto User, + DateTime CreatedAt, + DateTime? UpdatedAt +); \ No newline at end of file diff --git a/StackDuel.Application/Feedback/Dtos/AdminFeedbackListItemDto.cs b/StackDuel.Application/Feedback/Dtos/AdminFeedbackListItemDto.cs new file mode 100644 index 0000000..19ba332 --- /dev/null +++ b/StackDuel.Application/Feedback/Dtos/AdminFeedbackListItemDto.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Feedback.Dtos; + +public sealed record FeedbackUserDto(string Username, string? ImageUrl); + +public sealed record AdminFeedbackListItemDto( + Guid Id, + FeedbackType Type, + FeedbackStatus Status, + string Message, + int? Rating, + FeedbackContextType ContextType, + Guid? ContextEntityId, + FeedbackUserDto User, + DateTime CreatedAt, + DateTime? UpdatedAt +); \ No newline at end of file diff --git a/StackDuel.Application/Feedback/Dtos/SubmitFeedbackDto.cs b/StackDuel.Application/Feedback/Dtos/SubmitFeedbackDto.cs new file mode 100644 index 0000000..1cddd9d --- /dev/null +++ b/StackDuel.Application/Feedback/Dtos/SubmitFeedbackDto.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Feedback.Dtos; + +public sealed record SubmitFeedbackDto( + Guid UserId, + FeedbackType Type, + string Message, + int? Rating, + FeedbackContextType ContextType, + Guid? ContextEntityId, + string? PageUrl, + string? UserAgent +); \ No newline at end of file diff --git a/StackDuel.Application/Feedback/IFeedbackReadRepository.cs b/StackDuel.Application/Feedback/IFeedbackReadRepository.cs new file mode 100644 index 0000000..61975ea --- /dev/null +++ b/StackDuel.Application/Feedback/IFeedbackReadRepository.cs @@ -0,0 +1,20 @@ +using StackDuel.Application.Feedback.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Feedback; + +public interface IFeedbackReadRepository +{ + Task> GetAdminFeedbackPagedAsync( + PaginationRequest paginationRequest, + FeedbackType? type, + FeedbackStatus? status, + CancellationToken cancellationToken = default + ); + + Task FindAdminFeedbackByIdAsync( + Guid feedbackId, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/DifficultyRampProblemSelectionStrategy.cs b/StackDuel.Application/Games/DifficultyRampProblemSelectionStrategy.cs new file mode 100644 index 0000000..975b517 --- /dev/null +++ b/StackDuel.Application/Games/DifficultyRampProblemSelectionStrategy.cs @@ -0,0 +1,72 @@ +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.ValueObjects; +using System.Security.Cryptography; + +namespace StackDuel.Application.Games; + +internal sealed class DifficultyRampProblemSelectionStrategy( + string gameModeKey, + IProblemReadRepository problemReadRepository +) : IProblemSelectionStrategy +{ + private static readonly DifficultyBand[] Bands = + [ + new(0, 2, Difficulty.BeginnerMin, 120), + new(3, 5, 121, Difficulty.BeginnerMax), + new(6, 9, Difficulty.EasyMin, 350), + new(10, int.MaxValue, 351, Difficulty.EasyMax), + ]; + + public string GameModeKey { get; } = gameModeKey; + + public async Task SelectNextProblemIdAsync( + ProblemSelectionContext context, + CancellationToken cancellationToken = default + ) + { + DifficultyBand primaryBand = Bands.First(band => + context.RoundIndex >= band.FromRound && context.RoundIndex <= band.ToRound + ); + + List orderedBands = + [ + primaryBand, + .. Bands.Where(b => b != primaryBand).OrderBy(b => Math.Abs(b.FromRound - context.RoundIndex)), + ]; + + foreach (DifficultyBand band in orderedBands) + { + long selectionSeed = ComputeSelectionSeed(context.GameId, context.RoundIndex, band); + + Guid? problemId = await problemReadRepository.GetRandomProblemIdByDifficultyAsync( + context.PoolId, + band.MinDifficulty, + band.MaxDifficulty, + context.ExcludedProblemIds, + selectionSeed, + context.AllowedLanguageVersionIds, + cancellationToken + ); + + if (problemId.HasValue) + return problemId.Value; + } + + return null; + } + + private static long ComputeSelectionSeed(Guid gameId, int roundIndex, DifficultyBand band) + { + Span buffer = stackalloc byte[28]; + gameId.TryWriteBytes(buffer); + BitConverter.TryWriteBytes(buffer[16..], roundIndex); + BitConverter.TryWriteBytes(buffer[20..], band.MinDifficulty); + BitConverter.TryWriteBytes(buffer[24..], band.MaxDifficulty); + + Span hash = stackalloc byte[32]; + SHA256.HashData(buffer, hash); + return BitConverter.ToInt64(hash); + } + + private sealed record DifficultyBand(int FromRound, int ToRound, int MinDifficulty, int MaxDifficulty); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/AdminGameListItemDto.cs b/StackDuel.Application/Games/Dtos/AdminGameListItemDto.cs new file mode 100644 index 0000000..42c3b19 --- /dev/null +++ b/StackDuel.Application/Games/Dtos/AdminGameListItemDto.cs @@ -0,0 +1,23 @@ +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Games.Dtos; + +public sealed record AdminGameParticipantSummaryDto( + Guid UserId, + string Username, + int Score, + bool HasForfeited, + bool HasFinishedProblems +); + +public sealed record AdminGameListItemDto( + Guid GameId, + string GameModeKey, + string GameModeName, + GameStatus Status, + int TimeLimitInSeconds, + DateTime CreatedAt, + DateTime? StartedAt, + DateTime? EndedAt, + IReadOnlyList Participants +); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/AdminGamePlayerHistoryDto.cs b/StackDuel.Application/Games/Dtos/AdminGamePlayerHistoryDto.cs new file mode 100644 index 0000000..b5d4d7e --- /dev/null +++ b/StackDuel.Application/Games/Dtos/AdminGamePlayerHistoryDto.cs @@ -0,0 +1,20 @@ +namespace StackDuel.Application.Games.Dtos; + +public enum AdminGameHistoryEventType +{ + Accepted, + WrongAnswer, + Skipped, +} + +public sealed record AdminGameHistoryEventDto( + AdminGameHistoryEventType Type, + Guid ProblemId, + string ProblemTitle, + string ProblemSlug, + DateTime? OccurredAt, + Guid? SubmissionId, + string? LanguageName +); + +public sealed record AdminGamePlayerHistoryDto(Guid UserId, IReadOnlyList Events); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/DuelHeadToHeadRecordDto.cs b/StackDuel.Application/Games/Dtos/DuelHeadToHeadRecordDto.cs new file mode 100644 index 0000000..593cf23 --- /dev/null +++ b/StackDuel.Application/Games/Dtos/DuelHeadToHeadRecordDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Games.Dtos; + +public sealed record DuelHeadToHeadRecordDto(int Wins, int Losses, int Draws, int GamesPlayed); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/GameLobbySummaryDto.cs b/StackDuel.Application/Games/Dtos/GameLobbySummaryDto.cs new file mode 100644 index 0000000..4a00c9c --- /dev/null +++ b/StackDuel.Application/Games/Dtos/GameLobbySummaryDto.cs @@ -0,0 +1,16 @@ +namespace StackDuel.Application.Games.Dtos; + +public sealed record GameLobbySummaryDto( + Guid GameId, + string GameModeKey, + string GameModeName, + int TimeLimitInSeconds, + DateTime CreatedAt, + int MinPlayers, + int MaxPlayers, + int ParticipantCount, + string HostUsername, + bool IsHost, + bool IsParticipant, + IReadOnlyList TechStacks +); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/GameModeDto.cs b/StackDuel.Application/Games/Dtos/GameModeDto.cs new file mode 100644 index 0000000..ff5067b --- /dev/null +++ b/StackDuel.Application/Games/Dtos/GameModeDto.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Application.Games.Dtos; + +public sealed record GameModeTimeOptionDto(int DurationSeconds, bool IsDefault); + +public sealed record GameModeDto( + Guid Id, + string Key, + string Name, + string Description, + int MinPlayers, + int MaxPlayers, + IReadOnlyCollection TimeOptions +); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/GameProblemDto.cs b/StackDuel.Application/Games/Dtos/GameProblemDto.cs new file mode 100644 index 0000000..74a1fd4 --- /dev/null +++ b/StackDuel.Application/Games/Dtos/GameProblemDto.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Application.Games.Dtos; + +public sealed record GameCurrentProblemDto(Guid ProblemId); + +public sealed record GameParticipantScoreDto(Guid UserId, int Score, GameCurrentProblemDto? CurrentProblem); + +public sealed record SolvedProblemSubmissionDto(Guid ProblemId, Guid SubmissionId); + +public sealed record GameProblemHistoryDto( + Guid UserId, + IReadOnlyList SolvedProblemIds, + IReadOnlyList SolvedProblemSubmissions +); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/GameStateDto.cs b/StackDuel.Application/Games/Dtos/GameStateDto.cs new file mode 100644 index 0000000..68889e0 --- /dev/null +++ b/StackDuel.Application/Games/Dtos/GameStateDto.cs @@ -0,0 +1,31 @@ +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Games.Dtos; + +public sealed record GameParticipantDto( + Guid UserId, + string Username, + string? ImageUrl, + int SeatNumber, + DateTime JoinedAt, + int Score = 0, + GameCurrentProblemDto? CurrentProblem = null, + bool HasForfeited = false, + bool HasFinishedProblems = false, + int SkipsRemaining = 0 +); + +public sealed record GameStateDto( + Guid GameId, + Guid GameModeId, + string GameModeKey, + GameStatus Status, + int TimeLimitInSeconds, + DateTime CreatedAt, + DateTime? StartedAt, + DateTime? EndedAt, + IReadOnlyCollection Participants, + IReadOnlyList TechStacks, + string JoinCode, + bool SkipsEnabled = true +); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/MyActiveGameDto.cs b/StackDuel.Application/Games/Dtos/MyActiveGameDto.cs new file mode 100644 index 0000000..87f4440 --- /dev/null +++ b/StackDuel.Application/Games/Dtos/MyActiveGameDto.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Games.Dtos; + +public sealed record MyActiveGameDto( + Guid GameId, + string GameModeKey, + string GameModeName, + GameStatus Status, + int TimeLimitInSeconds, + DateTime CreatedAt, + DateTime? StartedAt, + int ParticipantCount, + int MaxPlayers, + bool IsHost, + string HostUsername, + IReadOnlyList TechStacks +); \ No newline at end of file diff --git a/StackDuel.Application/Games/Dtos/RecentSoloRushRunDto.cs b/StackDuel.Application/Games/Dtos/RecentSoloRushRunDto.cs new file mode 100644 index 0000000..9500338 --- /dev/null +++ b/StackDuel.Application/Games/Dtos/RecentSoloRushRunDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Games.Dtos; + +public sealed record RecentSoloRushRunDto(Guid UserId, int Score); \ No newline at end of file diff --git a/StackDuel.Application/Games/GameExpiryCanceller.cs b/StackDuel.Application/Games/GameExpiryCanceller.cs new file mode 100644 index 0000000..ba45b48 --- /dev/null +++ b/StackDuel.Application/Games/GameExpiryCanceller.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Games; + +internal sealed partial class GameExpiryCanceller( + IMessagePublisher messagePublisher, + ILogger logger +) : IGameExpiryCanceller +{ + public async Task CancelIfScheduledAsync(Game game, CancellationToken cancellationToken = default) + { + if (game.Status != GameStatus.Completed || game.ScheduledExpirySequenceNumber is not long sequenceNumber) + return; + + // Best-effort only: not every transport can cancel by handle (RabbitMQ's + // delayed-message-exchange plugin can't), and even where it can, the message may already + // be in flight. Either way the GameTimeExpiredMessage consumer will see this game as + // already-Completed and no-op, so a failed/unsupported cancel here is never a correctness + // problem — just a missed cost optimization. + try + { + await messagePublisher.CancelScheduledAsync(sequenceNumber, cancellationToken); + } + catch (NotSupportedException) + { + LogCancelNotSupported(game.Id); + } + catch (Exception ex) + { + LogCancelFailed(ex, game.Id); + } + + game.ClearScheduledExpiry(); + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Game {GameId} completed early; active transport doesn't support cancelling the scheduled GameTimeExpiredMessage by handle. The consumer will no-op against it when it arrives." + )] + private partial void LogCancelNotSupported(Guid gameId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Game {GameId} completed early but cancelling its scheduled GameTimeExpiredMessage failed. The consumer will still no-op against it when it arrives." + )] + private partial void LogCancelFailed(Exception ex, Guid gameId); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/GameModePermissions.cs b/StackDuel.Application/Games/GameModePermissions.cs new file mode 100644 index 0000000..f509cc4 --- /dev/null +++ b/StackDuel.Application/Games/GameModePermissions.cs @@ -0,0 +1,15 @@ +using StackDuel.Domain.Authorization.Rbac; + +namespace StackDuel.Application.Games; + +internal static class GameModePermissions +{ + public static string? For(string gameModeKey) => + gameModeKey switch + { + "solo_rush" => WellKnownAuthorization.PlaySoloRushPermission, + "duel" => WellKnownAuthorization.PlayDuelPermission, + "ffa" => WellKnownAuthorization.PlayFfaPermission, + _ => null, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Games/GameModeStartCountdown.cs b/StackDuel.Application/Games/GameModeStartCountdown.cs new file mode 100644 index 0000000..fad4ae1 --- /dev/null +++ b/StackDuel.Application/Games/GameModeStartCountdown.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Application.Games; + +internal static class GameModeStartCountdown +{ + private const int MultiplayerCountdownSeconds = 6; + + public static int SecondsFor(string gameModeKey) => + gameModeKey switch + { + "duel" or "ffa" => MultiplayerCountdownSeconds, + _ => 0, + }; +} \ No newline at end of file diff --git a/StackDuel.Application/Games/GameProblemAdvancer.cs b/StackDuel.Application/Games/GameProblemAdvancer.cs new file mode 100644 index 0000000..254072c --- /dev/null +++ b/StackDuel.Application/Games/GameProblemAdvancer.cs @@ -0,0 +1,46 @@ +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Games; + +internal sealed class GameProblemAdvancer( + IGameProblemSequencer gameProblemSequencer, + IGameExpiryCanceller gameExpiryCanceller, + IProblemSelectionStrategyResolver strategyResolver +) : IGameProblemAdvancer +{ + public async Task AdvanceAsync( + Game game, + GameParticipant participant, + string gameModeKey, + Action onAdvance, + Action? onFinish, + CancellationToken cancellationToken = default + ) + { + IProblemSelectionStrategy strategy = strategyResolver.Resolve(gameModeKey); + int nextPosition = participant.ProblemSession!.ExcludedProblemIds.Count; + + Guid? nextProblemId = await gameProblemSequencer.GetOrGenerateProblemAsync( + game, + nextPosition, + gameModeKey, + strategy, + cancellationToken + ); + + if (nextProblemId is { } problemId) + { + onAdvance(problemId); + } + else + { + // Nothing left anywhere in the pool for this participant. They're done — not the whole + // game; other participants may still be mid-run. See Game.FinishProblemsFor. + onFinish?.Invoke(); + game.FinishProblemsFor(participant.UserId); + await gameExpiryCanceller.CancelIfScheduledAsync(game, cancellationToken); + } + + return nextProblemId; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Games/GameProblemAttemptResult.cs b/StackDuel.Application/Games/GameProblemAttemptResult.cs new file mode 100644 index 0000000..b8756f1 --- /dev/null +++ b/StackDuel.Application/Games/GameProblemAttemptResult.cs @@ -0,0 +1,49 @@ +using Ardalis.Result; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Games; + +/// +/// Translates a that failed eligibility into the Result each +/// handler needs to return — shared by CompleteProblemHandler and SkipProblemHandler so the four +/// failure messages live in exactly one place instead of copy-pasted in both. +/// +internal static class GameProblemAttemptResult +{ + public static Result ToFailure(GameProblemAttempt attempt, ParticipantAction action) + { + return attempt.Eligibility switch + { + ProblemAttemptEligibility.ParticipantNotFound => Result.Forbidden(), + + ProblemAttemptEligibility.GameNotRunning => Result.Invalid( + new ValidationError( + "Status", + action == ParticipantAction.SkipProblem + ? "Only running games can have problems skipped." + : "Only running games can have problems completed." + ) + ), + + ProblemAttemptEligibility.ParticipantStopped => Result.Invalid( + new ValidationError( + nameof(GameParticipant.HasStoppedPlaying), + attempt.Participant!.HasForfeited + ? "You have forfeited this game." + : "You have already completed all available problems." + ) + ), + + ProblemAttemptEligibility.ProblemSessionNotInitialized => Result.Invalid( + new ValidationError(nameof(GameParticipant.ProblemSession), "Problem session not initialized.") + ), + + ProblemAttemptEligibility.ProblemMismatch => Result.Invalid( + new ValidationError("ProblemId", "The specified problem is not the participant's current problem.") + ), + + _ => throw new ArgumentOutOfRangeException(nameof(attempt), attempt.Eligibility, "Unhandled eligibility."), + }; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Games/GameProblemSequencer.cs b/StackDuel.Application/Games/GameProblemSequencer.cs new file mode 100644 index 0000000..a4f7695 --- /dev/null +++ b/StackDuel.Application/Games/GameProblemSequencer.cs @@ -0,0 +1,42 @@ +using StackDuel.Application.Languages; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Games; + +internal sealed class GameProblemSequencer(ILanguageReadRepository languageReadRepository) : IGameProblemSequencer +{ + public async Task GetOrGenerateProblemAsync( + Game game, + int position, + string gameModeKey, + IProblemSelectionStrategy strategy, + CancellationToken cancellationToken = default + ) + { + if (game.ProblemIdAtPosition(position) is { } existingProblemId) + return existingProblemId; + + Guid[] selectedLanguageIds = [.. game.Tracks.SelectMany(t => t.Languages).Select(l => l.LanguageId).Distinct()]; + IReadOnlyList allowedLanguageVersionIds = + await languageReadRepository.GetLanguageVersionIdsByLanguageIdsAsync( + selectedLanguageIds, + cancellationToken + ); + + Guid[] usedProblemIds = [.. game.ProblemSequence.Select(p => p.ProblemId)]; + var context = new ProblemSelectionContext( + game.Id, + game.PoolId, + gameModeKey, + position, + usedProblemIds, + allowedLanguageVersionIds + ); + + Guid? generatedProblemId = await strategy.SelectNextProblemIdAsync(context, cancellationToken); + if (generatedProblemId is { } newProblemId) + game.AppendProblem(newProblemId); + + return generatedProblemId; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Games/IGameExpiryCanceller.cs b/StackDuel.Application/Games/IGameExpiryCanceller.cs new file mode 100644 index 0000000..e9aff83 --- /dev/null +++ b/StackDuel.Application/Games/IGameExpiryCanceller.cs @@ -0,0 +1,19 @@ +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Games; + +/// +/// Cancels a game's scheduled GameTimeExpiredMessage when the game has just completed early — +/// shared by every command handler that can end a game before its clock naturally runs out +/// (ForfeitGame, CompleteProblem via pool exhaustion), so the cancellation try/catch and its +/// best-effort semantics live in exactly one place. +/// +public interface IGameExpiryCanceller +{ + /// + /// No-ops unless is Completed and still has a scheduled expiry handle + /// attached — safe to call unconditionally right after any action that might have completed + /// the game. + /// + Task CancelIfScheduledAsync(Game game, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/IGameProblemAdvancer.cs b/StackDuel.Application/Games/IGameProblemAdvancer.cs new file mode 100644 index 0000000..a236b93 --- /dev/null +++ b/StackDuel.Application/Games/IGameProblemAdvancer.cs @@ -0,0 +1,26 @@ +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Games; + +/// +/// Resolves and applies a participant's next problem for a game mode — shared by +/// CompleteProblemHandler and SkipProblemHandler, which differ only in which participant +/// transition to apply on success and whether running out of problems also consumes a skip. +/// +public interface IGameProblemAdvancer +{ + /// + /// Looks up (or generates) the participant's next problem and applies it: calls + /// with the new problem id if the pool still has one, otherwise + /// calls (if given), marks the participant finished, and cancels + /// the game's scheduled expiry if that finish just completed the whole game. + /// + Task AdvanceAsync( + Game game, + GameParticipant participant, + string gameModeKey, + Action onAdvance, + Action? onFinish, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/IGameProblemSequencer.cs b/StackDuel.Application/Games/IGameProblemSequencer.cs new file mode 100644 index 0000000..450218d --- /dev/null +++ b/StackDuel.Application/Games/IGameProblemSequencer.cs @@ -0,0 +1,21 @@ +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Games; + +/// +/// Resolves the problem a participant should get for a given position in a game's shared +/// sequence — reusing it if some other participant already reached that position, or +/// generating and recording a new one (restricted to the game's selected track languages) if +/// this is the first arrival. Callers (StartGame/CompleteProblem/SkipProblem) are responsible +/// for persisting the game afterward; this only mutates the in-memory aggregate. +/// +public interface IGameProblemSequencer +{ + Task GetOrGenerateProblemAsync( + Game game, + int position, + string gameModeKey, + IProblemSelectionStrategy strategy, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/IGameReadRepository.cs b/StackDuel.Application/Games/IGameReadRepository.cs new file mode 100644 index 0000000..e108cee --- /dev/null +++ b/StackDuel.Application/Games/IGameReadRepository.cs @@ -0,0 +1,63 @@ +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Games; + +public interface IGameReadRepository +{ + Task FindGameModeByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task FindGameModeByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task FindGameModeByIdIncludingInactiveAsync(Guid id, CancellationToken cancellationToken = default); + Task FindGameByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task FindGameByJoinCodeAsync(string joinCode, CancellationToken cancellationToken = default); + Task> GetActiveGameModesAsync(CancellationToken cancellationToken = default); + + Task> GetRunningGamesPastExpiryAsync( + DateTime cutoffUtc, + int maxBatchSize, + CancellationToken cancellationToken = default + ); + + Task> GetPendingGamesAsync( + string? gameModeKey, + PaginationRequest paginationRequest, + CancellationToken cancellationToken = default + ); + + /// + /// Every game regardless of participation, most recently created first — backs the admin + /// games list. Unlike this isn't scoped to Pending lobbies + /// or any particular viewer. + /// + Task> GetAdminGamesPagedAsync( + GameStatus? status, + PaginationRequest paginationRequest, + CancellationToken cancellationToken = default + ); + + /// + /// Games a user is currently part of that haven't finished yet — Pending (lobbies they're + /// waiting in) or Running (games in progress). Backs the "my active games" list and the + /// global active-game banner so a player can find their way back after navigating away. + /// + Task> GetActiveGamesForUserAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// Completed games (Status == Completed) a user participated in, most recently ended first — + /// backs profile game-mode stats and the recent-games list. Forfeited/expired games are still + /// Completed (see Game.Complete/CompleteIfEveryoneStopped) so they're included; Cancelled + /// lobbies (never started) are not. + /// + Task> GetCompletedGamesForUserAsync(Guid userId, CancellationToken cancellationToken = default); + + Task> GetRecentCompletedSoloRushRunsAsync( + Guid gameModeId, + int timeLimitInSeconds, + Guid excludeUserId, + int take, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/IProblemSelectionStrategy.cs b/StackDuel.Application/Games/IProblemSelectionStrategy.cs new file mode 100644 index 0000000..8010ae5 --- /dev/null +++ b/StackDuel.Application/Games/IProblemSelectionStrategy.cs @@ -0,0 +1,20 @@ +namespace StackDuel.Application.Games; + +public sealed record ProblemSelectionContext( + Guid GameId, + Guid PoolId, + string GameModeKey, + int RoundIndex, + IReadOnlyCollection ExcludedProblemIds, + IReadOnlyCollection AllowedLanguageVersionIds +); + +public interface IProblemSelectionStrategy +{ + string GameModeKey { get; } + + Task SelectNextProblemIdAsync( + ProblemSelectionContext context, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/IProblemSelectionStrategyResolver.cs b/StackDuel.Application/Games/IProblemSelectionStrategyResolver.cs new file mode 100644 index 0000000..93c04b2 --- /dev/null +++ b/StackDuel.Application/Games/IProblemSelectionStrategyResolver.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Games; + +public interface IProblemSelectionStrategyResolver +{ + IProblemSelectionStrategy Resolve(string gameModeKey); +} \ No newline at end of file diff --git a/StackDuel.Application/Games/ProblemSelectionStrategyResolver.cs b/StackDuel.Application/Games/ProblemSelectionStrategyResolver.cs new file mode 100644 index 0000000..c9b09bf --- /dev/null +++ b/StackDuel.Application/Games/ProblemSelectionStrategyResolver.cs @@ -0,0 +1,17 @@ +namespace StackDuel.Application.Games; + +internal sealed class ProblemSelectionStrategyResolver(IEnumerable strategies) + : IProblemSelectionStrategyResolver +{ + public IProblemSelectionStrategy Resolve(string gameModeKey) + { + IProblemSelectionStrategy? strategy = strategies.FirstOrDefault(s => + s.GameModeKey.Equals(gameModeKey, StringComparison.OrdinalIgnoreCase) + ); + + return strategy + ?? throw new InvalidOperationException( + $"No problem selection strategy registered for game mode '{gameModeKey}'." + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Games/TrackLanguageSelection.cs b/StackDuel.Application/Games/TrackLanguageSelection.cs new file mode 100644 index 0000000..f54df34 --- /dev/null +++ b/StackDuel.Application/Games/TrackLanguageSelection.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Games; + +public sealed record TrackLanguageSelection(string TrackKey, IReadOnlyList LanguageIds); \ No newline at end of file diff --git a/StackDuel.Application/Groups/Dtos/GroupDto.cs b/StackDuel.Application/Groups/Dtos/GroupDto.cs new file mode 100644 index 0000000..e685358 --- /dev/null +++ b/StackDuel.Application/Groups/Dtos/GroupDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Groups.Dtos; + +public sealed record GroupDto(Guid Id, string Name); \ No newline at end of file diff --git a/StackDuel.Application/Groups/IGroupReadRepository.cs b/StackDuel.Application/Groups/IGroupReadRepository.cs new file mode 100644 index 0000000..e2fc98c --- /dev/null +++ b/StackDuel.Application/Groups/IGroupReadRepository.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Groups.Dtos; + +namespace StackDuel.Application.Groups; + +public interface IGroupReadRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Images/ImageFormat.cs b/StackDuel.Application/Images/ImageFormat.cs new file mode 100644 index 0000000..864da07 --- /dev/null +++ b/StackDuel.Application/Images/ImageFormat.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Images; + +public sealed record ImageFormat(string ContentType, string FileExtension, Func Matches); \ No newline at end of file diff --git a/StackDuel.Application/Images/ImageSignature.cs b/StackDuel.Application/Images/ImageSignature.cs new file mode 100644 index 0000000..6b989e9 --- /dev/null +++ b/StackDuel.Application/Images/ImageSignature.cs @@ -0,0 +1,42 @@ +namespace StackDuel.Application.Images; + +public static class ImageSignature +{ + public static readonly IReadOnlyList SupportedFormats = + [ + new("image/png", ".png", IsPng), + new("image/jpeg", ".jpg", IsJpeg), + new("image/webp", ".webp", IsWebp), + ]; + + public static ImageFormat? Detect(byte[] content) => + SupportedFormats.FirstOrDefault(format => format.Matches(content)); + + public static string ExtensionFor(string contentType) => + SupportedFormats.First(format => format.ContentType == contentType).FileExtension; + + private static bool IsPng(byte[] content) => + content.Length >= 8 + && content[0] == 0x89 + && content[1] == 0x50 + && content[2] == 0x4E + && content[3] == 0x47 + && content[4] == 0x0D + && content[5] == 0x0A + && content[6] == 0x1A + && content[7] == 0x0A; + + private static bool IsJpeg(byte[] content) => + content.Length >= 3 && content[0] == 0xFF && content[1] == 0xD8 && content[2] == 0xFF; + + private static bool IsWebp(byte[] content) => + content.Length >= 12 + && content[0] == (byte)'R' + && content[1] == (byte)'I' + && content[2] == (byte)'F' + && content[3] == (byte)'F' + && content[8] == (byte)'W' + && content[9] == (byte)'E' + && content[10] == (byte)'B' + && content[11] == (byte)'P'; +} \ No newline at end of file diff --git a/StackDuel.Application/Jobs/DailyChallenges/IDailyChallengeAssignmentService.cs b/StackDuel.Application/Jobs/DailyChallenges/IDailyChallengeAssignmentService.cs new file mode 100644 index 0000000..6b7580e --- /dev/null +++ b/StackDuel.Application/Jobs/DailyChallenges/IDailyChallengeAssignmentService.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Jobs.DailyChallenges; + +public interface IDailyChallengeAssignmentService +{ + Task AssignUpcomingChallengesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Jobs/Games/GameExpirySweepService.cs b/StackDuel.Application/Jobs/Games/GameExpirySweepService.cs new file mode 100644 index 0000000..198da5c --- /dev/null +++ b/StackDuel.Application/Jobs/Games/GameExpirySweepService.cs @@ -0,0 +1,135 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Commands.Games.CompleteExpiredGame; +using StackDuel.Application.Games; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Jobs.Games; + +/// +/// Backstop for the GameTimeExpiredMessage path. Finds Running games that are past their +/// expiry with no corresponding message ever having arrived (lost message, scheduling failure, +/// broker outage, etc.) and finalizes them via the same CompleteExpiredGameCommand the message +/// consumer uses. Intended to run infrequently — this only exists to catch the rare miss, not +/// to be the primary expiry path. +/// +public interface IGameExpirySweepService +{ + Task RunAsync(CancellationToken cancellationToken = default); +} + +public sealed partial class GameExpirySweepService( + IGameReadRepository gameReadRepository, + IMediator mediator, + ILogger logger +) : IGameExpirySweepService +{ + // Only sweep games that are past expiry by more than this — keeps the sweep from racing + // the normal GameTimeExpiredMessage path for a game that simply hasn't hit its scheduled + // delivery yet. This job is a backstop for lost/failed messages, not a faster alternative. + private static readonly TimeSpan GracePeriod = TimeSpan.FromMinutes(5); + + // Upper bound per run so one sweep can't blow up if something has gone badly wrong (e.g. + // scheduling was broken for hours). Remaining stragglers just get picked up next run. + private const int MaxBatchSize = 200; + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + LogRunning(); + + DateTime cutoffUtc = DateTime.UtcNow - GracePeriod; + + IReadOnlyList candidates = await gameReadRepository.GetRunningGamesPastExpiryAsync( + cutoffUtc, + MaxBatchSize, + cancellationToken + ); + + if (candidates.Count == 0) + { + LogNoneFound(); + return; + } + + LogFound(candidates.Count); + + int completed = 0; + int noOp = 0; + int other = 0; + + foreach (Game game in candidates) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + // StartedAt is guaranteed non-null by the query (Status == Running games always + // have a StartedAt). RescheduleCount 0 is fine here: this isn't the reschedule + // loop the message consumer runs — if the handler ever did decide this game + // isn't actually expired yet (a race with StartedAt), it's simply picked up + // again on the next sweep rather than being explicitly rescheduled here. + var result = await mediator.Send( + new CompleteExpiredGameCommand(game.Id, game.StartedAt!.Value, RescheduleCount: 0), + cancellationToken + ); + + if (!result.IsSuccess) + { + other++; + LogCommandFailed(game.Id); + continue; + } + + switch (result.Value.Outcome) + { + case CompleteExpiredGameOutcome.Completed: + case CompleteExpiredGameOutcome.NotYetExpired_RescheduleLimitReached_CompletedAnyway: + completed++; + break; + case CompleteExpiredGameOutcome.AlreadyFinalized_NoOp: + case CompleteExpiredGameOutcome.Stale_Dropped: + noOp++; + break; + default: + other++; + break; + } + } + catch (Exception ex) + { + other++; + LogGameSweepError(ex, game.Id); + } + } + + LogSummary(completed, noOp, other); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Running game expiry sweep")] + private partial void LogRunning(); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Game expiry sweep found no stale Running games")] + private partial void LogNoneFound(); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Game expiry sweep found {Count} Running game(s) past expiry with no message ever having finalized them" + )] + private partial void LogFound(int count); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "CompleteExpiredGameCommand returned a failure result for game {GameId} during expiry sweep" + )] + private partial void LogCommandFailed(Guid gameId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error finalizing game {GameId} during expiry sweep")] + private partial void LogGameSweepError(Exception ex, Guid gameId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Game expiry sweep complete: {Completed} completed, {NoOp} no-op, {Other} other" + )] + private partial void LogSummary(int completed, int noOp, int other); +} \ No newline at end of file diff --git a/StackDuel.Application/Jobs/Submissions/ISubmissionCleanupService.cs b/StackDuel.Application/Jobs/Submissions/ISubmissionCleanupService.cs new file mode 100644 index 0000000..32e0e37 --- /dev/null +++ b/StackDuel.Application/Jobs/Submissions/ISubmissionCleanupService.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Jobs.Submissions; + +public interface ISubmissionCleanupService +{ + Task RunAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Jobs/Users/IAvatarCleanupService.cs b/StackDuel.Application/Jobs/Users/IAvatarCleanupService.cs new file mode 100644 index 0000000..8239ab9 --- /dev/null +++ b/StackDuel.Application/Jobs/Users/IAvatarCleanupService.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Jobs.Users; + +public interface IAvatarCleanupService +{ + Task RunAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/LanguageServer/ILanguageServerAdapter.cs b/StackDuel.Application/LanguageServer/ILanguageServerAdapter.cs new file mode 100644 index 0000000..43faaea --- /dev/null +++ b/StackDuel.Application/LanguageServer/ILanguageServerAdapter.cs @@ -0,0 +1,20 @@ +namespace StackDuel.Application.LanguageServer; + +public interface ILanguageServerAdapter +{ + string LanguageSlug { get; } + + string DocumentFileName { get; } + + Task PrepareWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken); + + LanguageServerProcessStartInfo BuildProcessStartInfo(string workspaceDirectory); + + object? BuildInitializationOptions(string workspaceDirectory); +} + +public sealed record LanguageServerProcessStartInfo( + string FileName, + IReadOnlyList Arguments, + string WorkingDirectory +); \ No newline at end of file diff --git a/StackDuel.Application/LanguageServer/ILanguageServerProcess.cs b/StackDuel.Application/LanguageServer/ILanguageServerProcess.cs new file mode 100644 index 0000000..8e2cf1b --- /dev/null +++ b/StackDuel.Application/LanguageServer/ILanguageServerProcess.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Application.LanguageServer; + +public interface ILanguageServerProcess : IAsyncDisposable +{ + Stream Input { get; } + + Stream Output { get; } + + Task Completion { get; } + + bool HasExited { get; } +} \ No newline at end of file diff --git a/StackDuel.Application/LanguageServer/ILanguageServerSessionManager.cs b/StackDuel.Application/LanguageServer/ILanguageServerSessionManager.cs new file mode 100644 index 0000000..a7a4e5c --- /dev/null +++ b/StackDuel.Application/LanguageServer/ILanguageServerSessionManager.cs @@ -0,0 +1,50 @@ +namespace StackDuel.Application.LanguageServer; + +public sealed record LanguageServerSession( + Guid Id, + Guid OwnerUserId, + string LanguageSlug, + string RootUri, + string DocumentUri, + object? InitializationOptions +); + +public enum LanguageServerSessionStartError +{ + Disabled, + UnsupportedLanguage, + AtCapacity, +} + +public sealed record LanguageServerSessionStartResult( + LanguageServerSession? Session, + LanguageServerSessionStartError? Error +) +{ + public static LanguageServerSessionStartResult Success(LanguageServerSession session) => new(session, null); + + public static LanguageServerSessionStartResult Failure(LanguageServerSessionStartError error) => new(null, error); +} + +public interface ILanguageServerSessionManager +{ + Task StartSessionAsync( + Guid userId, + string languageSlug, + CancellationToken cancellationToken + ); + + LanguageServerSession? TryGetSession(Guid sessionId, Guid userId); + + Task EndSessionAsync(Guid sessionId, CancellationToken cancellationToken); + + ILanguageServerProcess? TryAttach(Guid sessionId, Guid userId); + + void Detach(Guid sessionId); + + void Touch(Guid sessionId); + + Task ReapIdleSessionsAsync(TimeSpan idleTimeout, CancellationToken cancellationToken); + + Task SweepOrphanedWorkspacesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/LanguageServer/LspFraming.cs b/StackDuel.Application/LanguageServer/LspFraming.cs new file mode 100644 index 0000000..d5b6708 --- /dev/null +++ b/StackDuel.Application/LanguageServer/LspFraming.cs @@ -0,0 +1,80 @@ +using System.Text; + +namespace StackDuel.Application.LanguageServer; + +public static class LspFraming +{ + private const string ContentLengthHeader = "Content-Length"; + + public static async Task ReadMessageAsync(Stream stream, CancellationToken cancellationToken) + { + int? contentLength = null; + + while (true) + { + string? line = await ReadHeaderLineAsync(stream, cancellationToken); + if (line is null) + return null; + + if (line.Length == 0) + break; + + int separatorIndex = line.IndexOf(':'); + if (separatorIndex < 0) + continue; + + string name = line[..separatorIndex].Trim(); + string value = line[(separatorIndex + 1)..].Trim(); + + if ( + name.Equals(ContentLengthHeader, StringComparison.OrdinalIgnoreCase) + && int.TryParse(value, out int parsed) + ) + { + contentLength = parsed; + } + } + + if (contentLength is not int length) + throw new InvalidDataException("LSP message header did not include a valid Content-Length."); + + byte[] body = new byte[length]; + await stream.ReadExactlyAsync(body, cancellationToken); + + return Encoding.UTF8.GetString(body); + } + + public static async Task WriteMessageAsync(Stream stream, string message, CancellationToken cancellationToken) + { + byte[] body = Encoding.UTF8.GetBytes(message); + byte[] header = Encoding.ASCII.GetBytes($"{ContentLengthHeader}: {body.Length}\r\n\r\n"); + + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + private static async Task ReadHeaderLineAsync(Stream stream, CancellationToken cancellationToken) + { + List bytes = []; + byte[] single = new byte[1]; + + while (true) + { + int read = await stream.ReadAsync(single, cancellationToken); + if (read == 0) + return bytes.Count == 0 ? null : throw new EndOfStreamException("Stream ended mid-header."); + + if (single[0] == '\n') + { + if (bytes.Count > 0 && bytes[^1] == '\r') + bytes.RemoveAt(bytes.Count - 1); + break; + } + + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString(bytes.ToArray()); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Languages/Dtos/LanguageDto.cs b/StackDuel.Application/Languages/Dtos/LanguageDto.cs new file mode 100644 index 0000000..d06e31a --- /dev/null +++ b/StackDuel.Application/Languages/Dtos/LanguageDto.cs @@ -0,0 +1,5 @@ +namespace StackDuel.Application.Languages.Dtos; + +public sealed record LanguageVersionDto(Guid Id, string Version); + +public sealed record LanguageDto(Guid Id, string Name, IReadOnlyList Versions); \ No newline at end of file diff --git a/StackDuel.Application/Languages/LanguageReadRepository.cs b/StackDuel.Application/Languages/LanguageReadRepository.cs new file mode 100644 index 0000000..dae9d28 --- /dev/null +++ b/StackDuel.Application/Languages/LanguageReadRepository.cs @@ -0,0 +1,20 @@ +using StackDuel.Domain.Languages.Entities; + +namespace StackDuel.Application.Languages; + +public interface ILanguageReadRepository +{ + Task> FindLanguagesByVersionId( + IEnumerable versionIds, + CancellationToken cancellationToken + ); + Task> GetActiveLanguagesAsync(CancellationToken cancellationToken); + Task> GetActiveLanguagesByTrackIdsAsync( + IEnumerable trackIds, + CancellationToken cancellationToken + ); + Task> GetLanguageVersionIdsByLanguageIdsAsync( + IEnumerable languageIds, + CancellationToken cancellationToken + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Leaderboards/Dtos/LeaderboardEntryDto.cs b/StackDuel.Application/Leaderboards/Dtos/LeaderboardEntryDto.cs new file mode 100644 index 0000000..4be4564 --- /dev/null +++ b/StackDuel.Application/Leaderboards/Dtos/LeaderboardEntryDto.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Application.Leaderboards.Dtos; + +public sealed record LeaderboardEntryDto( + int Rank, + Guid UserId, + string Username, + string? ImageUrl, + int HighScore, + bool IsCurrentUser +); \ No newline at end of file diff --git a/StackDuel.Application/Leaderboards/Dtos/MyLeaderboardEntryDto.cs b/StackDuel.Application/Leaderboards/Dtos/MyLeaderboardEntryDto.cs new file mode 100644 index 0000000..fbce793 --- /dev/null +++ b/StackDuel.Application/Leaderboards/Dtos/MyLeaderboardEntryDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Leaderboards.Dtos; + +public sealed record MyLeaderboardEntryDto(int HighScore); \ No newline at end of file diff --git a/StackDuel.Application/Leaderboards/ILeaderboardReadRepository.cs b/StackDuel.Application/Leaderboards/ILeaderboardReadRepository.cs new file mode 100644 index 0000000..6bedbbe --- /dev/null +++ b/StackDuel.Application/Leaderboards/ILeaderboardReadRepository.cs @@ -0,0 +1,26 @@ +using StackDuel.Application.Pagination; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Leaderboards; + +public interface ILeaderboardReadRepository +{ + /// + /// Participants for the leaderboard identified by (gameModeId, timeLimitInSeconds), ordered + /// by high score descending. Returns an empty page (not a 404) when no leaderboard exists yet + /// for that combination — nobody has finished a game there, which isn't an error. + /// + Task> GetRankingsAsync( + Guid gameModeId, + int timeLimitInSeconds, + PaginationRequest paginationRequest, + CancellationToken cancellationToken = default + ); + + Task FindParticipantForUserAsync( + Guid gameModeId, + int timeLimitInSeconds, + Guid userId, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/IMessage.cs b/StackDuel.Application/Messaging/IMessage.cs new file mode 100644 index 0000000..0265330 --- /dev/null +++ b/StackDuel.Application/Messaging/IMessage.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Messaging; + +public interface IMessage +{ + static abstract string QueueName { get; } +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/IMessagePublisher.cs b/StackDuel.Application/Messaging/IMessagePublisher.cs new file mode 100644 index 0000000..258f75c --- /dev/null +++ b/StackDuel.Application/Messaging/IMessagePublisher.cs @@ -0,0 +1,29 @@ +namespace StackDuel.Application.Messaging; + +public interface IMessagePublisher +{ + Task PublishAsync(T message, CancellationToken cancellationToken = default) + where T : class, IMessage; + + /// + /// Publishes a message for delayed delivery at (approximately) scheduledEnqueueTimeUtc. + /// Returns a transport-specific handle that can be passed to CancelScheduledAsync to + /// cancel delivery before it fires — on Azure Service Bus this is the message's sequence + /// number. Not every transport can honor cancellation; consumers of scheduled messages + /// should be idempotent and re-verify state rather than relying solely on cancellation. + /// + Task PublishScheduledAsync( + T message, + DateTimeOffset scheduledEnqueueTimeUtc, + CancellationToken cancellationToken = default + ) + where T : class, IMessage; + + /// + /// Attempts to cancel a previously scheduled message using the handle returned from + /// PublishScheduledAsync. Support varies by transport — RabbitMQ's delayed-message-exchange + /// plugin has no cancel-by-handle API, so that implementation throws NotSupportedException. + /// + Task CancelScheduledAsync(long sequenceNumber, CancellationToken cancellationToken = default) + where T : class, IMessage; +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/Messages/GameTimeExpiredMessage.cs b/StackDuel.Application/Messaging/Messages/GameTimeExpiredMessage.cs new file mode 100644 index 0000000..7d14e65 --- /dev/null +++ b/StackDuel.Application/Messaging/Messages/GameTimeExpiredMessage.cs @@ -0,0 +1,18 @@ +namespace StackDuel.Application.Messaging.Messages; + +/// +/// Published (with a scheduled/delayed enqueue time) when a game starts, timed to arrive at +/// the moment the game's time limit elapses. The consumer must re-verify against the current +/// game state rather than trusting the message's arrival time as truth — see +/// and . +/// +public sealed record GameTimeExpiredMessage(Guid GameId, DateTime ExpectedStartedAt, int RescheduleCount) : IMessage +{ + public static string QueueName => "game-time-expired"; + + /// + /// Hard cap on self-reschedules. If exceeded, the consumer finalizes the game regardless + /// of the early-arrival check rather than rescheduling again. + /// + public const int MaxReschedules = 3; +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/Messages/ProblemValidationJobContinuationMessage.cs b/StackDuel.Application/Messaging/Messages/ProblemValidationJobContinuationMessage.cs new file mode 100644 index 0000000..7294c3c --- /dev/null +++ b/StackDuel.Application/Messaging/Messages/ProblemValidationJobContinuationMessage.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Messaging; + +namespace StackDuel.Application.Messaging.Messages; + +public sealed record ProblemValidationJobContinuationMessage(Guid JobId) : IMessage +{ + public static string QueueName => "problem-validation-job-continuation"; +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/Messages/SubmissionCreatedMessage.cs b/StackDuel.Application/Messaging/Messages/SubmissionCreatedMessage.cs new file mode 100644 index 0000000..a4093ad --- /dev/null +++ b/StackDuel.Application/Messaging/Messages/SubmissionCreatedMessage.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Messaging; + +namespace StackDuel.Application.Messaging.Messages; + +public sealed record SubmissionCreatedMessage(Guid SubmissionId) : IMessage +{ + public static string QueueName => "submission-created"; +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/Messages/SubmissionJobContinuationMessage.cs b/StackDuel.Application/Messaging/Messages/SubmissionJobContinuationMessage.cs new file mode 100644 index 0000000..2f8ecdc --- /dev/null +++ b/StackDuel.Application/Messaging/Messages/SubmissionJobContinuationMessage.cs @@ -0,0 +1,12 @@ +using StackDuel.Application.Messaging; + +namespace StackDuel.Application.Messaging.Messages; + +/// +/// Published after a submission job's step completes (advances or needs a retry) so the +/// next step can be picked up immediately instead of waiting for the fallback cron sweep. +/// +public sealed record SubmissionJobContinuationMessage(Guid SubmissionId) : IMessage +{ + public static string QueueName => "submission-job-continuation"; +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/Messages/TestCaseGenerationJobCompletedMessage.cs b/StackDuel.Application/Messaging/Messages/TestCaseGenerationJobCompletedMessage.cs new file mode 100644 index 0000000..f852504 --- /dev/null +++ b/StackDuel.Application/Messaging/Messages/TestCaseGenerationJobCompletedMessage.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Messaging; + +namespace StackDuel.Application.Messaging.Messages; + +public sealed record TestCaseGenerationJobCompletedMessage(Guid JobId) : IMessage +{ + public static string QueueName => "test-case-generation-job-completed"; +} \ No newline at end of file diff --git a/StackDuel.Application/Messaging/Messages/TestCaseGenerationJobContinuationMessage.cs b/StackDuel.Application/Messaging/Messages/TestCaseGenerationJobContinuationMessage.cs new file mode 100644 index 0000000..0034b7d --- /dev/null +++ b/StackDuel.Application/Messaging/Messages/TestCaseGenerationJobContinuationMessage.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Messaging; + +namespace StackDuel.Application.Messaging.Messages; + +public sealed record TestCaseGenerationJobContinuationMessage(Guid JobId) : IMessage +{ + public static string QueueName => "test-case-generation-job-continuation"; +} \ No newline at end of file diff --git a/StackDuel.Application/Notifications/GameCompletedNotification.cs b/StackDuel.Application/Notifications/GameCompletedNotification.cs new file mode 100644 index 0000000..d76f8b5 --- /dev/null +++ b/StackDuel.Application/Notifications/GameCompletedNotification.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Notifications; + +/// +/// Shape pushed to clients over the game hub when a game reaches a terminal state. Kept in the +/// Application layer so the payload shape has one definition regardless of transport. +/// +public sealed record GameCompletedNotification(Guid GameId, GameStatus Status, DateTime EndedAt); \ No newline at end of file diff --git a/StackDuel.Application/Notifications/GameLobbyUpdatedNotification.cs b/StackDuel.Application/Notifications/GameLobbyUpdatedNotification.cs new file mode 100644 index 0000000..e6d2170 --- /dev/null +++ b/StackDuel.Application/Notifications/GameLobbyUpdatedNotification.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Application.Notifications; + +/// +/// Shape pushed to clients over the game hub when a Pending lobby's participant roster changes. +/// Deliberately just a "go re-fetch" signal, like GameCompletedNotification — the client reads +/// the actual roster back from the REST API rather than trusting anything more off the socket. +/// +public sealed record GameLobbyUpdatedNotification(Guid GameId); \ No newline at end of file diff --git a/StackDuel.Application/Notifications/GameParticipantAttemptedNotification.cs b/StackDuel.Application/Notifications/GameParticipantAttemptedNotification.cs new file mode 100644 index 0000000..85b2d25 --- /dev/null +++ b/StackDuel.Application/Notifications/GameParticipantAttemptedNotification.cs @@ -0,0 +1,16 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Notifications; + +/// +/// Shape pushed to clients over the game hub whenever a participant's submission in a Running +/// game reaches a terminal state. Unlike GameProgressUpdatedNotification, this carries its own +/// data (status) rather than being a bare re-fetch signal — the live activity feed is built +/// entirely from these pushes and has no REST endpoint to fall back on. +/// +public sealed record GameParticipantAttemptedNotification( + Guid GameId, + Guid UserId, + SubmissionStatus Status, + DateTime AttemptedAt +); \ No newline at end of file diff --git a/StackDuel.Application/Notifications/GameParticipantReactedNotification.cs b/StackDuel.Application/Notifications/GameParticipantReactedNotification.cs new file mode 100644 index 0000000..a3e2405 --- /dev/null +++ b/StackDuel.Application/Notifications/GameParticipantReactedNotification.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Application.Notifications; + +/// +/// Shape pushed to clients over the game hub whenever a participant sends a quick emoji reaction +/// in a Running game. Same "carries its own data, no REST fallback" treatment as +/// GameParticipantAttemptedNotification — there's nothing persisted behind this to poll instead. +/// +public sealed record GameParticipantReactedNotification(Guid GameId, Guid UserId, string Emoji, DateTime SentAt); \ No newline at end of file diff --git a/StackDuel.Application/Notifications/GameProgressUpdatedNotification.cs b/StackDuel.Application/Notifications/GameProgressUpdatedNotification.cs new file mode 100644 index 0000000..4934883 --- /dev/null +++ b/StackDuel.Application/Notifications/GameProgressUpdatedNotification.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Application.Notifications; + +/// +/// Shape pushed to clients over the game hub when a Running game's progress changes (someone +/// solved a problem). Same "just a re-fetch signal" treatment as GameCompletedNotification / +/// GameLobbyUpdatedNotification — the client reads the actual score back from the REST API. +/// +public sealed record GameProgressUpdatedNotification(Guid GameId); \ No newline at end of file diff --git a/StackDuel.Application/Notifications/IGameNotificationService.cs b/StackDuel.Application/Notifications/IGameNotificationService.cs new file mode 100644 index 0000000..a8f8b76 --- /dev/null +++ b/StackDuel.Application/Notifications/IGameNotificationService.cs @@ -0,0 +1,65 @@ +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Notifications; + +/// +/// Pushes real-time game-lifecycle notifications to connected clients. The Application layer +/// only knows it needs to announce "this game is now in this state" — the actual transport +/// (SignalR, or anything else in the future) is an Api/Infrastructure concern. +/// +public interface IGameNotificationService +{ + /// + /// Notifies whoever is watching the given game that it has reached a terminal state. + /// Best-effort: a client that never connects, or a transient push failure, doesn't affect + /// game correctness — it's purely a latency optimization over polling. + /// + Task NotifyGameCompletedAsync( + Guid gameId, + GameStatus status, + DateTime endedAt, + CancellationToken cancellationToken = default + ); + + /// + /// Notifies whoever is sitting in a Pending lobby that its participant roster changed — someone + /// joined or left. Same best-effort semantics as : purely + /// a latency optimization, never a correctness dependency. + /// + Task NotifyGameLobbyUpdatedAsync(Guid gameId, CancellationToken cancellationToken = default); + + /// + /// Notifies whoever is watching a Running game that a participant's progress changed (they + /// solved a problem). Same best-effort semantics as . + /// + Task NotifyGameProgressUpdatedAsync(Guid gameId, CancellationToken cancellationToken = default); + + /// + /// Notifies whoever is watching a Running game that a participant's submission just reached a + /// terminal state (Accepted or WrongAnswer) — powers the live activity feed. Same best-effort + /// semantics as , except there's no REST fallback here: + /// a missed push is just a missing feed entry, not stale data a poll would later correct. + /// + Task NotifyGameParticipantAttemptedAsync( + Guid gameId, + Guid userId, + SubmissionStatus status, + DateTime attemptedAt, + CancellationToken cancellationToken = default + ); + + /// + /// Notifies whoever is watching a Running game that a participant sent a quick emoji + /// reaction. Same best-effort semantics as , and same + /// "no REST fallback" caveat as — a missed + /// push is just a missing bubble in the chat, not stale data. + /// + Task NotifyGameParticipantReactedAsync( + Guid gameId, + Guid userId, + string emoji, + DateTime sentAt, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Notifications/ISubmissionNotificationService.cs b/StackDuel.Application/Notifications/ISubmissionNotificationService.cs new file mode 100644 index 0000000..3e20cdd --- /dev/null +++ b/StackDuel.Application/Notifications/ISubmissionNotificationService.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Application.Notifications; + +public interface ISubmissionNotificationService +{ + Task NotifySubmissionCompletedAsync(Guid userId, Guid submissionId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Pagination/PageResult.cs b/StackDuel.Application/Pagination/PageResult.cs new file mode 100644 index 0000000..7c6cb76 --- /dev/null +++ b/StackDuel.Application/Pagination/PageResult.cs @@ -0,0 +1,20 @@ +namespace StackDuel.Application.Pagination; + +public sealed class PageResult +{ + public required IReadOnlyList Results { get; init; } + + public required int Total { get; init; } + + public required int Page { get; init; } + + public required int Size { get; init; } + + public int TotalPages => Size > 0 && Total > 0 ? (int)Math.Ceiling((double)Total / Size) : 0; + + public bool HasPrevious => Page > 1; + + public bool HasNext => Page < TotalPages; + + public int Offset => (Page - 1) * Size; +} \ No newline at end of file diff --git a/StackDuel.Application/Pagination/PaginationRequest.cs b/StackDuel.Application/Pagination/PaginationRequest.cs new file mode 100644 index 0000000..b81263f --- /dev/null +++ b/StackDuel.Application/Pagination/PaginationRequest.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Application.Pagination; + +public class PaginationRequest +{ + public required int Page { get; init; } + + public required int Size { get; init; } + + public DateTime Timestamp { get; init; } +} \ No newline at end of file diff --git a/StackDuel.Application/Pagination/SortDirection.cs b/StackDuel.Application/Pagination/SortDirection.cs new file mode 100644 index 0000000..be4643c --- /dev/null +++ b/StackDuel.Application/Pagination/SortDirection.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Application.Pagination; + +public enum SortDirection +{ + Asc, + Desc, +} \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/AdminProblemDetailDto.cs b/StackDuel.Application/Problems/Dtos/AdminProblemDetailDto.cs new file mode 100644 index 0000000..583433e --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/AdminProblemDetailDto.cs @@ -0,0 +1,53 @@ +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Application.Problems.Dtos; + +public sealed record AdminProblemDetailDto( + Guid Id, + string Slug, + string Title, + string Question, + int DifficultyValue, + DifficultyTier DifficultyTier, + int TimeLimitMs, + int MemoryLimitMb, + ProblemStatus Status, + string? ValidationFailureReason, + Guid? TrackId, + string? TrackName, + DateTime CreatedAt, + string? CreatedByUsername, + IReadOnlyList Tags, + IReadOnlyList PoolKeys, + IReadOnlyList Setups, + IReadOnlyList GenerationParameters, + string? GenerationOutputValueType, + int? GenerationTargetCaseCount, + int? GenerationSeed +); + +public sealed record AdminProblemSetupDto( + Guid Id, + Guid LanguageVersionId, + string LanguageName, + string LanguageVersion, + string? FunctionName, + string InitialCode, + string? ReferenceSolutionCode, + bool HasReferenceSolution, + bool HasGenerationSpec, + int TestSuiteCount, + int TestCaseCount, + IReadOnlyList SampleTestCases +); + +public sealed record AdminSampleTestCaseDto( + Guid Id, + string? Name, + IReadOnlyList Inputs, + string ExpectedOutputValue, + string ExpectedOutputValueType +); + +public sealed record AdminSampleTestCaseInputDto(string Value, string ValueType); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/AdminProblemListItemDto.cs b/StackDuel.Application/Problems/Dtos/AdminProblemListItemDto.cs new file mode 100644 index 0000000..2232365 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/AdminProblemListItemDto.cs @@ -0,0 +1,37 @@ +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Problems.Dtos; + +public sealed record AdminProblemListItemDto( + Guid Id, + string Slug, + string Title, + int DifficultyValue, + DifficultyTier DifficultyTier, + ProblemStatus Status, + int TimeLimitMs, + int MemoryLimitMb, + IReadOnlyList Tags, + IReadOnlyList Languages, + int SetupCount, + DateTime CreatedAt, + string? CreatedByUsername +); + +/// +/// Repository-level projection before language version ids are resolved to display names. +/// +public sealed record AdminProblemListRowDto( + Guid Id, + string Slug, + string Title, + int DifficultyValue, + ProblemStatus Status, + int TimeLimitMs, + int MemoryLimitMb, + IReadOnlyList Tags, + IReadOnlyList LanguageVersionIds, + int SetupCount, + DateTime CreatedAt, + string? CreatedByUsername +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/CreateProblemDraftInput.cs b/StackDuel.Application/Problems/Dtos/CreateProblemDraftInput.cs new file mode 100644 index 0000000..8252976 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/CreateProblemDraftInput.cs @@ -0,0 +1,11 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record CreateProblemDraftInput( + string Title, + string Question, + int Difficulty, + int TimeLimitMs, + int MemoryLimitMb, + IReadOnlyCollection Tags, + Guid TrackId +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemAuthorDto.cs b/StackDuel.Application/Problems/Dtos/ProblemAuthorDto.cs new file mode 100644 index 0000000..1ffe985 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemAuthorDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemAuthorDto(string Username, string? ImageUrl); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemDifficultyLookupDto.cs b/StackDuel.Application/Problems/Dtos/ProblemDifficultyLookupDto.cs new file mode 100644 index 0000000..772e1fb --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemDifficultyLookupDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemDifficultyLookupDto(Guid ProblemId, int DifficultyValue); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemDto.cs b/StackDuel.Application/Problems/Dtos/ProblemDto.cs new file mode 100644 index 0000000..a0945d4 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemDto.cs @@ -0,0 +1,26 @@ +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemLanguageDto(Guid Id, string Name, string Slug); + +public sealed record ProblemDto( + Guid Id, + string Slug, + string Title, + DifficultyTier DifficultyTier, + IReadOnlyList Tags, + IReadOnlyList Languages +); + +/// +/// Repository-level projection before language version ids are resolved to language display info. +/// +public sealed record ProblemListRowDto( + Guid Id, + string Slug, + string Title, + int DifficultyValue, + IReadOnlyList Tags, + IReadOnlyList LanguageVersionIds +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemPoolDto.cs b/StackDuel.Application/Problems/Dtos/ProblemPoolDto.cs new file mode 100644 index 0000000..2a58754 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemPoolDto.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemPoolDto( + Guid Id, + string Key, + string Name, + string? Description, + int ProblemCount, + DateTime CreatedAt +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemReactionSummaryDto.cs b/StackDuel.Application/Problems/Dtos/ProblemReactionSummaryDto.cs new file mode 100644 index 0000000..0eb3238 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemReactionSummaryDto.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemReactionCountDto(string Key, string Name, string? Emoji, int Count); + +public sealed record ProblemReactionSummaryDto( + IReadOnlyList Counts, + string? CurrentUserReactionKey +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemSetupDto.cs b/StackDuel.Application/Problems/Dtos/ProblemSetupDto.cs new file mode 100644 index 0000000..6af9d01 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemSetupDto.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemSetupTestCaseDto(string Inputs, string ExpectedOutput); + +public sealed record ProblemSetupFileDto(string Path, string Content); + +public sealed record ProblemSetupDto( + Guid Id, + string InitialCode, + string? FunctionName, + IEnumerable TestCases, + IEnumerable AdditionalFiles +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemSetupLanguageDto.cs b/StackDuel.Application/Problems/Dtos/ProblemSetupLanguageDto.cs new file mode 100644 index 0000000..9979702 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemSetupLanguageDto.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemSetupLanguageVersionDto(Guid Id, string Version); + +public sealed record ProblemSetupLanguageDto( + Guid Id, + string Name, + IEnumerable Versions +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/ProblemWithSetupsDto.cs b/StackDuel.Application/Problems/Dtos/ProblemWithSetupsDto.cs new file mode 100644 index 0000000..bc56a15 --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/ProblemWithSetupsDto.cs @@ -0,0 +1,15 @@ +using StackDuel.Domain.Problems.Enums; + +namespace StackDuel.Application.Problems.Dtos; + +public sealed record ProblemWithSetupsDto( + Guid Id, + string Slug, + string Title, + DifficultyTier DifficultyTier, + string Question, + IEnumerable AvailableLanguages, + IEnumerable PublicTestCases, + ProblemAuthorDto? Author, + IEnumerable Tags +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/Dtos/PublicTestCaseDto.cs b/StackDuel.Application/Problems/Dtos/PublicTestCaseDto.cs new file mode 100644 index 0000000..9a7057d --- /dev/null +++ b/StackDuel.Application/Problems/Dtos/PublicTestCaseDto.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Application.Problems.Dtos; + +public sealed record PublicTestCaseInputDto(string Value, string ValueType); + +public sealed record PublicTestCaseExpectedOutputDto(string Value, string ValueType); + +public sealed record PublicTestCaseDto( + string Name, + string? Description, + IEnumerable Inputs, + IEnumerable ExpectedOutputs +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/IProblemPoolRepository.cs b/StackDuel.Application/Problems/IProblemPoolRepository.cs new file mode 100644 index 0000000..08fbab1 --- /dev/null +++ b/StackDuel.Application/Problems/IProblemPoolRepository.cs @@ -0,0 +1,22 @@ +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Problems; + +public interface IProblemPoolRepository +{ + Task FindByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task ExistsByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task> GetPoolKeysForProblemAsync( + Guid problemId, + CancellationToken cancellationToken = default + ); + + Task AddAsync(ProblemPool pool, CancellationToken cancellationToken = default); + + Task UpdateAsync(ProblemPool pool, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Problems/IProblemReactionReadRepository.cs b/StackDuel.Application/Problems/IProblemReactionReadRepository.cs new file mode 100644 index 0000000..3612bd7 --- /dev/null +++ b/StackDuel.Application/Problems/IProblemReactionReadRepository.cs @@ -0,0 +1,15 @@ +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Problems; + +public interface IProblemReactionReadRepository +{ + Task FindReactionTypeByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task GetSummaryAsync( + Guid problemId, + Guid? userId, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Problems/IProblemReadRepository.cs b/StackDuel.Application/Problems/IProblemReadRepository.cs new file mode 100644 index 0000000..c898d6b --- /dev/null +++ b/StackDuel.Application/Problems/IProblemReadRepository.cs @@ -0,0 +1,82 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Problems; + +public interface IProblemReadRepository +{ + Task FindBySlugAsync(string slug, CancellationToken cancellationToken = default); + + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task FindByIdForAdminAsync(Guid id, CancellationToken cancellationToken = default); + + Task ExistsAsync(Guid id, CancellationToken cancellationToken = default); + + Task ExistsForAdminAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetIdBySlugAsync(string slug, CancellationToken cancellationToken = default); + + Task> GetPagedAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ); + + Task> GetAdminProblemsPagedAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ); + + Task> FindByIdsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken = default + ); + + Task> GetPoolMembersPagedAsync( + Guid poolId, + PaginationRequest pagination, + CancellationToken cancellationToken = default + ); + + Task> GetAdminProblemIdsMatchingAsync( + string? search, + CancellationToken cancellationToken = default + ); + + /// + /// Picks a problem in the given difficulty range, excluding the given ids. Selection is + /// deterministic for a given and set of matching problems — + /// the same seed always yields the same problem — so callers that need multiple participants + /// in the same game to land on the same problem (e.g. a shared round in Duel/FFA) can derive + /// the seed from the game and round rather than the problem being picked independently at + /// random for each participant. + /// + /// + /// Restricts candidates to problems with a setup for at least one of these language versions + /// (e.g. the tracks/languages a player selected for the game). Empty means unrestricted. + /// + Task GetRandomProblemIdByDifficultyAsync( + Guid poolId, + int minDifficulty, + int maxDifficulty, + IReadOnlyCollection excludedProblemIds, + long selectionSeed, + IReadOnlyCollection allowedLanguageVersionIds, + CancellationToken cancellationToken = default + ); + + Task FindDifficultyByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ); + + /// + /// Returns the pool's published members' ids, ordered by their position in the pool. Used by the + /// daily-challenge job for strict sequential assignment (as opposed to , + /// which game modes use for random selection). + /// + Task> GetOrderedEligibleProblemIdsAsync(Guid poolId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Problems/RequiredLanguages/Dtos/RequiredProblemLanguageAdminDto.cs b/StackDuel.Application/Problems/RequiredLanguages/Dtos/RequiredProblemLanguageAdminDto.cs new file mode 100644 index 0000000..40f4bb4 --- /dev/null +++ b/StackDuel.Application/Problems/RequiredLanguages/Dtos/RequiredProblemLanguageAdminDto.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Application.Problems.RequiredLanguages.Dtos; + +public sealed record RequiredProblemLanguageAdminDto( + Guid Id, + Guid LanguageVersionId, + string LanguageName, + string VersionLabel, + int SortOrder, + Guid? TrackId, + string? TrackKey, + string? TrackName +); \ No newline at end of file diff --git a/StackDuel.Application/Problems/RequiredLanguages/IRequiredProblemLanguageReadRepository.cs b/StackDuel.Application/Problems/RequiredLanguages/IRequiredProblemLanguageReadRepository.cs new file mode 100644 index 0000000..fcbb27a --- /dev/null +++ b/StackDuel.Application/Problems/RequiredLanguages/IRequiredProblemLanguageReadRepository.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Problems.RequiredLanguages.Dtos; + +namespace StackDuel.Application.Problems.RequiredLanguages; + +public interface IRequiredProblemLanguageReadRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Audit/GetAuditLogPageable/GetAuditLogPageableHandler.cs b/StackDuel.Application/Queries/Audit/GetAuditLogPageable/GetAuditLogPageableHandler.cs new file mode 100644 index 0000000..08c4879 --- /dev/null +++ b/StackDuel.Application/Queries/Audit/GetAuditLogPageable/GetAuditLogPageableHandler.cs @@ -0,0 +1,20 @@ +using Ardalis.Result; +using StackDuel.Application.Audit; +using StackDuel.Application.Audit.Dtos; +using StackDuel.Application.Pagination; + +namespace StackDuel.Application.Queries.Audit.GetAuditLogPageable; + +internal sealed class GetAuditLogPageableHandler(IAuditLogReadRepository auditLogReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetAuditLogPageableQuery request, + CancellationToken cancellationToken + ) + { + var result = await auditLogReadRepository.GetPageableAsync(request.PaginationRequest, cancellationToken); + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Audit/GetAuditLogPageable/GetAuditLogPageableQuery.cs b/StackDuel.Application/Queries/Audit/GetAuditLogPageable/GetAuditLogPageableQuery.cs new file mode 100644 index 0000000..994e9b1 --- /dev/null +++ b/StackDuel.Application/Queries/Audit/GetAuditLogPageable/GetAuditLogPageableQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Audit.Dtos; +using StackDuel.Application.Pagination; + +namespace StackDuel.Application.Queries.Audit.GetAuditLogPageable; + +public sealed record GetAuditLogPageableQuery(PaginationRequest PaginationRequest) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetAdminCampaignDetail/GetAdminCampaignDetailHandler.cs b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignDetail/GetAdminCampaignDetailHandler.cs new file mode 100644 index 0000000..bbfda69 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignDetail/GetAdminCampaignDetailHandler.cs @@ -0,0 +1,23 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Queries.Campaigns.GetAdminCampaignDetail; + +internal sealed class GetAdminCampaignDetailHandler(ICampaignReadRepository campaignReadRepository) + : IQueryHandler +{ + public async Task> Handle( + GetAdminCampaignDetailQuery request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignReadRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + return Result.Success(CampaignDtoMapper.ToDto(campaign)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetAdminCampaignDetail/GetAdminCampaignDetailQuery.cs b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignDetail/GetAdminCampaignDetailQuery.cs new file mode 100644 index 0000000..9b44a0d --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignDetail/GetAdminCampaignDetailQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetAdminCampaignDetail; + +public sealed record GetAdminCampaignDetailQuery(Guid CampaignId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetAdminCampaignsPageable/GetAdminCampaignsPageableHandler.cs b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignsPageable/GetAdminCampaignsPageableHandler.cs new file mode 100644 index 0000000..89a8877 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignsPageable/GetAdminCampaignsPageableHandler.cs @@ -0,0 +1,35 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Queries.Campaigns.GetAdminCampaignsPageable; + +internal sealed class GetAdminCampaignsPageableHandler(ICampaignReadRepository campaignReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetAdminCampaignsPageableQuery request, + CancellationToken cancellationToken + ) + { + PageResult page = await campaignReadRepository.GetAdminPagedAsync( + request.PaginationRequest, + request.Search, + cancellationToken + ); + + List results = [.. page.Results.Select(CampaignDtoMapper.ToAdminListItemDto)]; + + return Result.Success( + new PageResult + { + Results = results, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetAdminCampaignsPageable/GetAdminCampaignsPageableQuery.cs b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignsPageable/GetAdminCampaignsPageableQuery.cs new file mode 100644 index 0000000..e9cc383 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetAdminCampaignsPageable/GetAdminCampaignsPageableQuery.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetAdminCampaignsPageable; + +public sealed record GetAdminCampaignsPageableQuery(PaginationRequest PaginationRequest, string? Search) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetCampaignBySlug/GetCampaignBySlugHandler.cs b/StackDuel.Application/Queries/Campaigns/GetCampaignBySlug/GetCampaignBySlugHandler.cs new file mode 100644 index 0000000..b7863cb --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetCampaignBySlug/GetCampaignBySlugHandler.cs @@ -0,0 +1,20 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Queries.Campaigns.GetCampaignBySlug; + +internal sealed class GetCampaignBySlugHandler(ICampaignReadRepository campaignReadRepository) + : IQueryHandler +{ + public async Task> Handle(GetCampaignBySlugQuery request, CancellationToken cancellationToken) + { + Campaign? campaign = await campaignReadRepository.FindPublishedBySlugAsync(request.Slug, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + return Result.Success(CampaignDtoMapper.ToDto(campaign)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetCampaignBySlug/GetCampaignBySlugQuery.cs b/StackDuel.Application/Queries/Campaigns/GetCampaignBySlug/GetCampaignBySlugQuery.cs new file mode 100644 index 0000000..a860260 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetCampaignBySlug/GetCampaignBySlugQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetCampaignBySlug; + +public sealed record GetCampaignBySlugQuery(string Slug) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetCampaignPath/GetCampaignPathHandler.cs b/StackDuel.Application/Queries/Campaigns/GetCampaignPath/GetCampaignPathHandler.cs new file mode 100644 index 0000000..2075ee9 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetCampaignPath/GetCampaignPathHandler.cs @@ -0,0 +1,22 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Queries.Campaigns.GetCampaignPath; + +internal sealed class GetCampaignPathHandler(ICampaignReadRepository campaignReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetCampaignPathQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList campaigns = await campaignReadRepository.GetPublishedPathAsync(cancellationToken); + + IReadOnlyList dtos = [.. campaigns.Select(CampaignDtoMapper.ToSummaryDto)]; + + return Result>.Success(dtos); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetCampaignPath/GetCampaignPathQuery.cs b/StackDuel.Application/Queries/Campaigns/GetCampaignPath/GetCampaignPathQuery.cs new file mode 100644 index 0000000..86dcff5 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetCampaignPath/GetCampaignPathQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetCampaignPath; + +public sealed record GetCampaignPathQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetMyCampaignProgress/GetMyCampaignProgressHandler.cs b/StackDuel.Application/Queries/Campaigns/GetMyCampaignProgress/GetMyCampaignProgressHandler.cs new file mode 100644 index 0000000..02b0101 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetMyCampaignProgress/GetMyCampaignProgressHandler.cs @@ -0,0 +1,48 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Queries.Campaigns.GetMyCampaignProgress; + +internal sealed class GetMyCampaignProgressHandler( + ICampaignReadRepository campaignReadRepository, + ICampaignProgressReadRepository campaignProgressReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetMyCampaignProgressQuery request, + CancellationToken cancellationToken + ) + { + Campaign? campaign = await campaignReadRepository.FindByIdAsync(request.CampaignId, cancellationToken); + + if (campaign is null) + return Result.NotFound(); + + CampaignEnrollment? enrollment = await campaignProgressReadRepository.FindEnrollmentAsync( + request.UserId, + request.CampaignId, + cancellationToken + ); + + IReadOnlyList completedUnitIds = await campaignProgressReadRepository.GetCompletedUnitIdsAsync( + request.UserId, + request.CampaignId, + cancellationToken + ); + + int totalUnits = campaign.Modules.SelectMany(m => m.Units).Count(); + + return Result.Success( + new UserCampaignProgressDto( + campaign.Id, + enrollment is not null, + enrollment?.Status, + totalUnits, + completedUnitIds.Count, + completedUnitIds + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetMyCampaignProgress/GetMyCampaignProgressQuery.cs b/StackDuel.Application/Queries/Campaigns/GetMyCampaignProgress/GetMyCampaignProgressQuery.cs new file mode 100644 index 0000000..1b195c8 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetMyCampaignProgress/GetMyCampaignProgressQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetMyCampaignProgress; + +public sealed record GetMyCampaignProgressQuery(Guid CampaignId, Guid UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetMyEnrollments/GetMyEnrollmentsHandler.cs b/StackDuel.Application/Queries/Campaigns/GetMyEnrollments/GetMyEnrollmentsHandler.cs new file mode 100644 index 0000000..e9abde1 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetMyEnrollments/GetMyEnrollmentsHandler.cs @@ -0,0 +1,54 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Application.Queries.Campaigns.GetMyEnrollments; + +internal sealed class GetMyEnrollmentsHandler( + ICampaignReadRepository campaignReadRepository, + ICampaignProgressReadRepository campaignProgressReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetMyEnrollmentsQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList enrollments = await campaignProgressReadRepository.GetEnrollmentsForUserAsync( + request.UserId, + cancellationToken + ); + + List results = []; + + foreach (CampaignEnrollment enrollment in enrollments) + { + Campaign? campaign = await campaignReadRepository.FindByIdAsync(enrollment.CampaignId, cancellationToken); + + if (campaign is null) + continue; + + IReadOnlyList completedUnitIds = await campaignProgressReadRepository.GetCompletedUnitIdsAsync( + request.UserId, + enrollment.CampaignId, + cancellationToken + ); + + int totalUnits = campaign.Modules.SelectMany(m => m.Units).Count(); + + results.Add( + new UserCampaignProgressDto( + campaign.Id, + true, + enrollment.Status, + totalUnits, + completedUnitIds.Count, + completedUnitIds + ) + ); + } + + return Result.Success>(results); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetMyEnrollments/GetMyEnrollmentsQuery.cs b/StackDuel.Application/Queries/Campaigns/GetMyEnrollments/GetMyEnrollmentsQuery.cs new file mode 100644 index 0000000..f324f4c --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetMyEnrollments/GetMyEnrollmentsQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetMyEnrollments; + +public sealed record GetMyEnrollmentsQuery(Guid UserId) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetMyLearningStats/GetMyLearningStatsHandler.cs b/StackDuel.Application/Queries/Campaigns/GetMyLearningStats/GetMyLearningStatsHandler.cs new file mode 100644 index 0000000..f5ff76c --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetMyLearningStats/GetMyLearningStatsHandler.cs @@ -0,0 +1,69 @@ +using Ardalis.Result; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Queries.Campaigns.GetMyLearningStats; + +internal sealed class GetMyLearningStatsHandler(ICampaignProgressReadRepository progressReadRepository) + : IQueryHandler +{ + private const int XpPerLevel = 500; + + public async Task> Handle( + GetMyLearningStatsQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList completedUnitTypes = await progressReadRepository.GetCompletedUnitTypesAsync( + request.UserId, + cancellationToken + ); + IReadOnlyList completionTimestamps = await progressReadRepository.GetCompletionTimestampsAsync( + request.UserId, + cancellationToken + ); + IReadOnlyList enrollments = await progressReadRepository.GetEnrollmentsForUserAsync( + request.UserId, + cancellationToken + ); + + int totalXp = completedUnitTypes.Sum(UnitTypeXp.For); + int level = (totalXp / XpPerLevel) + 1; + int xpIntoLevel = totalXp % XpPerLevel; + + int currentStreakDays = CalculateCurrentStreakDays(completionTimestamps); + + int completedCampaignsCount = enrollments.Count(e => e.Status == EnrollmentStatus.Completed); + + return Result.Success( + new UserLearningStatsDto( + totalXp, + level, + xpIntoLevel, + XpPerLevel, + currentStreakDays, + completedCampaignsCount + ) + ); + } + + private static int CalculateCurrentStreakDays(IReadOnlyList completionTimestamps) + { + HashSet completedDates = [.. completionTimestamps.Select(t => DateOnly.FromDateTime(t.Date))]; + + DateOnly cursor = DateOnly.FromDateTime(DateTime.UtcNow.Date); + if (!completedDates.Contains(cursor)) + cursor = cursor.AddDays(-1); + + int streakDays = 0; + while (completedDates.Contains(cursor)) + { + streakDays++; + cursor = cursor.AddDays(-1); + } + + return streakDays; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Campaigns/GetMyLearningStats/GetMyLearningStatsQuery.cs b/StackDuel.Application/Queries/Campaigns/GetMyLearningStats/GetMyLearningStatsQuery.cs new file mode 100644 index 0000000..44c2671 --- /dev/null +++ b/StackDuel.Application/Queries/Campaigns/GetMyLearningStats/GetMyLearningStatsQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Campaigns.GetMyLearningStats; + +public sealed record GetMyLearningStatsQuery(Guid UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeHandler.cs b/StackDuel.Application/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeHandler.cs new file mode 100644 index 0000000..62d11a6 --- /dev/null +++ b/StackDuel.Application/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeHandler.cs @@ -0,0 +1,112 @@ +using Ardalis.Result; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.DailyChallenges.Dtos; +using StackDuel.Application.Problems; +using StackDuel.Application.Submissions; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Queries.DailyChallenges.GetTodaysDailyChallenge; + +internal sealed class GetTodaysDailyChallengeHandler( + IDailyChallengeRepository dailyChallengeRepository, + IProblemReadRepository problemReadRepository, + ISubmissionReadRepository submissionReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetTodaysDailyChallengeQuery request, + CancellationToken cancellationToken + ) + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + + DailyChallenge? todaysChallenge = await dailyChallengeRepository.FindByDateAsync(today, cancellationToken); + if (todaysChallenge is null) + return Result.NotFound(); + + Problem? problem = await problemReadRepository.FindByIdAsync(todaysChallenge.ProblemId, cancellationToken); + if (problem is null) + return Result.NotFound(); + + IReadOnlyList challengesNewestFirst = + [ + .. (await dailyChallengeRepository.GetAllOrderedByDateDescendingAsync(cancellationToken)).Where(c => + c.ChallengeDate <= today + ), + ]; + + IReadOnlyList problemIds = [.. challengesNewestFirst.Select(c => c.ProblemId).Distinct()]; + IReadOnlyList acceptedProblemIds = await submissionReadRepository.GetAcceptedProblemIdsForUserAsync( + request.UserId, + problemIds, + cancellationToken + ); + HashSet solved = [.. acceptedProblemIds]; + + bool solvedToday = solved.Contains(todaysChallenge.ProblemId); + int currentStreak = CalculateCurrentStreak(challengesNewestFirst, solved, today); + int longestStreak = CalculateLongestStreak(challengesNewestFirst, solved); + + return Result.Success( + new TodaysDailyChallengeDto( + problem.Id, + problem.Slug.Value, + problem.Title.Value, + problem.Difficulty.Tier, + solvedToday, + currentStreak, + longestStreak + ) + ); + } + + private static int CalculateCurrentStreak( + IReadOnlyList challengesNewestFirst, + HashSet solvedProblemIds, + DateOnly today + ) + { + int startIndex = 0; + if ( + challengesNewestFirst.Count > 0 + && challengesNewestFirst[0].ChallengeDate == today + && !solvedProblemIds.Contains(challengesNewestFirst[0].ProblemId) + ) + { + startIndex = 1; + } + + int streak = 0; + for (int i = startIndex; i < challengesNewestFirst.Count; i++) + { + if (!solvedProblemIds.Contains(challengesNewestFirst[i].ProblemId)) + break; + + streak++; + } + + return streak; + } + + private static int CalculateLongestStreak(IReadOnlyList challenges, HashSet solvedProblemIds) + { + int longest = 0; + int current = 0; + + foreach (DailyChallenge challenge in challenges) + { + if (solvedProblemIds.Contains(challenge.ProblemId)) + { + current++; + longest = Math.Max(longest, current); + } + else + { + current = 0; + } + } + + return longest; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeQuery.cs b/StackDuel.Application/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeQuery.cs new file mode 100644 index 0000000..d054462 --- /dev/null +++ b/StackDuel.Application/Queries/DailyChallenges/GetTodaysDailyChallenge/GetTodaysDailyChallengeQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.DailyChallenges.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.DailyChallenges.GetTodaysDailyChallenge; + +public sealed record GetTodaysDailyChallengeQuery(Guid UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesHandler.cs b/StackDuel.Application/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesHandler.cs new file mode 100644 index 0000000..239e97e --- /dev/null +++ b/StackDuel.Application/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesHandler.cs @@ -0,0 +1,63 @@ +using Ardalis.Result; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.DailyChallenges.Dtos; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Application.Queries.DailyChallenges.GetUpcomingDailyChallenges; + +internal sealed class GetUpcomingDailyChallengesHandler( + IDailyChallengeRepository dailyChallengeRepository, + IProblemReadRepository problemReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetUpcomingDailyChallengesQuery request, + CancellationToken cancellationToken + ) + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + IReadOnlyList upcoming = await dailyChallengeRepository.GetUpcomingAsync( + today, + cancellationToken + ); + + if (upcoming.Count == 0) + return Result.Success>([]); + + Guid[] problemIds = [.. upcoming.Select(c => c.ProblemId).Distinct()]; + IReadOnlyList rows = await problemReadRepository.FindByIdsAsync( + problemIds, + cancellationToken + ); + Dictionary rowsById = rows.ToDictionary(r => r.Id); + + IReadOnlyList results = + [ + .. upcoming.Select(challenge => + { + rowsById.TryGetValue(challenge.ProblemId, out AdminProblemListRowDto? row); + return new UpcomingDailyChallengeDto( + challenge.ChallengeDate, + challenge.ProblemId, + row?.Slug ?? "", + row?.Title ?? "(problem no longer exists)", + ToDifficultyTier(row?.DifficultyValue ?? 0), + row?.Status ?? ProblemStatus.Archived + ); + }), + ]; + + return Result.Success(results); + } + + private static DifficultyTier ToDifficultyTier(int difficultyValue) => + difficultyValue <= Difficulty.BeginnerMax ? DifficultyTier.Beginner + : difficultyValue <= Difficulty.EasyMax ? DifficultyTier.Easy + : difficultyValue <= Difficulty.IntermediateMax ? DifficultyTier.Intermediate + : difficultyValue <= Difficulty.AdvancedMax ? DifficultyTier.Advanced + : DifficultyTier.Expert; +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesQuery.cs b/StackDuel.Application/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesQuery.cs new file mode 100644 index 0000000..315ce22 --- /dev/null +++ b/StackDuel.Application/Queries/DailyChallenges/GetUpcomingDailyChallenges/GetUpcomingDailyChallengesQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.DailyChallenges.Dtos; + +namespace StackDuel.Application.Queries.DailyChallenges.GetUpcomingDailyChallenges; + +public sealed record GetUpcomingDailyChallengesQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsHandler.cs b/StackDuel.Application/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsHandler.cs new file mode 100644 index 0000000..8a337ee --- /dev/null +++ b/StackDuel.Application/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsHandler.cs @@ -0,0 +1,19 @@ +using Ardalis.Result; +using StackDuel.Application.Dashboard; +using StackDuel.Application.Dashboard.Dtos; + +namespace StackDuel.Application.Queries.Dashboard.GetAdminDashboardStats; + +internal sealed class GetAdminDashboardStatsHandler(IDashboardReadRepository dashboardReadRepository) + : IQueryHandler +{ + public async Task> Handle( + GetAdminDashboardStatsQuery request, + CancellationToken cancellationToken + ) + { + var stats = await dashboardReadRepository.GetAdminDashboardStatsAsync(request.NewUsersDays, cancellationToken); + + return Result.Success(stats); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsQuery.cs b/StackDuel.Application/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsQuery.cs new file mode 100644 index 0000000..5a05338 --- /dev/null +++ b/StackDuel.Application/Queries/Dashboard/GetAdminDashboardStats/GetAdminDashboardStatsQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Dashboard.Dtos; + +namespace StackDuel.Application.Queries.Dashboard.GetAdminDashboardStats; + +public sealed record GetAdminDashboardStatsQuery(int NewUsersDays) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetAllFeatureFlagsForViewer/GetAllFeatureFlagsForViewerHandler.cs b/StackDuel.Application/Queries/FeatureFlags/GetAllFeatureFlagsForViewer/GetAllFeatureFlagsForViewerHandler.cs new file mode 100644 index 0000000..badbdd8 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetAllFeatureFlagsForViewer/GetAllFeatureFlagsForViewerHandler.cs @@ -0,0 +1,25 @@ +using Ardalis.Result; +using StackDuel.Application.FeatureFlags; +using StackDuel.Domain.FeatureFlags; + +namespace StackDuel.Application.Queries.FeatureFlags.GetAllFeatureFlagsForViewer; + +internal sealed class GetAllFeatureFlagsForViewerHandler(IFeatureFlagEvaluationRepository evaluationRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetAllFeatureFlagsForViewerQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyDictionary allData = + await evaluationRepository.GetAllEvaluationDataAsync(request.UserId, cancellationToken); + + Dictionary resolved = allData.ToDictionary( + kvp => kvp.Key, + kvp => FeatureFlagEvaluator.Evaluate(kvp.Value, request.UserId) + ); + + return Result.Success>(resolved); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetAllFeatureFlagsForViewer/GetAllFeatureFlagsForViewerQuery.cs b/StackDuel.Application/Queries/FeatureFlags/GetAllFeatureFlagsForViewer/GetAllFeatureFlagsForViewerQuery.cs new file mode 100644 index 0000000..7a69251 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetAllFeatureFlagsForViewer/GetAllFeatureFlagsForViewerQuery.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Queries.FeatureFlags.GetAllFeatureFlagsForViewer; + +public sealed record GetAllFeatureFlagsForViewerQuery(Guid? UserId) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagByKey/GetFeatureFlagByKeyHandler.cs b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagByKey/GetFeatureFlagByKeyHandler.cs new file mode 100644 index 0000000..52ec237 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagByKey/GetFeatureFlagByKeyHandler.cs @@ -0,0 +1,22 @@ +using Ardalis.Result; +using StackDuel.Application.FeatureFlags; +using StackDuel.Application.FeatureFlags.Dtos; + +namespace StackDuel.Application.Queries.FeatureFlags.GetFeatureFlagByKey; + +internal sealed class GetFeatureFlagByKeyHandler(IFeatureFlagAdminReadRepository adminReadRepository) + : IQueryHandler +{ + public async Task> Handle( + GetFeatureFlagByKeyQuery request, + CancellationToken cancellationToken + ) + { + FeatureFlagAdminDto? flag = await adminReadRepository.GetByKeyAsync(request.Key, cancellationToken); + + if (flag is null) + return Result.NotFound(); + + return Result.Success(flag); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagByKey/GetFeatureFlagByKeyQuery.cs b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagByKey/GetFeatureFlagByKeyQuery.cs new file mode 100644 index 0000000..2758a22 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagByKey/GetFeatureFlagByKeyQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.FeatureFlags.Dtos; + +namespace StackDuel.Application.Queries.FeatureFlags.GetFeatureFlagByKey; + +public sealed record GetFeatureFlagByKeyQuery(string Key) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagEnabled/GetFeatureFlagEnabledHandler.cs b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagEnabled/GetFeatureFlagEnabledHandler.cs new file mode 100644 index 0000000..b90ff8e --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagEnabled/GetFeatureFlagEnabledHandler.cs @@ -0,0 +1,23 @@ +using Ardalis.Result; +using StackDuel.Application.FeatureFlags; +using StackDuel.Domain.FeatureFlags; + +namespace StackDuel.Application.Queries.FeatureFlags.GetFeatureFlagEnabled; + +internal sealed class GetFeatureFlagEnabledHandler(IFeatureFlagEvaluationRepository evaluationRepository) + : IQueryHandler +{ + public async Task> Handle(GetFeatureFlagEnabledQuery request, CancellationToken cancellationToken) + { + FeatureFlagEvaluationData? data = await evaluationRepository.GetEvaluationDataAsync( + request.FlagKey, + request.UserId, + cancellationToken + ); + + if (data is null) + return Result.NotFound(); + + return Result.Success(FeatureFlagEvaluator.Evaluate(data, request.UserId)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagEnabled/GetFeatureFlagEnabledQuery.cs b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagEnabled/GetFeatureFlagEnabledQuery.cs new file mode 100644 index 0000000..e745a65 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlagEnabled/GetFeatureFlagEnabledQuery.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Queries.FeatureFlags.GetFeatureFlagEnabled; + +public sealed record GetFeatureFlagEnabledQuery(string FlagKey, Guid? UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlags/GetFeatureFlagsHandler.cs b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlags/GetFeatureFlagsHandler.cs new file mode 100644 index 0000000..baf7e10 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlags/GetFeatureFlagsHandler.cs @@ -0,0 +1,18 @@ +using Ardalis.Result; +using StackDuel.Application.FeatureFlags; +using StackDuel.Application.FeatureFlags.Dtos; + +namespace StackDuel.Application.Queries.FeatureFlags.GetFeatureFlags; + +internal sealed class GetFeatureFlagsHandler(IFeatureFlagAdminReadRepository adminReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetFeatureFlagsQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList flags = await adminReadRepository.GetAllAsync(cancellationToken); + return Result.Success(flags); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlags/GetFeatureFlagsQuery.cs b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlags/GetFeatureFlagsQuery.cs new file mode 100644 index 0000000..0a36324 --- /dev/null +++ b/StackDuel.Application/Queries/FeatureFlags/GetFeatureFlags/GetFeatureFlagsQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.FeatureFlags.Dtos; + +namespace StackDuel.Application.Queries.FeatureFlags.GetFeatureFlags; + +public sealed record GetFeatureFlagsQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Feedback/GetAdminFeedbackDetail/GetAdminFeedbackDetailHandler.cs b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackDetail/GetAdminFeedbackDetailHandler.cs new file mode 100644 index 0000000..6d006b0 --- /dev/null +++ b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackDetail/GetAdminFeedbackDetailHandler.cs @@ -0,0 +1,22 @@ +using Ardalis.Result; +using StackDuel.Application.Feedback; +using StackDuel.Application.Feedback.Dtos; + +namespace StackDuel.Application.Queries.Feedback.GetAdminFeedbackDetail; + +internal sealed class GetAdminFeedbackDetailHandler(IFeedbackReadRepository feedbackReadRepository) + : IQueryHandler +{ + public async Task> Handle( + GetAdminFeedbackDetailQuery request, + CancellationToken cancellationToken + ) + { + var feedback = await feedbackReadRepository.FindAdminFeedbackByIdAsync(request.FeedbackId, cancellationToken); + + if (feedback is null) + return Result.NotFound(); + + return Result.Success(feedback); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Feedback/GetAdminFeedbackDetail/GetAdminFeedbackDetailQuery.cs b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackDetail/GetAdminFeedbackDetailQuery.cs new file mode 100644 index 0000000..c3fbc12 --- /dev/null +++ b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackDetail/GetAdminFeedbackDetailQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Feedback.Dtos; + +namespace StackDuel.Application.Queries.Feedback.GetAdminFeedbackDetail; + +public sealed record GetAdminFeedbackDetailQuery(Guid FeedbackId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Feedback/GetAdminFeedbackPageable/GetAdminFeedbackPageableHandler.cs b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackPageable/GetAdminFeedbackPageableHandler.cs new file mode 100644 index 0000000..6c37283 --- /dev/null +++ b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackPageable/GetAdminFeedbackPageableHandler.cs @@ -0,0 +1,25 @@ +using Ardalis.Result; +using StackDuel.Application.Feedback; +using StackDuel.Application.Feedback.Dtos; +using StackDuel.Application.Pagination; + +namespace StackDuel.Application.Queries.Feedback.GetAdminFeedbackPageable; + +internal sealed class GetAdminFeedbackPageableHandler(IFeedbackReadRepository feedbackReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetAdminFeedbackPageableQuery request, + CancellationToken cancellationToken + ) + { + var result = await feedbackReadRepository.GetAdminFeedbackPagedAsync( + request.PaginationRequest, + request.Type, + request.Status, + cancellationToken + ); + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Feedback/GetAdminFeedbackPageable/GetAdminFeedbackPageableQuery.cs b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackPageable/GetAdminFeedbackPageableQuery.cs new file mode 100644 index 0000000..85143fe --- /dev/null +++ b/StackDuel.Application/Queries/Feedback/GetAdminFeedbackPageable/GetAdminFeedbackPageableQuery.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Feedback.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Queries.Feedback.GetAdminFeedbackPageable; + +public sealed record GetAdminFeedbackPageableQuery( + PaginationRequest PaginationRequest, + FeedbackType? Type, + FeedbackStatus? Status +) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetAdminGameDetail/GetAdminGameDetailHandler.cs b/StackDuel.Application/Queries/Games/GetAdminGameDetail/GetAdminGameDetailHandler.cs new file mode 100644 index 0000000..09868c1 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetAdminGameDetail/GetAdminGameDetailHandler.cs @@ -0,0 +1,89 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Queries.Games.GetAdminGameDetail; + +internal sealed class GetAdminGameDetailHandler( + IGameReadRepository gameReadRepository, + IUserReadRepository userReadRepository, + ITrackReadRepository trackReadRepository +) : IQueryHandler +{ + public async Task> Handle(GetAdminGameDetailQuery request, CancellationToken cancellationToken) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound(); + + GameMode? gameMode = await gameReadRepository.FindGameModeByIdIncludingInactiveAsync( + game.GameModeId, + cancellationToken + ); + + if (gameMode is null) + return Result.Error("Game references a mode that no longer exists."); + + IReadOnlyDictionary userMap = await userReadRepository.FindByIdsAsync( + game.Participants.Select(p => p.UserId), + cancellationToken + ); + + IReadOnlyList tracks = await trackReadRepository.FindByIdsAsync( + game.Tracks.Select(t => t.TrackId), + cancellationToken + ); + Dictionary trackNamesById = tracks.ToDictionary(t => t.Id, t => t.Name); + + var participants = game + .Participants.Select(p => + { + userMap.TryGetValue(p.UserId, out var user); + + GameCurrentProblemDto? currentProblem = + p.ProblemSession != null ? new GameCurrentProblemDto(p.ProblemSession.CurrentProblemId) : null; + + return new GameParticipantDto( + p.UserId, + user?.Username ?? string.Empty, + user?.ImageUrl, + p.SeatNo, + p.JoinedAt, + p.Score, + currentProblem, + p.HasForfeited, + p.HasFinishedProblems, + p.SkipsRemaining + ); + }) + .ToArray(); + + return Result.Success( + new GameStateDto( + game.Id, + game.GameModeId, + gameMode.Key, + game.Status, + game.TimeLimitInSeconds, + game.CreatedAt, + game.StartedAt, + game.EndedAt, + participants, + [ + .. game + .Tracks.Select(t => trackNamesById.GetValueOrDefault(t.TrackId)) + .Where(name => name is not null) + .Select(name => name!), + ], + game.JoinCode, + game.SkipsEnabled + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetAdminGameDetail/GetAdminGameDetailQuery.cs b/StackDuel.Application/Queries/Games/GetAdminGameDetail/GetAdminGameDetailQuery.cs new file mode 100644 index 0000000..89c6ce3 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetAdminGameDetail/GetAdminGameDetailQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Games.Dtos; + +namespace StackDuel.Application.Queries.Games.GetAdminGameDetail; + +public sealed record GetAdminGameDetailQuery(Guid GameId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryHandler.cs b/StackDuel.Application/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryHandler.cs new file mode 100644 index 0000000..733ac0a --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryHandler.cs @@ -0,0 +1,106 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Problems; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Queries.Games.GetAdminGamePlayerHistory; + +internal sealed class GetAdminGamePlayerHistoryHandler( + IGameReadRepository gameReadRepository, + ISubmissionReadRepository submissionReadRepository, + IProblemReadRepository problemReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetAdminGamePlayerHistoryQuery request, + CancellationToken cancellationToken + ) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + if (game is null) + return Result>.NotFound(); + + IReadOnlyList submissions = await submissionReadRepository.GetSubmissionsForGameAsync( + request.GameId, + cancellationToken + ); + ILookup submissionsByUser = submissions.ToLookup(s => s.UserId); + + // A skip carries no timestamp of its own in the domain model (GameProblemSession only + // keeps an unordered id list), and titles for a problem skipped with zero attempts never + // appear in the submissions join above, so both are resolved separately here. + HashSet skippedProblemIds = + [ + .. game.Participants.SelectMany(p => p.ProblemSession?.SkippedProblemIds ?? []), + ]; + Dictionary skippedProblemTitles = + skippedProblemIds.Count == 0 + ? [] + : (await problemReadRepository.FindByIdsAsync(skippedProblemIds, cancellationToken)).ToDictionary( + p => p.Id, + p => (p.Title, p.Slug) + ); + + var histories = game + .Participants.Select(participant => + BuildHistory(participant, submissionsByUser[participant.UserId].ToList(), skippedProblemTitles) + ) + .ToList(); + + return Result>.Success(histories); + } + + private static AdminGamePlayerHistoryDto BuildHistory( + GameParticipant participant, + IReadOnlyList userSubmissions, + IReadOnlyDictionary skippedProblemTitles + ) + { + List events = + [ + .. userSubmissions.Select(s => new AdminGameHistoryEventDto( + s.Status == SubmissionStatus.Accepted + ? AdminGameHistoryEventType.Accepted + : AdminGameHistoryEventType.WrongAnswer, + s.ProblemId, + s.ProblemTitle, + s.ProblemSlug, + s.CreatedAt, + s.Id, + s.LanguageName + )), + ]; + + foreach (Guid skippedProblemId in participant.ProblemSession?.SkippedProblemIds ?? []) + { + DateTime? lastAttemptAt = userSubmissions + .Where(s => s.ProblemId == skippedProblemId) + .Select(s => (DateTime?)s.CreatedAt) + .Max(); + + (string title, string slug) = skippedProblemTitles.TryGetValue(skippedProblemId, out var lookup) + ? lookup + : ("Unknown problem", ""); + + events.Add( + new AdminGameHistoryEventDto( + AdminGameHistoryEventType.Skipped, + skippedProblemId, + title, + slug, + lastAttemptAt, + null, + null + ) + ); + } + + events = [.. events.OrderBy(e => e.OccurredAt ?? DateTime.MaxValue)]; + + return new AdminGamePlayerHistoryDto(participant.UserId, events); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryQuery.cs b/StackDuel.Application/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryQuery.cs new file mode 100644 index 0000000..03965fe --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetAdminGamePlayerHistory/GetAdminGamePlayerHistoryQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Games.Dtos; + +namespace StackDuel.Application.Queries.Games.GetAdminGamePlayerHistory; + +public sealed record GetAdminGamePlayerHistoryQuery(Guid GameId) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableHandler.cs b/StackDuel.Application/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableHandler.cs new file mode 100644 index 0000000..27156bc --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableHandler.cs @@ -0,0 +1,88 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Games.GetAdminGamesPageable; + +internal sealed class GetAdminGamesPageableHandler( + IGameReadRepository gameReadRepository, + IUserReadRepository userReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetAdminGamesPageableQuery request, + CancellationToken cancellationToken + ) + { + PageResult page = await gameReadRepository.GetAdminGamesPagedAsync( + request.Status, + request.PaginationRequest, + cancellationToken + ); + + var gameModesById = new Dictionary(); + foreach (Guid gameModeId in page.Results.Select(g => g.GameModeId).Distinct()) + { + var gameMode = await gameReadRepository.FindGameModeByIdIncludingInactiveAsync( + gameModeId, + cancellationToken + ); + if (gameMode is not null) + gameModesById[gameModeId] = gameMode; + } + + var participantUserIds = page.Results.SelectMany(g => g.Participants.Select(p => p.UserId)).Distinct(); + var userMap = await userReadRepository.FindByIdsAsync(participantUserIds, cancellationToken); + + var items = page.Results.Select(game => BuildListItem(game, gameModesById, userMap)).ToList(); + + return Result.Success( + new PageResult + { + Results = items, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } + + private static AdminGameListItemDto BuildListItem( + Game game, + IReadOnlyDictionary gameModesById, + IReadOnlyDictionary userMap + ) + { + gameModesById.TryGetValue(game.GameModeId, out GameMode? gameMode); + + var participants = game + .Participants.Select(p => + { + userMap.TryGetValue(p.UserId, out var user); + return new AdminGameParticipantSummaryDto( + p.UserId, + user?.Username ?? string.Empty, + p.Score, + p.HasForfeited, + p.HasFinishedProblems + ); + }) + .ToArray(); + + return new AdminGameListItemDto( + game.Id, + gameMode?.Key ?? "unknown", + gameMode?.Name ?? "Unknown", + game.Status, + game.TimeLimitInSeconds, + game.CreatedAt, + game.StartedAt, + game.EndedAt, + participants + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableQuery.cs b/StackDuel.Application/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableQuery.cs new file mode 100644 index 0000000..3e54554 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetAdminGamesPageable/GetAdminGamesPageableQuery.cs @@ -0,0 +1,8 @@ +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Queries.Games.GetAdminGamesPageable; + +public sealed record GetAdminGamesPageableQuery(GameStatus? Status, PaginationRequest PaginationRequest) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordHandler.cs b/StackDuel.Application/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordHandler.cs new file mode 100644 index 0000000..05c2b10 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordHandler.cs @@ -0,0 +1,52 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Games.GetDuelHeadToHeadRecord; + +internal sealed class GetDuelHeadToHeadRecordHandler(IGameReadRepository gameReadRepository) + : IQueryHandler +{ + public async Task> Handle( + GetDuelHeadToHeadRecordQuery request, + CancellationToken cancellationToken + ) + { + GameMode? duelMode = await gameReadRepository.FindGameModeByKeyAsync("duel", cancellationToken); + if (duelMode is null) + return Result.NotFound("Duel game mode was not found."); + + IReadOnlyList completedGames = await gameReadRepository.GetCompletedGamesForUserAsync( + request.UserId, + cancellationToken + ); + + var matchups = completedGames.Where(g => + g.GameModeId == duelMode.Id && g.Participants.Any(p => p.UserId == request.OpponentId) + ); + + int wins = 0, + losses = 0, + draws = 0, + gamesPlayed = 0; + + foreach (Game game in matchups) + { + gamesPlayed++; + + int myScore = game.Participants.First(p => p.UserId == request.UserId).Score; + int maxScore = game.Participants.Max(p => p.Score); + int topScorerCount = game.Participants.Count(p => p.Score == maxScore); + + if (myScore != maxScore) + losses++; + else if (topScorerCount > 1) + draws++; + else + wins++; + } + + return Result.Success(new DuelHeadToHeadRecordDto(wins, losses, draws, gamesPlayed)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordQuery.cs b/StackDuel.Application/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordQuery.cs new file mode 100644 index 0000000..6016488 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetDuelHeadToHeadRecord/GetDuelHeadToHeadRecordQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Games.Dtos; + +namespace StackDuel.Application.Queries.Games.GetDuelHeadToHeadRecord; + +public sealed record GetDuelHeadToHeadRecordQuery(Guid UserId, Guid OpponentId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetGameModes/GetGameModesHandler.cs b/StackDuel.Application/Queries/Games/GetGameModes/GetGameModesHandler.cs new file mode 100644 index 0000000..816d3af --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetGameModes/GetGameModesHandler.cs @@ -0,0 +1,38 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Queries; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Games.GetGameModes; + +internal sealed class GetGameModesHandler(IGameReadRepository gameReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetGameModesQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList modes = await gameReadRepository.GetActiveGameModesAsync(cancellationToken); + + IReadOnlyList dtos = + [ + .. modes.Select(m => new GameModeDto( + m.Id, + m.Key, + m.Name, + m.Description, + m.MinPlayers, + m.MaxPlayers, + [ + .. m + .TimeOptions.OrderBy(o => o.DurationSeconds) + .Select(o => new GameModeTimeOptionDto(o.DurationSeconds, o.IsDefault)), + ] + )), + ]; + + return Result>.Success(dtos); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetGameModes/GetGameModesQuery.cs b/StackDuel.Application/Queries/Games/GetGameModes/GetGameModesQuery.cs new file mode 100644 index 0000000..04d7f4e --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetGameModes/GetGameModesQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Games.GetGameModes; + +public sealed record GetGameModesQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetGameProblems/GetGameProblemsHandler.cs b/StackDuel.Application/Queries/Games/GetGameProblems/GetGameProblemsHandler.cs new file mode 100644 index 0000000..6727880 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetGameProblems/GetGameProblemsHandler.cs @@ -0,0 +1,41 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Queries; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Games.GetGameProblems; + +internal sealed class GetGameProblemsHandler(IGameReadRepository gameReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetGameProblemsQuery request, + CancellationToken cancellationToken + ) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result>.NotFound(); + + bool isParticipant = game.Participants.Any(p => p.UserId == request.RequestedByUserId); + if (!isParticipant) + return Result>.Forbidden(); + + var problemHistories = game + .Participants.Select(p => new GameProblemHistoryDto( + p.UserId, + p.ProblemSession?.SolvedProblemIds.ToList() ?? new List(), + p.ProblemSession?.SolvedProblemSubmissionIds.Select(kvp => new SolvedProblemSubmissionDto( + kvp.Key, + kvp.Value + )) + .ToList() + ?? new List() + )) + .ToList(); + + return Result>.Success(problemHistories); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetGameProblems/GetGameProblemsQuery.cs b/StackDuel.Application/Queries/Games/GetGameProblems/GetGameProblemsQuery.cs new file mode 100644 index 0000000..bafb2f4 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetGameProblems/GetGameProblemsQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Games.GetGameProblems; + +internal sealed record GetGameProblemsQuery(Guid GameId, Guid RequestedByUserId) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetGameState/GetGameStateHandler.cs b/StackDuel.Application/Queries/Games/GetGameState/GetGameStateHandler.cs new file mode 100644 index 0000000..f5201e2 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetGameState/GetGameStateHandler.cs @@ -0,0 +1,115 @@ +using Ardalis.Result; +using StackDuel.Application.Events; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Queries.Games.GetGameState; + +internal sealed class GetGameStateHandler( + IGameReadRepository gameReadRepository, + IGameWriteRepository gameWriteRepository, + IGameExpiryCanceller gameExpiryCanceller, + IDomainEventDispatcher domainEventDispatcher, + IUserReadRepository userReadRepository, + ITrackReadRepository trackReadRepository +) : IQueryHandler +{ + public async Task> Handle(GetGameStateQuery request, CancellationToken cancellationToken) + { + Game? game = await gameReadRepository.FindGameByIdAsync(request.GameId, cancellationToken); + + if (game is null) + return Result.NotFound(); + + bool isParticipant = game.Participants.Any(p => p.UserId == request.RequestedByUserId); + if (!isParticipant) + return Result.Forbidden(); + + if (game.CompleteIfExpired(DateTime.UtcNow)) + { + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + } + else if (game.CompleteIfEveryoneStopped()) + { + // Self-healing for the race where two participants stop playing (forfeit/finish) via + // concurrent requests and each writes before seeing the other's — neither call + // completes the game, so it's left Running with nobody left to act in it until the + // next read notices. Early completion (not via the clock), so cancel the now-moot + // scheduled expiry too, same as Forfeit/CompleteProblem do for their own completions. + await gameExpiryCanceller.CancelIfScheduledAsync(game, cancellationToken); + await gameWriteRepository.SaveChangesAsync(game, cancellationToken); + await domainEventDispatcher.DispatchAsync(game.PopDomainEvents(), cancellationToken); + } + + GameMode? gameMode = await gameReadRepository.FindGameModeByIdIncludingInactiveAsync( + game.GameModeId, + cancellationToken + ); + + if (gameMode is null) + return Result.Error("Game references a mode that no longer exists."); + + IReadOnlyDictionary userMap = await userReadRepository.FindByIdsAsync( + game.Participants.Select(p => p.UserId), + cancellationToken + ); + + IReadOnlyList tracks = await trackReadRepository.FindByIdsAsync( + game.Tracks.Select(t => t.TrackId), + cancellationToken + ); + Dictionary trackNamesById = tracks.ToDictionary(t => t.Id, t => t.Name); + + var participants = game + .Participants.Select(p => + { + userMap.TryGetValue(p.UserId, out UserDto? user); + + GameCurrentProblemDto? currentProblem = + p.ProblemSession != null ? new GameCurrentProblemDto(p.ProblemSession.CurrentProblemId) : null; + + return new GameParticipantDto( + p.UserId, + user?.Username ?? string.Empty, + user?.ImageUrl, + p.SeatNo, + p.JoinedAt, + p.Score, + currentProblem, + p.HasForfeited, + p.HasFinishedProblems, + p.SkipsRemaining + ); + }) + .ToArray(); + + return Result.Success( + new GameStateDto( + game.Id, + game.GameModeId, + gameMode.Key, + game.Status, + game.TimeLimitInSeconds, + game.CreatedAt, + game.StartedAt, + game.EndedAt, + participants, + [ + .. game + .Tracks.Select(t => trackNamesById.GetValueOrDefault(t.TrackId)) + .Where(name => name is not null) + .Select(name => name!), + ], + game.JoinCode, + game.SkipsEnabled + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetGameState/GetGameStateQuery.cs b/StackDuel.Application/Queries/Games/GetGameState/GetGameStateQuery.cs new file mode 100644 index 0000000..4046502 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetGameState/GetGameStateQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.Games.GetGameState; + +public sealed record GetGameStateQuery(Guid GameId, Guid RequestedByUserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetMyActiveGames/GetMyActiveGamesHandler.cs b/StackDuel.Application/Queries/Games/GetMyActiveGames/GetMyActiveGamesHandler.cs new file mode 100644 index 0000000..19211a7 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetMyActiveGames/GetMyActiveGamesHandler.cs @@ -0,0 +1,85 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Queries.Games.GetMyActiveGames; + +internal sealed class GetMyActiveGamesHandler( + IGameReadRepository gameReadRepository, + ITrackReadRepository trackReadRepository, + IUserReadRepository userReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetMyActiveGamesQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList games = await gameReadRepository.GetActiveGamesForUserAsync( + request.RequestedByUserId, + cancellationToken + ); + + Dictionary gameModesById = []; + foreach (Guid gameModeId in games.Select(g => g.GameModeId).Distinct()) + { + GameMode? gameMode = await gameReadRepository.FindGameModeByIdIncludingInactiveAsync( + gameModeId, + cancellationToken + ); + if (gameMode is not null) + gameModesById[gameModeId] = gameMode; + } + + IReadOnlyList tracks = await trackReadRepository.FindByIdsAsync( + games.SelectMany(g => g.Tracks.Select(t => t.TrackId)).Distinct(), + cancellationToken + ); + Dictionary trackNamesById = tracks.ToDictionary(t => t.Id, t => t.Name); + + IReadOnlyDictionary hostsByUserId = await userReadRepository.FindByIdsAsync( + games.Select(g => g.HostUserId).Where(id => id is not null).Select(id => id!.Value), + cancellationToken + ); + + var results = games + .Where(g => gameModesById.ContainsKey(g.GameModeId)) + .Select(g => + { + GameMode gameMode = gameModesById[g.GameModeId]; + Guid? hostUserId = g.HostUserId; + string hostUsername = + hostUserId is Guid id && hostsByUserId.TryGetValue(id, out UserDto? host) + ? host.Username + : string.Empty; + + return new MyActiveGameDto( + g.Id, + gameMode.Key, + gameMode.Name, + g.Status, + g.TimeLimitInSeconds, + g.CreatedAt, + g.StartedAt, + g.Participants.Count, + gameMode.MaxPlayers, + hostUserId == request.RequestedByUserId, + hostUsername, + [ + .. g + .Tracks.Select(t => trackNamesById.GetValueOrDefault(t.TrackId)) + .Where(name => name is not null) + .Select(name => name!), + ] + ); + }) + .ToList(); + + return Result>.Success(results); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetMyActiveGames/GetMyActiveGamesQuery.cs b/StackDuel.Application/Queries/Games/GetMyActiveGames/GetMyActiveGamesQuery.cs new file mode 100644 index 0000000..d616064 --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetMyActiveGames/GetMyActiveGamesQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Games.Dtos; + +namespace StackDuel.Application.Queries.Games.GetMyActiveGames; + +internal sealed record GetMyActiveGamesQuery(Guid RequestedByUserId) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetOpenGames/GetOpenGamesHandler.cs b/StackDuel.Application/Queries/Games/GetOpenGames/GetOpenGamesHandler.cs new file mode 100644 index 0000000..e0518dd --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetOpenGames/GetOpenGamesHandler.cs @@ -0,0 +1,91 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Queries.Games.GetOpenGames; + +internal sealed class GetOpenGamesHandler( + IGameReadRepository gameReadRepository, + IUserReadRepository userReadRepository, + ITrackReadRepository trackReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetOpenGamesQuery request, + CancellationToken cancellationToken + ) + { + PageResult page = await gameReadRepository.GetPendingGamesAsync( + request.GameModeKey, + request.PaginationRequest, + cancellationToken + ); + + Dictionary gameModesById = []; + foreach (Guid gameModeId in page.Results.Select(g => g.GameModeId).Distinct()) + { + GameMode? gameMode = await gameReadRepository.FindGameModeByIdAsync(gameModeId, cancellationToken); + if (gameMode is not null) + gameModesById[gameModeId] = gameMode; + } + + IReadOnlyDictionary hostsByUserId = await userReadRepository.FindByIdsAsync( + page.Results.Select(g => g.HostUserId).Where(id => id is not null).Select(id => id!.Value), + cancellationToken + ); + + IReadOnlyList tracks = await trackReadRepository.FindByIdsAsync( + page.Results.SelectMany(g => g.Tracks.Select(t => t.TrackId)).Distinct(), + cancellationToken + ); + Dictionary trackNamesById = tracks.ToDictionary(t => t.Id, t => t.Name); + + var summaries = page + .Results.Where(g => gameModesById.ContainsKey(g.GameModeId)) + .Select(g => + { + GameMode gameMode = gameModesById[g.GameModeId]; + Guid? hostUserId = g.HostUserId; + string hostUsername = + hostUserId is Guid id && hostsByUserId.TryGetValue(id, out UserDto? host) + ? host.Username + : string.Empty; + + return new GameLobbySummaryDto( + g.Id, + gameMode.Key, + gameMode.Name, + g.TimeLimitInSeconds, + g.CreatedAt, + gameMode.MinPlayers, + gameMode.MaxPlayers, + g.Participants.Count, + hostUsername, + request.RequestedByUserId is Guid requestedByUserId && hostUserId == requestedByUserId, + request.RequestedByUserId is Guid participantUserId + && g.Participants.Any(p => p.UserId == participantUserId), + g.Tracks.Select(t => trackNamesById.GetValueOrDefault(t.TrackId)) + .Where(name => name is not null) + .Select(name => name!) + .ToList() + ); + }) + .ToList(); + + return Result>.Success( + new PageResult + { + Results = summaries, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Games/GetOpenGames/GetOpenGamesQuery.cs b/StackDuel.Application/Queries/Games/GetOpenGames/GetOpenGamesQuery.cs new file mode 100644 index 0000000..9f3a46a --- /dev/null +++ b/StackDuel.Application/Queries/Games/GetOpenGames/GetOpenGamesQuery.cs @@ -0,0 +1,10 @@ +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; + +namespace StackDuel.Application.Queries.Games.GetOpenGames; + +internal sealed record GetOpenGamesQuery( + string? GameModeKey, + PaginationRequest PaginationRequest, + Guid? RequestedByUserId +) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Groups/GetGroups/GetGroupsHandler.cs b/StackDuel.Application/Queries/Groups/GetGroups/GetGroupsHandler.cs new file mode 100644 index 0000000..8357e5d --- /dev/null +++ b/StackDuel.Application/Queries/Groups/GetGroups/GetGroupsHandler.cs @@ -0,0 +1,19 @@ +using Ardalis.Result; +using StackDuel.Application.Groups; +using StackDuel.Application.Groups.Dtos; + +namespace StackDuel.Application.Queries.Groups.GetGroups; + +internal sealed class GetGroupsHandler(IGroupReadRepository groupReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetGroupsQuery request, + CancellationToken cancellationToken + ) + { + var groups = await groupReadRepository.GetAllAsync(cancellationToken); + + return Result.Success(groups); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Groups/GetGroups/GetGroupsQuery.cs b/StackDuel.Application/Queries/Groups/GetGroups/GetGroupsQuery.cs new file mode 100644 index 0000000..2f6fd38 --- /dev/null +++ b/StackDuel.Application/Queries/Groups/GetGroups/GetGroupsQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Groups.Dtos; + +namespace StackDuel.Application.Queries.Groups.GetGroups; + +public sealed record GetGroupsQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/IQuery.cs b/StackDuel.Application/Queries/IQuery.cs new file mode 100644 index 0000000..7761a51 --- /dev/null +++ b/StackDuel.Application/Queries/IQuery.cs @@ -0,0 +1,6 @@ +using Ardalis.Result; +using MediatR; + +namespace StackDuel.Application.Queries; + +public interface IQuery : IRequest> { } \ No newline at end of file diff --git a/StackDuel.Application/Queries/IQueryHandler.cs b/StackDuel.Application/Queries/IQueryHandler.cs new file mode 100644 index 0000000..b769b00 --- /dev/null +++ b/StackDuel.Application/Queries/IQueryHandler.cs @@ -0,0 +1,8 @@ +using Ardalis.Result; +using MediatR; + +namespace StackDuel.Application.Queries; + +public interface IQueryHandler : IRequestHandler> + where TQuery : IQuery +{ } \ No newline at end of file diff --git a/StackDuel.Application/Queries/Languages/GetLanguages/GetLanguagesHandler.cs b/StackDuel.Application/Queries/Languages/GetLanguages/GetLanguagesHandler.cs new file mode 100644 index 0000000..fc21d0d --- /dev/null +++ b/StackDuel.Application/Queries/Languages/GetLanguages/GetLanguagesHandler.cs @@ -0,0 +1,33 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Languages.Dtos; + +namespace StackDuel.Application.Queries.Languages.GetLanguages; + +internal sealed class GetLanguagesHandler(ILanguageReadRepository languageReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetLanguagesQuery request, + CancellationToken cancellationToken + ) + { + IEnumerable languages = + await languageReadRepository.GetActiveLanguagesAsync(cancellationToken); + + IReadOnlyList result = + [ + .. languages.Select(language => new LanguageDto( + language.Id, + language.Name.Value, + [ + .. language + .Versions.Where(version => version.IsActive) + .Select(version => new LanguageVersionDto(version.Id, version.Version.Value)), + ] + )), + ]; + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Languages/GetLanguages/GetLanguagesQuery.cs b/StackDuel.Application/Queries/Languages/GetLanguages/GetLanguagesQuery.cs new file mode 100644 index 0000000..b195714 --- /dev/null +++ b/StackDuel.Application/Queries/Languages/GetLanguages/GetLanguagesQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Languages.Dtos; + +namespace StackDuel.Application.Queries.Languages.GetLanguages; + +public sealed record GetLanguagesQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Leaderboards/GetLeaderboard/GetLeaderboardHandler.cs b/StackDuel.Application/Queries/Leaderboards/GetLeaderboard/GetLeaderboardHandler.cs new file mode 100644 index 0000000..aa41c25 --- /dev/null +++ b/StackDuel.Application/Queries/Leaderboards/GetLeaderboard/GetLeaderboardHandler.cs @@ -0,0 +1,67 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Leaderboards; +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Leaderboards.GetLeaderboard; + +internal sealed class GetLeaderboardHandler( + IGameReadRepository gameReadRepository, + ILeaderboardReadRepository leaderboardReadRepository, + IUserReadRepository userReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetLeaderboardQuery request, + CancellationToken cancellationToken + ) + { + GameMode? gameMode = await gameReadRepository.FindGameModeByKeyAsync(request.GameModeKey, cancellationToken); + if (gameMode is null) + return Result>.NotFound("Game mode not found."); + + PageResult page = await leaderboardReadRepository.GetRankingsAsync( + gameMode.Id, + request.TimeLimitInSeconds, + request.PaginationRequest, + cancellationToken + ); + + IReadOnlyDictionary usersById = await userReadRepository.FindByIdsAsync( + page.Results.Select(p => p.UserId), + cancellationToken + ); + + List entries = page + .Results.Select( + (participant, index) => + { + usersById.TryGetValue(participant.UserId, out UserDto? user); + + return new LeaderboardEntryDto( + page.Offset + index + 1, + participant.UserId, + user?.Username ?? string.Empty, + user?.ImageUrl, + participant.HighScore, + request.RequestedByUserId == participant.UserId + ); + } + ) + .ToList(); + + return Result>.Success( + new PageResult + { + Results = entries, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Leaderboards/GetLeaderboard/GetLeaderboardQuery.cs b/StackDuel.Application/Queries/Leaderboards/GetLeaderboard/GetLeaderboardQuery.cs new file mode 100644 index 0000000..388ae63 --- /dev/null +++ b/StackDuel.Application/Queries/Leaderboards/GetLeaderboard/GetLeaderboardQuery.cs @@ -0,0 +1,11 @@ +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Application.Pagination; + +namespace StackDuel.Application.Queries.Leaderboards.GetLeaderboard; + +public sealed record GetLeaderboardQuery( + string GameModeKey, + int TimeLimitInSeconds, + PaginationRequest PaginationRequest, + Guid? RequestedByUserId +) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryHandler.cs b/StackDuel.Application/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryHandler.cs new file mode 100644 index 0000000..0888f92 --- /dev/null +++ b/StackDuel.Application/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryHandler.cs @@ -0,0 +1,35 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Leaderboards; +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Leaderboards.GetMyLeaderboardEntry; + +internal sealed class GetMyLeaderboardEntryHandler( + IGameReadRepository gameReadRepository, + ILeaderboardReadRepository leaderboardReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetMyLeaderboardEntryQuery request, + CancellationToken cancellationToken + ) + { + GameMode? gameMode = await gameReadRepository.FindGameModeByKeyAsync(request.GameModeKey, cancellationToken); + if (gameMode is null) + return Result.NotFound("Game mode not found."); + + LeaderboardParticipant? participant = await leaderboardReadRepository.FindParticipantForUserAsync( + gameMode.Id, + request.TimeLimitInSeconds, + request.UserId, + cancellationToken + ); + + if (participant is null) + return Result.NotFound("No leaderboard entry for this user yet."); + + return Result.Success(new MyLeaderboardEntryDto(participant.HighScore)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryQuery.cs b/StackDuel.Application/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryQuery.cs new file mode 100644 index 0000000..277a33b --- /dev/null +++ b/StackDuel.Application/Queries/Leaderboards/GetMyLeaderboardEntry/GetMyLeaderboardEntryQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Leaderboards.Dtos; + +namespace StackDuel.Application.Queries.Leaderboards.GetMyLeaderboardEntry; + +public sealed record GetMyLeaderboardEntryQuery(string GameModeKey, int TimeLimitInSeconds, Guid UserId) + : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Permissions/GetUserAccessContext/GetUserAccessContextHandler.cs b/StackDuel.Application/Queries/Permissions/GetUserAccessContext/GetUserAccessContextHandler.cs new file mode 100644 index 0000000..d16305d --- /dev/null +++ b/StackDuel.Application/Queries/Permissions/GetUserAccessContext/GetUserAccessContextHandler.cs @@ -0,0 +1,35 @@ +using Ardalis.Result; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Authorization; + +namespace StackDuel.Application.Queries.Permissions.GetUserAccessContext; + +internal sealed class GetUserAccessContextHandler( + IUserReadRepository userReadRepository, + IAuthorizationReadRepository authorizationReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetUserAccessContextQuery request, + CancellationToken cancellationToken + ) + { + UserDto? user = await userReadRepository.FindBySubAsync(request.Sub, cancellationToken); + + if (user is null) + return Result.NotFound(); + + IReadOnlyList permissions = await authorizationReadRepository.GetUserPermissionsAsync( + user.Id, + cancellationToken + ); + + IReadOnlyList roles = await authorizationReadRepository.GetUserRoleNamesAsync( + user.Id, + cancellationToken + ); + + return Result.Success(new UserAccessContextDto(user, permissions, roles)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Permissions/GetUserAccessContext/GetUserAccessContextQuery.cs b/StackDuel.Application/Queries/Permissions/GetUserAccessContext/GetUserAccessContextQuery.cs new file mode 100644 index 0000000..9591674 --- /dev/null +++ b/StackDuel.Application/Queries/Permissions/GetUserAccessContext/GetUserAccessContextQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Permissions.GetUserAccessContext; + +public sealed record GetUserAccessContextQuery(string Sub) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersHandler.cs b/StackDuel.Application/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersHandler.cs new file mode 100644 index 0000000..8b98d4e --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersHandler.cs @@ -0,0 +1,30 @@ +using Ardalis.Result; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Queries.ProblemPools.GetOrderedProblemPoolMembers; + +internal sealed class GetOrderedProblemPoolMembersHandler( + IProblemPoolRepository problemPoolRepository, + IProblemReadRepository problemReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetOrderedProblemPoolMembersQuery request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + IReadOnlyCollection orderedIds = pool.ProblemIds; + var rows = await problemReadRepository.FindByIdsAsync(orderedIds, cancellationToken); + Dictionary rowsById = rows.ToDictionary(row => row.Id); + + return Result.Success>( + [.. orderedIds.Where(rowsById.ContainsKey).Select(id => rowsById[id])] + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersQuery.cs b/StackDuel.Application/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersQuery.cs new file mode 100644 index 0000000..9ce8c10 --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetOrderedProblemPoolMembers/GetOrderedProblemPoolMembersQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.ProblemPools.GetOrderedProblemPoolMembers; + +public sealed record GetOrderedProblemPoolMembersQuery(string PoolKey) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsHandler.cs b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsHandler.cs new file mode 100644 index 0000000..dfa61c8 --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsHandler.cs @@ -0,0 +1,21 @@ +using Ardalis.Result; +using StackDuel.Application.Problems; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Application.Queries.ProblemPools.GetProblemPoolMemberIds; + +internal sealed class GetProblemPoolMemberIdsHandler(IProblemPoolRepository problemPoolRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetProblemPoolMemberIdsQuery request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + return Result.Success>([.. pool.ProblemIds]); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsQuery.cs b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsQuery.cs new file mode 100644 index 0000000..f4ba68d --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMemberIds/GetProblemPoolMemberIdsQuery.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Queries.ProblemPools.GetProblemPoolMemberIds; + +public sealed record GetProblemPoolMemberIdsQuery(string PoolKey) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedHandler.cs b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedHandler.cs new file mode 100644 index 0000000..6d0feda --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedHandler.cs @@ -0,0 +1,77 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using DifficultyTier = StackDuel.Domain.Problems.Enums.DifficultyTier; + +namespace StackDuel.Application.Queries.ProblemPools.GetProblemPoolMembersPaged; + +internal sealed class GetProblemPoolMembersPagedHandler( + IProblemPoolRepository problemPoolRepository, + IProblemReadRepository problemReadRepository, + ILanguageReadRepository languageReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetProblemPoolMembersPagedQuery request, + CancellationToken cancellationToken + ) + { + ProblemPool? pool = await problemPoolRepository.FindByKeyAsync(request.PoolKey, cancellationToken); + if (pool is null) + return Result.NotFound($"Pool '{request.PoolKey}' was not found."); + + var page = await problemReadRepository.GetPoolMembersPagedAsync( + pool.Id, + request.PaginationRequest, + cancellationToken + ); + + Guid[] versionIds = [.. page.Results.SelectMany(r => r.LanguageVersionIds).Distinct()]; + var languages = await languageReadRepository.FindLanguagesByVersionId(versionIds, cancellationToken); + + Dictionary versionDisplayNames = languages + .SelectMany(language => + language.Versions.Select(version => (version.Id, Display: $"{language.Name} {version.Version}")) + ) + .ToDictionary(x => x.Id, x => x.Display); + + var results = page + .Results.Select(row => new AdminProblemListItemDto( + row.Id, + row.Slug, + row.Title, + row.DifficultyValue, + ToDifficultyTier(row.DifficultyValue), + row.Status, + row.TimeLimitMs, + row.MemoryLimitMb, + row.Tags, + [.. row.LanguageVersionIds.Select(id => versionDisplayNames.GetValueOrDefault(id, "Unknown"))], + row.SetupCount, + row.CreatedAt, + row.CreatedByUsername + )) + .ToList(); + + return Result.Success( + new PageResult + { + Results = results, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } + + private static DifficultyTier ToDifficultyTier(int difficultyValue) => + difficultyValue <= Difficulty.BeginnerMax ? DifficultyTier.Beginner + : difficultyValue <= Difficulty.EasyMax ? DifficultyTier.Easy + : difficultyValue <= Difficulty.IntermediateMax ? DifficultyTier.Intermediate + : difficultyValue <= Difficulty.AdvancedMax ? DifficultyTier.Advanced + : DifficultyTier.Expert; +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedQuery.cs b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedQuery.cs new file mode 100644 index 0000000..87a966d --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetProblemPoolMembersPaged/GetProblemPoolMembersPagedQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.ProblemPools.GetProblemPoolMembersPaged; + +public sealed record GetProblemPoolMembersPagedQuery(string PoolKey, PaginationRequest PaginationRequest) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetProblemPools/GetProblemPoolsHandler.cs b/StackDuel.Application/Queries/ProblemPools/GetProblemPools/GetProblemPoolsHandler.cs new file mode 100644 index 0000000..fe42e28 --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetProblemPools/GetProblemPoolsHandler.cs @@ -0,0 +1,19 @@ +using Ardalis.Result; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.ProblemPools.GetProblemPools; + +internal sealed class GetProblemPoolsHandler(IProblemPoolRepository problemPoolRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetProblemPoolsQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList pools = await problemPoolRepository.GetAllAsync(cancellationToken); + + return Result>.Success(pools); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/ProblemPools/GetProblemPools/GetProblemPoolsQuery.cs b/StackDuel.Application/Queries/ProblemPools/GetProblemPools/GetProblemPoolsQuery.cs new file mode 100644 index 0000000..b35bdc7 --- /dev/null +++ b/StackDuel.Application/Queries/ProblemPools/GetProblemPools/GetProblemPoolsQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Queries; + +namespace StackDuel.Application.Queries.ProblemPools.GetProblemPools; + +public sealed record GetProblemPoolsQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailHandler.cs b/StackDuel.Application/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailHandler.cs new file mode 100644 index 0000000..77daab5 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailHandler.cs @@ -0,0 +1,112 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Tracks; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Queries.Problems.GetAdminProblemDetail; + +internal sealed class GetAdminProblemDetailHandler( + IProblemReadRepository problemReadRepository, + ILanguageReadRepository languageReadRepository, + IProblemPoolRepository problemPoolRepository, + ITrackReadRepository trackReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetAdminProblemDetailQuery request, + CancellationToken cancellationToken + ) + { + var problem = await problemReadRepository.FindByIdForAdminAsync(request.ProblemId, cancellationToken); + + if (problem is null) + return Result.NotFound(); + + var languages = await languageReadRepository.FindLanguagesByVersionId( + problem.AvailableLanguageVersionIds(), + cancellationToken + ); + var poolKeys = await problemPoolRepository.GetPoolKeysForProblemAsync(problem.Id, cancellationToken); + + Track? track = problem.TrackId is Guid trackId + ? await trackReadRepository.FindByIdAsync(trackId, cancellationToken) + : null; + + Dictionary versionLookup = languages + .SelectMany(language => + language.Versions.Select(version => + (version.Id, LanguageName: (string)language.Name, VersionLabel: (string)version.Version) + ) + ) + .ToDictionary(x => x.Id, x => (x.LanguageName, x.VersionLabel)); + + var setups = problem + .Setups.Select(setup => + { + var (languageName, versionLabel) = versionLookup.TryGetValue(setup.LanguageVersionId, out var found) + ? found + : ("Unknown", "?"); + + List sampleTestCases = + [ + .. setup + .TestSuites.Where(ts => ts.Type == TestSuiteType.Sample) + .SelectMany(ts => ts.TestCases) + .Where(tc => tc.Source == TestCaseSource.Authored && tc.RetiredAt is null) + .Select(tc => new AdminSampleTestCaseDto( + tc.Id, + tc.Name, + [.. tc.Inputs.Select(i => new AdminSampleTestCaseInputDto(i.Value, i.ValueType))], + tc.ExpectedOutputs.Select(o => o.Value).FirstOrDefault() ?? string.Empty, + tc.ExpectedOutputs.Select(o => o.ValueType).FirstOrDefault() ?? string.Empty + )), + ]; + + return new AdminProblemSetupDto( + setup.Id, + setup.LanguageVersionId, + languageName, + versionLabel, + setup.FunctionName, + setup.InitialCode, + setup.ReferenceSolutionCode, + setup.ReferenceSolutionCode is not null, + setup.GenerationSpecId is not null, + setup.TestSuites.Count, + setup.TestSuites.Sum(ts => ts.TestCases.Count), + sampleTestCases + ); + }) + .ToList(); + + return Result.Success( + new AdminProblemDetailDto( + problem.Id, + problem.Slug, + problem.Title, + problem.Question, + problem.Difficulty.Value, + problem.Difficulty.Tier, + problem.TimeLimit.Milliseconds, + problem.MemoryLimit.Megabytes, + problem.Status, + problem.ValidationFailureReason, + problem.TrackId, + track?.Name, + problem.CreatedAt, + problem.CreatedBy?.Username.Value, + [.. problem.Tags.Select(t => t.Name.Value)], + poolKeys, + setups, + problem.GenerationSpec?.Parameters ?? [], + problem.GenerationSpec?.OutputValueType, + problem.GenerationSpec?.TargetCaseCount, + problem.GenerationSpec?.Seed + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailQuery.cs b/StackDuel.Application/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailQuery.cs new file mode 100644 index 0000000..abebcce --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetAdminProblemDetail/GetAdminProblemDetailQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetAdminProblemDetail; + +public sealed record GetAdminProblemDetailQuery(Guid ProblemId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableHandler.cs b/StackDuel.Application/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableHandler.cs new file mode 100644 index 0000000..61aec66 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableHandler.cs @@ -0,0 +1,72 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.ValueObjects; +using DifficultyTier = StackDuel.Domain.Problems.Enums.DifficultyTier; + +namespace StackDuel.Application.Queries.Problems.GetAdminProblemsPageable; + +internal sealed class GetAdminProblemsPageableHandler( + IProblemReadRepository problemReadRepository, + ILanguageReadRepository languageReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetAdminProblemsPageableQuery request, + CancellationToken cancellationToken + ) + { + var page = await problemReadRepository.GetAdminProblemsPagedAsync( + request.PaginationRequest, + request.Search, + cancellationToken + ); + + Guid[] versionIds = [.. page.Results.SelectMany(r => r.LanguageVersionIds).Distinct()]; + + var languages = await languageReadRepository.FindLanguagesByVersionId(versionIds, cancellationToken); + + Dictionary versionDisplayNames = languages + .SelectMany(language => + language.Versions.Select(version => (version.Id, Display: $"{language.Name} {version.Version}")) + ) + .ToDictionary(x => x.Id, x => x.Display); + + var results = page + .Results.Select(row => new AdminProblemListItemDto( + row.Id, + row.Slug, + row.Title, + row.DifficultyValue, + ToDifficultyTier(row.DifficultyValue), + row.Status, + row.TimeLimitMs, + row.MemoryLimitMb, + row.Tags, + [.. row.LanguageVersionIds.Select(id => versionDisplayNames.GetValueOrDefault(id, "Unknown"))], + row.SetupCount, + row.CreatedAt, + row.CreatedByUsername + )) + .ToList(); + + return Result.Success( + new PageResult + { + Results = results, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } + + private static DifficultyTier ToDifficultyTier(int difficultyValue) => + difficultyValue <= Difficulty.BeginnerMax ? DifficultyTier.Beginner + : difficultyValue <= Difficulty.EasyMax ? DifficultyTier.Easy + : difficultyValue <= Difficulty.IntermediateMax ? DifficultyTier.Intermediate + : difficultyValue <= Difficulty.AdvancedMax ? DifficultyTier.Advanced + : DifficultyTier.Expert; +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableQuery.cs b/StackDuel.Application/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableQuery.cs new file mode 100644 index 0000000..01bdcc1 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetAdminProblemsPageable/GetAdminProblemsPageableQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetAdminProblemsPageable; + +public sealed record GetAdminProblemsPageableQuery(PaginationRequest PaginationRequest, string? Search) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemById/GetProblemByIdHandler.cs b/StackDuel.Application/Queries/Problems/GetProblemById/GetProblemByIdHandler.cs new file mode 100644 index 0000000..03f473b --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemById/GetProblemByIdHandler.cs @@ -0,0 +1,66 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemById; + +internal sealed class GetProblemByIdHandler( + IProblemReadRepository problemReadRepository, + ILanguageReadRepository languageReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetProblemByIdQuery request, + CancellationToken cancellationToken + ) + { + var problem = await problemReadRepository.FindByIdAsync(request.Id, cancellationToken); + + if (problem is null) + { + return Result.NotFound(); + } + + var languages = await languageReadRepository.FindLanguagesByVersionId( + problem.AvailableLanguageVersionIds(), + cancellationToken + ); + + var publicTestCases = + problem + .Setups.FirstOrDefault() + ?.PublicTestSuites() + .SelectMany(suite => suite.TestCases) + .Select(tc => new PublicTestCaseDto( + tc.Name, + tc.Description, + tc.Inputs.Select(i => new PublicTestCaseInputDto(i.Value, i.ValueType)), + tc.ExpectedOutputs.Select(o => new PublicTestCaseExpectedOutputDto(o.Value, o.ValueType)) + )) + ?? []; + + return Result.Success( + new ProblemWithSetupsDto( + Id: problem.Id, + Slug: problem.Slug, + Title: problem.Title, + DifficultyTier: problem.Difficulty.Tier, + Question: problem.Question, + AvailableLanguages: languages.Select(language => new ProblemSetupLanguageDto( + language.Id, + language.Name, + Versions: language.Versions.Select(version => new ProblemSetupLanguageVersionDto( + version.Id, + version.Version + )) + )), + PublicTestCases: publicTestCases, + Author: problem.CreatedBy is null + ? null + : new ProblemAuthorDto(problem.CreatedBy.Username.Value, problem.CreatedBy.ImageUrl?.Value), + Tags: problem.Tags.Select(t => t.Name.Value) + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemById/GetProblemByIdQuery.cs b/StackDuel.Application/Queries/Problems/GetProblemById/GetProblemByIdQuery.cs new file mode 100644 index 0000000..12eea65 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemById/GetProblemByIdQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemById; + +public sealed record GetProblemByIdQuery(Guid Id) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs b/StackDuel.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs new file mode 100644 index 0000000..3693d7d --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugHandler.cs @@ -0,0 +1,66 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemBySlug; + +internal sealed class GetProblemBySlugHandler( + IProblemReadRepository problemReadRepository, + ILanguageReadRepository languageReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetProblemBySlugQuery request, + CancellationToken cancellationToken + ) + { + var problem = await problemReadRepository.FindBySlugAsync(request.Slug, cancellationToken); + + if (problem is null) + { + return Result.NotFound(); + } + + var languages = await languageReadRepository.FindLanguagesByVersionId( + problem.AvailableLanguageVersionIds(), + cancellationToken + ); + + var publicTestCases = + problem + .Setups.FirstOrDefault() + ?.PublicTestSuites() + .SelectMany(suite => suite.TestCases) + .Select(tc => new PublicTestCaseDto( + tc.Name, + tc.Description, + tc.Inputs.Select(i => new PublicTestCaseInputDto(i.Value, i.ValueType)), + tc.ExpectedOutputs.Select(o => new PublicTestCaseExpectedOutputDto(o.Value, o.ValueType)) + )) + ?? []; + + return Result.Success( + new ProblemWithSetupsDto( + Id: problem.Id, + Slug: problem.Slug, + Title: problem.Title, + DifficultyTier: problem.Difficulty.Tier, + Question: problem.Question, + AvailableLanguages: languages.Select(language => new ProblemSetupLanguageDto( + language.Id, + language.Name, + Versions: language.Versions.Select(version => new ProblemSetupLanguageVersionDto( + version.Id, + version.Version + )) + )), + PublicTestCases: publicTestCases, + Author: problem.CreatedBy is null + ? null + : new ProblemAuthorDto(problem.CreatedBy.Username.Value, problem.CreatedBy.ImageUrl?.Value), + Tags: problem.Tags.Select(t => t.Name.Value) + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs b/StackDuel.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs new file mode 100644 index 0000000..b93d190 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemBySlug/GetProblemBySlugQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemBySlug; + +public sealed record GetProblemBySlugQuery(string Slug) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemReactionSummary/GetProblemReactionSummaryHandler.cs b/StackDuel.Application/Queries/Problems/GetProblemReactionSummary/GetProblemReactionSummaryHandler.cs new file mode 100644 index 0000000..81c1a40 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemReactionSummary/GetProblemReactionSummaryHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemReactionSummary; + +internal sealed class GetProblemReactionSummaryHandler( + IProblemReadRepository problemReadRepository, + IProblemReactionReadRepository problemReactionReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetProblemReactionSummaryQuery request, + CancellationToken cancellationToken + ) + { + bool problemExists = await problemReadRepository.ExistsAsync(request.ProblemId, cancellationToken); + if (!problemExists) + return Result.NotFound($"Problem '{request.ProblemId}' was not found."); + + var summary = await problemReactionReadRepository.GetSummaryAsync( + request.ProblemId, + request.UserId, + cancellationToken + ); + + return Result.Success(summary); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemReactionSummary/GetProblemReactionSummaryQuery.cs b/StackDuel.Application/Queries/Problems/GetProblemReactionSummary/GetProblemReactionSummaryQuery.cs new file mode 100644 index 0000000..0afa00b --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemReactionSummary/GetProblemReactionSummaryQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemReactionSummary; + +public sealed record GetProblemReactionSummaryQuery(Guid ProblemId, Guid? UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs b/StackDuel.Application/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs new file mode 100644 index 0000000..6e9f66e --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemSetup/GetProblemSetupHandler.cs @@ -0,0 +1,45 @@ +using Ardalis.Result; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemSetup; + +internal sealed class GetProblemSetupHandler(IProblemReadRepository problemReadRepository) + : IQueryHandler +{ + public async Task> Handle(GetProblemSetupQuery request, CancellationToken cancellationToken) + { + var problem = await problemReadRepository.FindBySlugAsync(request.Slug, cancellationToken); + + if (problem is null) + { + return Result.NotFound(); + } + + var foundSetup = problem.FindSetupByLanguageVersionId(request.LanguageVersionId); + + if (foundSetup is null) + { + return Result.NotFound(); + } + + return Result.Success( + new ProblemSetupDto( + foundSetup.Id, + foundSetup.InitialCode, + foundSetup.FunctionName, + foundSetup + .PublicTestSuites() + .SelectMany( + testSuite => testSuite.TestCases, + (testSuite, testCase) => + new ProblemSetupTestCaseDto( + string.Join(", ", testCase.Inputs.Select(i => i.Value)), + string.Join(", ", testCase.ExpectedOutputs.Select(o => o.Value)) + ) + ), + foundSetup.AdditionalFiles.Select(f => new ProblemSetupFileDto(f.Path, f.Content)) + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs b/StackDuel.Application/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs new file mode 100644 index 0000000..3475e3a --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemSetup/GetProblemSetupQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemSetup; + +public sealed record GetProblemSetupQuery(string Slug, Guid LanguageVersionId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs b/StackDuel.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs new file mode 100644 index 0000000..31d0586 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableHandler.cs @@ -0,0 +1,75 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.ValueObjects; +using DifficultyTier = StackDuel.Domain.Problems.Enums.DifficultyTier; + +namespace StackDuel.Application.Queries.Problems.GetProblemsPageable; + +internal sealed class GetProblemsPageableHandler( + IProblemReadRepository problemReadRepository, + ILanguageReadRepository languageReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetProblemsPageableQuery request, + CancellationToken cancellationToken + ) + { + var page = await problemReadRepository.GetPagedAsync( + request.PaginationRequest, + request.Search, + cancellationToken + ); + + Guid[] versionIds = [.. page.Results.SelectMany(r => r.LanguageVersionIds).Distinct()]; + + var languages = await languageReadRepository.FindLanguagesByVersionId(versionIds, cancellationToken); + + Dictionary versionLanguages = languages + .SelectMany(language => + language.Versions.Select(version => + ( + version.Id, + Language: new ProblemLanguageDto(language.Id, language.Name.Value, language.Slug.Value) + ) + ) + ) + .ToDictionary(x => x.Id, x => x.Language); + + var results = page + .Results.Select(row => new ProblemDto( + row.Id, + row.Slug, + row.Title, + ToDifficultyTier(row.DifficultyValue), + row.Tags, + [ + .. row + .LanguageVersionIds.Select(id => versionLanguages.GetValueOrDefault(id)) + .OfType() + .DistinctBy(language => language.Id), + ] + )) + .ToList(); + + return Result.Success( + new PageResult + { + Results = results, + Total = page.Total, + Page = page.Page, + Size = page.Size, + } + ); + } + + private static DifficultyTier ToDifficultyTier(int difficultyValue) => + difficultyValue <= Difficulty.BeginnerMax ? DifficultyTier.Beginner + : difficultyValue <= Difficulty.EasyMax ? DifficultyTier.Easy + : difficultyValue <= Difficulty.IntermediateMax ? DifficultyTier.Intermediate + : difficultyValue <= Difficulty.AdvancedMax ? DifficultyTier.Advanced + : DifficultyTier.Expert; +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs b/StackDuel.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs new file mode 100644 index 0000000..8568b8f --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetProblemsPageable/GetProblemsPageableQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetProblemsPageable; + +public sealed record GetProblemsPageableQuery(PaginationRequest PaginationRequest, string? Search) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetRequiredProblemLanguages/GetRequiredProblemLanguagesHandler.cs b/StackDuel.Application/Queries/Problems/GetRequiredProblemLanguages/GetRequiredProblemLanguagesHandler.cs new file mode 100644 index 0000000..6e15ea7 --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetRequiredProblemLanguages/GetRequiredProblemLanguagesHandler.cs @@ -0,0 +1,18 @@ +using Ardalis.Result; +using StackDuel.Application.Problems.RequiredLanguages; +using StackDuel.Application.Problems.RequiredLanguages.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetRequiredProblemLanguages; + +internal sealed class GetRequiredProblemLanguagesHandler(IRequiredProblemLanguageReadRepository readRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetRequiredProblemLanguagesQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList languages = await readRepository.GetAllAsync(cancellationToken); + return Result.Success(languages); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Problems/GetRequiredProblemLanguages/GetRequiredProblemLanguagesQuery.cs b/StackDuel.Application/Queries/Problems/GetRequiredProblemLanguages/GetRequiredProblemLanguagesQuery.cs new file mode 100644 index 0000000..6efbd1b --- /dev/null +++ b/StackDuel.Application/Queries/Problems/GetRequiredProblemLanguages/GetRequiredProblemLanguagesQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Problems.RequiredLanguages.Dtos; + +namespace StackDuel.Application.Queries.Problems.GetRequiredProblemLanguages; + +public sealed record GetRequiredProblemLanguagesQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailHandler.cs b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailHandler.cs new file mode 100644 index 0000000..2b8b90f --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailHandler.cs @@ -0,0 +1,150 @@ +using Ardalis.Result; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.SubmissionJobs.Entities; +using StackDuel.Domain.SubmissionJobs.Enums; +using StackDuel.Domain.Submissions; + +namespace StackDuel.Application.Queries.Submissions.GetAdminSubmissionDetail; + +internal sealed class GetAdminSubmissionDetailHandler( + ISubmissionWriteRepository submissionRepository, + ISubmissionReadRepository submissionReadRepository, + ISubmissionJobRepository submissionJobRepository, + IExecutionPipelineRepository pipelineRepository +) : IQueryHandler +{ + public async Task> Handle( + GetAdminSubmissionDetailQuery request, + CancellationToken cancellationToken + ) + { + var submission = await submissionRepository.FindByIdAsync(request.SubmissionId, cancellationToken); + if (submission is null) + return Result.NotFound(); + + var context = await submissionReadRepository.FindAdminSubmissionByIdAsync( + request.SubmissionId, + cancellationToken + ); + if (context is null) + return Result.NotFound(); + + var results = submission + .Results.Select(r => new AdminSubmissionResultDto( + r.TestCaseId, + r.Status, + r.Runtime, + r.MemoryUsed, + r.ActualOutput, + r.StandardOutput, + r.StandardError, + r.CompileOutput + )) + .ToArray(); + + AdminSubmissionJobDto? jobDto = null; + var job = await submissionJobRepository.FindBySubmissionIdAsync(request.SubmissionId, cancellationToken); + if (job is not null) + { + var pipeline = await pipelineRepository.FindByIdWithStepsAsync(job.PipelineId, cancellationToken); + var orderedSteps = pipeline?.Steps.OrderBy(s => s.StepOrder).ToArray() ?? []; + + var stepDtos = orderedSteps + .Select(step => + { + var attempts = job + .Attempts.Where(a => a.PipelineStepId == step.Id) + .OrderBy(a => a.AttemptNumber) + .Select(a => new AdminSubmissionJobAttemptDto( + a.AttemptNumber, + a.Status, + a.RequestPayload, + a.ResponsePayload, + a.Error, + a.StartedAt, + a.CompletedAt, + a.DurationMs + )) + .ToArray(); + + bool isCurrent = job.CurrentStepId == step.Id; + var status = DeriveStepStatus(job, attempts, isCurrent); + int? totalDuration = attempts.Length > 0 ? attempts.Sum(a => a.DurationMs ?? 0) : null; + + return new AdminSubmissionJobStepDto( + step.Id, + step.Name, + step.StepType, + step.StepOrder, + step.MaxAttempts, + step.TimeoutSeconds, + step.IsPolling, + isCurrent, + status, + attempts.Length, + totalDuration, + attempts + ); + }) + .ToArray(); + + jobDto = new AdminSubmissionJobDto( + job.Id, + job.Status, + job.FailureReason, + job.CreatedAt, + job.CompletedAt, + job.CurrentStepId, + stepDtos + ); + } + + return Result.Success( + new AdminSubmissionDetailDto( + submission.Id, + submission.Type, + submission.Status, + submission.SourceCode.Value, + submission.CreatedAt, + context.ProblemSetupId, + context.ProblemId, + context.ProblemTitle, + context.ProblemSlug, + context.Language, + context.User, + submission.Results.Max(r => r.MemoryUsed), + submission.Results.Max(r => r.Runtime), + results, + jobDto + ) + ); + } + + // SubmissionJob.Fail() leaves CurrentStepId pointing at the step that failed; + // Complete() clears CurrentStepId to null. So a non-current step here has + // necessarily already been passed (succeeded) or the job failed on it earlier + // and CurrentStepId still points at it (making it "current" from this method's + // perspective, not a past step) — the branches below rely on both behaviors. + private static AdminSubmissionJobStepStatus DeriveStepStatus( + SubmissionJob job, + IReadOnlyList attempts, + bool isCurrent + ) + { + if (attempts.Count == 0) + return isCurrent ? AdminSubmissionJobStepStatus.Running : AdminSubmissionJobStepStatus.Pending; + + if (isCurrent) + return job.Status == SubmissionJobStatus.Failed + ? AdminSubmissionJobStepStatus.Failed + : AdminSubmissionJobStepStatus.Running; + + var lastAttempt = attempts[^1]; + return lastAttempt.Status == SubmissionJobAttemptStatus.Succeeded + ? AdminSubmissionJobStepStatus.Succeeded + : AdminSubmissionJobStepStatus.Failed; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailQuery.cs b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailQuery.cs new file mode 100644 index 0000000..c2f7a98 --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionDetail/GetAdminSubmissionDetailQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Queries.Submissions.GetAdminSubmissionDetail; + +public sealed record GetAdminSubmissionDetailQuery(Guid SubmissionId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableHandler.cs b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableHandler.cs new file mode 100644 index 0000000..517659f --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableHandler.cs @@ -0,0 +1,24 @@ +using Ardalis.Result; +using StackDuel.Application.Pagination; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Queries.Submissions.GetAdminSubmissionsPageable; + +internal sealed class GetAdminSubmissionsPageableHandler(ISubmissionReadRepository submissionReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetAdminSubmissionsPageableQuery request, + CancellationToken cancellationToken + ) + { + var result = await submissionReadRepository.GetAdminSubmissionsPagedAsync( + request.PaginationRequest, + request.Id, + cancellationToken + ); + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableQuery.cs b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableQuery.cs new file mode 100644 index 0000000..1a255c5 --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetAdminSubmissionsPageable/GetAdminSubmissionsPageableQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Queries.Submissions.GetAdminSubmissionsPageable; + +public sealed record GetAdminSubmissionsPageableQuery(PaginationRequest PaginationRequest, Guid? Id) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandler.cs b/StackDuel.Application/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandler.cs new file mode 100644 index 0000000..4e5cda6 --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusHandler.cs @@ -0,0 +1,69 @@ +using Ardalis.Result; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.TestSuites; + +namespace StackDuel.Application.Queries.Submissions.GetSubmissionStatus; + +internal sealed class GetSubmissionStatusHandler( + ISubmissionWriteRepository submissionRepository, + ITestSuiteWriteRepository testSuiteRepository, + ISubmissionJobRepository submissionJobRepository, + IExecutionPipelineRepository pipelineRepository +) : IQueryHandler +{ + public async Task> Handle( + GetSubmissionStatusQuery request, + CancellationToken cancellationToken + ) + { + var submission = await submissionRepository.FindByIdAsync(request.SubmissionId, cancellationToken); + + if (submission is null || submission.UserId != request.UserId) + return Result.NotFound(); + + var testCaseIds = submission.Results.Select(result => result.TestCaseId).ToArray(); + + var expectedOutputMap = await testSuiteRepository.FindExpectedOutputsByTestCaseIdsAsync( + testCaseIds, + cancellationToken + ); + + var inputMap = await testSuiteRepository.FindInputsByTestCaseIdsAsync(testCaseIds, cancellationToken); + + var results = submission + .Results.Select(result => new SubmissionResultStatusDto( + result.Status, + result.Runtime, + result.MemoryUsed, + inputMap.GetValueOrDefault(result.TestCaseId), + result.ActualOutput, + expectedOutputMap.GetValueOrDefault(result.TestCaseId), + result.StandardOutput, + result.StandardError, + result.CompileOutput + )) + .ToArray(); + + string? currentStepName = null; + var job = await submissionJobRepository.FindBySubmissionIdAsync(request.SubmissionId, cancellationToken); + if (job?.CurrentStepId is not null) + { + var pipeline = await pipelineRepository.FindByIdWithStepsAsync(job.PipelineId, cancellationToken); + currentStepName = pipeline?.Steps.FirstOrDefault(s => s.Id == job.CurrentStepId)?.Name; + } + + return Result.Success( + new SubmissionStatusDto( + submission.Id, + submission.ProblemSetupId, + submission.Status, + currentStepName, + submission.SourceCode.Value, + results + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusQuery.cs b/StackDuel.Application/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusQuery.cs new file mode 100644 index 0000000..4a33ede --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetSubmissionStatus/GetSubmissionStatusQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Queries.Submissions.GetSubmissionStatus; + +public sealed record GetSubmissionStatusQuery(Guid SubmissionId, Guid UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugHandler.cs b/StackDuel.Application/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugHandler.cs new file mode 100644 index 0000000..2f48817 --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugHandler.cs @@ -0,0 +1,37 @@ +using Ardalis.Result; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Queries.Submissions.GetSubmissionsByProblemSlug; + +internal sealed class GetSubmissionsByProblemSlugHandler( + IProblemReadRepository problemReadRepository, + ISubmissionReadRepository submissionReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetSubmissionsByProblemSlugQuery request, + CancellationToken cancellationToken + ) + { + var problemId = await problemReadRepository.GetIdBySlugAsync(request.ProblemSlug, cancellationToken); + + if (problemId is null) + { + return Result.NotFound(); + } + + var pageResult = await submissionReadRepository.GetProblemSubmissionsPagedAsync( + problemId.Value, + request.PaginationRequest, + request.UserId, + request.Type, + request.SortOrder, + cancellationToken + ); + + return Result.Success(pageResult); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugQuery.cs b/StackDuel.Application/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugQuery.cs new file mode 100644 index 0000000..ff6367e --- /dev/null +++ b/StackDuel.Application/Queries/Submissions/GetSubmissionsByProblemSlug/GetSubmissionsByProblemSlugQuery.cs @@ -0,0 +1,13 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Queries.Submissions.GetSubmissionsByProblemSlug; + +internal sealed record GetSubmissionsByProblemSlugQuery( + string ProblemSlug, + PaginationRequest PaginationRequest, + Guid? UserId, + SubmissionFilter Type, + SubmissionSortOrder SortOrder +) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Tracks/GetTracks/GetTracksHandler.cs b/StackDuel.Application/Queries/Tracks/GetTracks/GetTracksHandler.cs new file mode 100644 index 0000000..69ededc --- /dev/null +++ b/StackDuel.Application/Queries/Tracks/GetTracks/GetTracksHandler.cs @@ -0,0 +1,42 @@ +using Ardalis.Result; +using StackDuel.Application.Languages; +using StackDuel.Application.Tracks; +using StackDuel.Application.Tracks.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Queries.Tracks.GetTracks; + +internal sealed class GetTracksHandler( + ITrackReadRepository trackReadRepository, + ILanguageReadRepository languageReadRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetTracksQuery request, + CancellationToken cancellationToken + ) + { + IReadOnlyList tracks = await trackReadRepository.GetActiveTracksAsync(cancellationToken); + + IReadOnlyList languages = await languageReadRepository.GetActiveLanguagesByTrackIdsAsync( + tracks.Select(t => t.Id), + cancellationToken + ); + + ILookup languagesByTrackId = languages.ToLookup(l => l.TrackId); + + IReadOnlyList dtos = + [ + .. tracks.Select(t => new TrackDto( + t.Id, + t.Key, + t.Name, + t.AllowsLanguageSelection, + [.. languagesByTrackId[t.Id].Select(l => new TrackLanguageDto(l.Id, l.Name.Value))] + )), + ]; + + return Result>.Success(dtos); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Tracks/GetTracks/GetTracksQuery.cs b/StackDuel.Application/Queries/Tracks/GetTracks/GetTracksQuery.cs new file mode 100644 index 0000000..4376f4e --- /dev/null +++ b/StackDuel.Application/Queries/Tracks/GetTracks/GetTracksQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Queries; +using StackDuel.Application.Tracks.Dtos; + +namespace StackDuel.Application.Queries.Tracks.GetTracks; + +public sealed record GetTracksQuery : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GameModeStatsBuilder.cs b/StackDuel.Application/Queries/Users/GameModeStatsBuilder.cs new file mode 100644 index 0000000..43c1621 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GameModeStatsBuilder.cs @@ -0,0 +1,94 @@ +using StackDuel.Application.Games; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Users; + +internal sealed record GameModeStatsResult( + IReadOnlyDictionary GameModesById, + IReadOnlyList GamesWithKnownMode, + IReadOnlyList Stats +); + +internal static class GameModeStatsBuilder +{ + public static async Task BuildAsync( + IGameReadRepository gameReadRepository, + Guid userId, + CancellationToken cancellationToken + ) + { + IReadOnlyList completedGames = await gameReadRepository.GetCompletedGamesForUserAsync( + userId, + cancellationToken + ); + IReadOnlyList activeGameModes = await gameReadRepository.GetActiveGameModesAsync(cancellationToken); + + // Seed with every currently-active mode (so an unplayed mode still shows a zeroed-out + // row), then fill in any mode from the user's history that's since been deactivated — + // they should still see stats for a mode they actually played. + Dictionary gameModesById = activeGameModes.ToDictionary(m => m.Id); + foreach (Guid gameModeId in completedGames.Select(g => g.GameModeId).Distinct()) + { + if (gameModesById.ContainsKey(gameModeId)) + continue; + + GameMode? gameMode = await gameReadRepository.FindGameModeByIdIncludingInactiveAsync( + gameModeId, + cancellationToken + ); + if (gameMode is not null) + gameModesById[gameModeId] = gameMode; + } + + var gamesWithKnownMode = completedGames.Where(g => gameModesById.ContainsKey(g.GameModeId)).ToList(); + var gamesByModeId = gamesWithKnownMode.GroupBy(g => g.GameModeId).ToDictionary(g => g.Key, g => g.ToList()); + + var stats = gameModesById + .Values.Select(mode => BuildGameModeStat(mode, gamesByModeId.GetValueOrDefault(mode.Id), userId)) + .OrderByDescending(s => s.GamesPlayed) + .ThenBy(s => s.GameModeName) + .ToList(); + + return new GameModeStatsResult(gameModesById, gamesWithKnownMode, stats); + } + + private static GameModeStatDto BuildGameModeStat(GameMode mode, List? games, Guid userId) + { + // A mode capped at one player (Solo Rush) has no opponent to win or lose against — + // it's just a high-score chase, so only track the best score for it. + bool hasOpponents = mode.MaxPlayers > 1; + + int gamesPlayed = 0, + wins = 0, + losses = 0, + draws = 0, + bestScore = 0; + + if (games is not null) + { + foreach (Game game in games) + { + gamesPlayed++; + + int myScore = game.Participants.First(p => p.UserId == userId).Score; + bestScore = Math.Max(bestScore, myScore); + + if (!hasOpponents) + continue; + + int maxScore = game.Participants.Max(p => p.Score); + int topScorerCount = game.Participants.Count(p => p.Score == maxScore); + + if (myScore != maxScore) + losses++; + else if (topScorerCount > 1) + draws++; + else + wins++; + } + } + + return new GameModeStatDto(mode.Key, mode.Name, hasOpponents, gamesPlayed, wins, losses, draws, bestScore); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetAdminUserDetail/GetAdminUserDetailHandler.cs b/StackDuel.Application/Queries/Users/GetAdminUserDetail/GetAdminUserDetailHandler.cs new file mode 100644 index 0000000..e1be47f --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetAdminUserDetail/GetAdminUserDetailHandler.cs @@ -0,0 +1,22 @@ +using Ardalis.Result; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos.Admin; + +namespace StackDuel.Application.Queries.Users.GetAdminUserDetail; + +internal sealed class GetAdminUserDetailHandler(IUserReadRepository userReadRepository) + : IQueryHandler +{ + public async Task> Handle( + GetAdminUserDetailQuery request, + CancellationToken cancellationToken + ) + { + var user = await userReadRepository.FindAdminUserDetailByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.NotFound(); + + return Result.Success(user); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetAdminUserDetail/GetAdminUserDetailQuery.cs b/StackDuel.Application/Queries/Users/GetAdminUserDetail/GetAdminUserDetailQuery.cs new file mode 100644 index 0000000..7a492f4 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetAdminUserDetail/GetAdminUserDetailQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Users.Dtos.Admin; + +namespace StackDuel.Application.Queries.Users.GetAdminUserDetail; + +public sealed record GetAdminUserDetailQuery(Guid UserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableHandler.cs b/StackDuel.Application/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableHandler.cs new file mode 100644 index 0000000..daec3e9 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableHandler.cs @@ -0,0 +1,24 @@ +using Ardalis.Result; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos.Admin; + +namespace StackDuel.Application.Queries.Users.GetAdminUsersPageable; + +internal sealed class GetAdminUsersPageableHandler(IUserReadRepository userReadRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetAdminUsersPageableQuery request, + CancellationToken cancellationToken + ) + { + var result = await userReadRepository.GetAdminUsersPageableAsync( + request.PaginationRequest, + request.Search, + cancellationToken + ); + + return Result.Success(result); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableQuery.cs b/StackDuel.Application/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableQuery.cs new file mode 100644 index 0000000..f3efe1a --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetAdminUsersPageable/GetAdminUsersPageableQuery.cs @@ -0,0 +1,7 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Users.Dtos.Admin; + +namespace StackDuel.Application.Queries.Users.GetAdminUsersPageable; + +public sealed record GetAdminUsersPageableQuery(PaginationRequest PaginationRequest, string? Search) + : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserAvatarHistory/GetUserAvatarHistoryHandler.cs b/StackDuel.Application/Queries/Users/GetUserAvatarHistory/GetUserAvatarHistoryHandler.cs new file mode 100644 index 0000000..dc5c303 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserAvatarHistory/GetUserAvatarHistoryHandler.cs @@ -0,0 +1,41 @@ +using Ardalis.Result; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Application.Queries.Users.GetUserAvatarHistory; + +internal sealed class GetUserAvatarHistoryHandler( + IUserReadRepository userReadRepository, + IUserAvatarWriteRepository userAvatarRepository +) : IQueryHandler> +{ + public async Task>> Handle( + GetUserAvatarHistoryQuery request, + CancellationToken cancellationToken + ) + { + UserDto? user = await userReadRepository.FindByIdAsync(request.UserId, cancellationToken); + + if (user is null) + return Result.NotFound(); + + IReadOnlyList avatars = await userAvatarRepository.GetAllByUserIdNewestFirstAsync( + request.UserId, + cancellationToken + ); + + IReadOnlyList dtos = + [ + .. avatars.Select(a => new UserAvatarDto( + a.Id, + a.ImageUrl.Value, + a.CreatedAt, + a.ImageUrl.Value == user.ImageUrl + )), + ]; + + return Result.Success(dtos); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserAvatarHistory/GetUserAvatarHistoryQuery.cs b/StackDuel.Application/Queries/Users/GetUserAvatarHistory/GetUserAvatarHistoryQuery.cs new file mode 100644 index 0000000..23c8e63 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserAvatarHistory/GetUserAvatarHistoryQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Queries; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Users.GetUserAvatarHistory; + +public sealed record GetUserAvatarHistoryQuery(Guid UserId) : IQuery>; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserBySub/GetUserBySubHandler.cs b/StackDuel.Application/Queries/Users/GetUserBySub/GetUserBySubHandler.cs new file mode 100644 index 0000000..f4b66ac --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserBySub/GetUserBySubHandler.cs @@ -0,0 +1,19 @@ +using Ardalis.Result; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Users.GetUserBySub; + +internal sealed class GetUserBySubHandler(IUserReadRepository userReadRepository) + : IQueryHandler +{ + public async Task> Handle(GetUserBySubQuery request, CancellationToken cancellationToken) + { + var user = await userReadRepository.FindBySubAsync(request.Sub, cancellationToken); + + if (user is null) + return Result.NotFound(); + + return Result.Success(user); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserBySub/GetUserBySubQuery.cs b/StackDuel.Application/Queries/Users/GetUserBySub/GetUserBySubQuery.cs new file mode 100644 index 0000000..b87be85 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserBySub/GetUserBySubQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Queries; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Users.GetUserBySub; + +public sealed record GetUserBySubQuery(string Sub) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserGameStats/GetUserGameStatsHandler.cs b/StackDuel.Application/Queries/Users/GetUserGameStats/GetUserGameStatsHandler.cs new file mode 100644 index 0000000..fc13912 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserGameStats/GetUserGameStatsHandler.cs @@ -0,0 +1,37 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Users.GetUserGameStats; + +internal sealed class GetUserGameStatsHandler( + IUserReadRepository userReadRepository, + IGameReadRepository gameReadRepository +) : IQueryHandler +{ + public async Task> Handle( + GetUserGameStatsQuery request, + CancellationToken cancellationToken + ) + { + var profile = await userReadRepository.FindProfileByUsernameAsync(request.Username, cancellationToken); + + if (profile is null) + return Result.NotFound(); + + bool isOwnProfile = request.RequestingUserId is not null && request.RequestingUserId == profile.Id; + bool showPrivateSections = isOwnProfile || !profile.IsPrivate; + + if (!showPrivateSections) + return Result.Success(new UserGameStatsDto(profile.Id, profile.Username, isOwnProfile, null)); + + GameModeStatsResult statsResult = await GameModeStatsBuilder.BuildAsync( + gameReadRepository, + profile.Id, + cancellationToken + ); + + return Result.Success(new UserGameStatsDto(profile.Id, profile.Username, isOwnProfile, statsResult.Stats)); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserGameStats/GetUserGameStatsQuery.cs b/StackDuel.Application/Queries/Users/GetUserGameStats/GetUserGameStatsQuery.cs new file mode 100644 index 0000000..55a66b8 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserGameStats/GetUserGameStatsQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Queries; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Users.GetUserGameStats; + +public sealed record GetUserGameStatsQuery(string Username, Guid? RequestingUserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameHandler.cs b/StackDuel.Application/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameHandler.cs new file mode 100644 index 0000000..ef1aeb3 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameHandler.cs @@ -0,0 +1,120 @@ +using Ardalis.Result; +using StackDuel.Application.Games; +using StackDuel.Application.Submissions; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Application.Queries.Users.GetUserProfileByUsername; + +internal sealed class GetUserProfileByUsernameHandler( + IUserReadRepository userReadRepository, + IGameReadRepository gameReadRepository, + ISubmissionReadRepository submissionReadRepository +) : IQueryHandler +{ + private const int RecentGamesLimit = 10; + private const int RecentSubmissionsLimit = 10; + private const int SubmissionCalendarMonths = 12; + + public async Task> Handle( + GetUserProfileByUsernameQuery request, + CancellationToken cancellationToken + ) + { + var profile = await userReadRepository.FindProfileByUsernameAsync(request.Username, cancellationToken); + + if (profile is null) + return Result.NotFound(); + + bool isOwnProfile = request.RequestingUserId is not null && request.RequestingUserId == profile.Id; + bool showPrivateSections = isOwnProfile || !profile.IsPrivate; + + if (!showPrivateSections) + return Result.Success(profile with { IsOwnProfile = isOwnProfile }); + + var (gameModeStats, recentGames) = await BuildGameSectionsAsync(profile.Id, cancellationToken); + var recentSubmissions = await submissionReadRepository.GetRecentSubmissionsForUserAsync( + profile.Id, + RecentSubmissionsLimit, + cancellationToken + ); + + DateOnly rangeEnd = DateOnly.FromDateTime(DateTime.UtcNow); + DateOnly rangeStart = rangeEnd.AddMonths(-SubmissionCalendarMonths); + var submissionCalendar = await BuildSubmissionCalendarAsync(profile.Id, rangeStart, cancellationToken); + + return Result.Success( + profile with + { + IsOwnProfile = isOwnProfile, + GameModeStats = gameModeStats, + RecentGames = recentGames, + RecentSubmissions = recentSubmissions, + SubmissionCalendar = submissionCalendar, + SubmissionCalendarRangeStart = rangeStart, + SubmissionCalendarRangeEnd = rangeEnd, + } + ); + } + + // Days with zero submissions are implied by their absence, not sent over the wire — the + // caller already knows the full window via SubmissionCalendarRangeStart/End and fills gaps + // itself when it needs a contiguous grid. + private async Task> BuildSubmissionCalendarAsync( + Guid userId, + DateOnly rangeStart, + CancellationToken cancellationToken + ) + { + var counts = await submissionReadRepository.GetSubmissionCountsByDayForUserAsync( + userId, + rangeStart, + cancellationToken + ); + + return [.. counts.Where(c => c.Count > 0).OrderBy(c => c.Date)]; + } + + private async Task<( + IReadOnlyList Stats, + IReadOnlyList RecentGames + )> BuildGameSectionsAsync(Guid userId, CancellationToken cancellationToken) + { + GameModeStatsResult statsResult = await GameModeStatsBuilder.BuildAsync( + gameReadRepository, + userId, + cancellationToken + ); + + List recentGameEntities = [.. statsResult.GamesWithKnownMode.Take(RecentGamesLimit)]; + Guid[] participantUserIds = + [ + .. recentGameEntities.SelectMany(g => g.Participants).Select(p => p.UserId).Distinct(), + ]; + var usersById = await userReadRepository.FindByIdsAsync(participantUserIds, cancellationToken); + + List recentGames = + [ + .. recentGameEntities.Select(g => + { + GameMode mode = statsResult.GameModesById[g.GameModeId]; + + List participants = + [ + .. g + .Participants.OrderByDescending(p => p.Score) + .Select(p => + { + usersById.TryGetValue(p.UserId, out var user); + return new ProfileGameParticipantDto(user?.Username ?? "Unknown", user?.ImageUrl, p.Score); + }), + ]; + + return new ProfileGameDto(g.Id, mode.Key, mode.Name, g.EndedAt, participants); + }), + ]; + + return (statsResult.Stats, recentGames); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameQuery.cs b/StackDuel.Application/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameQuery.cs new file mode 100644 index 0000000..c8dc817 --- /dev/null +++ b/StackDuel.Application/Queries/Users/GetUserProfileByUsername/GetUserProfileByUsernameQuery.cs @@ -0,0 +1,6 @@ +using StackDuel.Application.Queries; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Queries.Users.GetUserProfileByUsername; + +public sealed record GetUserProfileByUsernameQuery(string Username, Guid? RequestingUserId) : IQuery; \ No newline at end of file diff --git a/StackDuel.Application/Services/Audit/AuditLogService.cs b/StackDuel.Application/Services/Audit/AuditLogService.cs new file mode 100644 index 0000000..06b2793 --- /dev/null +++ b/StackDuel.Application/Services/Audit/AuditLogService.cs @@ -0,0 +1,23 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Audit.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Audit.GetAuditLogPageable; + +namespace StackDuel.Application.Services.Audit; + +public interface IAuditLogService +{ + Task>> GetPageableAsync( + PaginationRequest pagination, + CancellationToken cancellationToken + ); +} + +internal sealed class AuditLogService(IMediator mediator) : IAuditLogService +{ + public async Task>> GetPageableAsync( + PaginationRequest pagination, + CancellationToken cancellationToken + ) => await mediator.Send(new GetAuditLogPageableQuery(pagination), cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Campaigns/CampaignService.cs b/StackDuel.Application/Services/Campaigns/CampaignService.cs new file mode 100644 index 0000000..20ed68a --- /dev/null +++ b/StackDuel.Application/Services/Campaigns/CampaignService.cs @@ -0,0 +1,262 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Campaigns.Dtos; +using StackDuel.Application.Commands.Campaigns.AddCampaignModule; +using StackDuel.Application.Commands.Campaigns.AddCampaignUnit; +using StackDuel.Application.Commands.Campaigns.ArchiveCampaign; +using StackDuel.Application.Commands.Campaigns.CompleteUnit; +using StackDuel.Application.Commands.Campaigns.CreateCampaign; +using StackDuel.Application.Commands.Campaigns.EnrollInCampaign; +using StackDuel.Application.Commands.Campaigns.PublishCampaign; +using StackDuel.Application.Commands.Campaigns.SetCampaignPrerequisites; +using StackDuel.Application.Commands.Campaigns.SetUnitProblems; +using StackDuel.Application.Commands.Campaigns.UpdateCampaignDetails; +using StackDuel.Application.Commands.Campaigns.UpdateCampaignModule; +using StackDuel.Application.Commands.Campaigns.UpdateCampaignUnit; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Campaigns.GetAdminCampaignDetail; +using StackDuel.Application.Queries.Campaigns.GetAdminCampaignsPageable; +using StackDuel.Application.Queries.Campaigns.GetCampaignBySlug; +using StackDuel.Application.Queries.Campaigns.GetCampaignPath; +using StackDuel.Application.Queries.Campaigns.GetMyCampaignProgress; +using StackDuel.Application.Queries.Campaigns.GetMyEnrollments; +using StackDuel.Application.Queries.Campaigns.GetMyLearningStats; +using StackDuel.Domain.Campaigns.Enums; + +namespace StackDuel.Application.Services.Campaigns; + +public interface ICampaignService +{ + Task>> GetCampaignPathAsync(CancellationToken cancellationToken); + + Task> GetCampaignBySlugAsync(string slug, CancellationToken cancellationToken); + + Task>> GetAdminCampaignsPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ); + + Task> GetAdminCampaignDetailAsync(Guid campaignId, CancellationToken cancellationToken); + + Task> GetMyCampaignProgressAsync( + Guid campaignId, + Guid userId, + CancellationToken cancellationToken + ); + + Task>> GetMyEnrollmentsAsync( + Guid userId, + CancellationToken cancellationToken + ); + + Task> GetMyLearningStatsAsync(Guid userId, CancellationToken cancellationToken); + + Task> CreateCampaignAsync( + string title, + string description, + CampaignDifficulty difficulty, + CancellationToken cancellationToken + ); + + Task UpdateCampaignDetailsAsync( + Guid campaignId, + string title, + string description, + CampaignDifficulty difficulty, + string? iconKey, + CancellationToken cancellationToken + ); + + Task PublishCampaignAsync(Guid campaignId, CancellationToken cancellationToken); + + Task ArchiveCampaignAsync(Guid campaignId, CancellationToken cancellationToken); + + Task SetCampaignPrerequisitesAsync( + Guid campaignId, + IReadOnlyList requiredCampaignIds, + CancellationToken cancellationToken + ); + + Task> AddCampaignModuleAsync( + Guid campaignId, + string title, + string description, + CancellationToken cancellationToken + ); + + Task UpdateCampaignModuleAsync( + Guid campaignId, + Guid moduleId, + string title, + string description, + CancellationToken cancellationToken + ); + + Task> AddCampaignUnitAsync( + Guid campaignId, + Guid moduleId, + string title, + string content, + UnitType unitType, + int estimatedMinutes, + CancellationToken cancellationToken + ); + + Task UpdateCampaignUnitAsync( + Guid campaignId, + Guid moduleId, + Guid unitId, + string title, + string content, + UnitType unitType, + int estimatedMinutes, + CancellationToken cancellationToken + ); + + Task SetUnitProblemsAsync( + Guid campaignId, + Guid moduleId, + Guid unitId, + IReadOnlyList problemIds, + CancellationToken cancellationToken + ); + + Task> EnrollInCampaignAsync(Guid campaignId, Guid userId, CancellationToken cancellationToken); + + Task CompleteUnitAsync(Guid unitId, Guid userId, CancellationToken cancellationToken); +} + +internal sealed class CampaignService(IMediator mediator) : ICampaignService +{ + public async Task>> GetCampaignPathAsync( + CancellationToken cancellationToken + ) => await mediator.Send(new GetCampaignPathQuery(), cancellationToken); + + public async Task> GetCampaignBySlugAsync(string slug, CancellationToken cancellationToken) => + await mediator.Send(new GetCampaignBySlugQuery(slug), cancellationToken); + + public async Task>> GetAdminCampaignsPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ) => await mediator.Send(new GetAdminCampaignsPageableQuery(paginationRequest, search), cancellationToken); + + public async Task> GetAdminCampaignDetailAsync( + Guid campaignId, + CancellationToken cancellationToken + ) => await mediator.Send(new GetAdminCampaignDetailQuery(campaignId), cancellationToken); + + public async Task> GetMyCampaignProgressAsync( + Guid campaignId, + Guid userId, + CancellationToken cancellationToken + ) => await mediator.Send(new GetMyCampaignProgressQuery(campaignId, userId), cancellationToken); + + public async Task>> GetMyEnrollmentsAsync( + Guid userId, + CancellationToken cancellationToken + ) => await mediator.Send(new GetMyEnrollmentsQuery(userId), cancellationToken); + + public async Task> GetMyLearningStatsAsync( + Guid userId, + CancellationToken cancellationToken + ) => await mediator.Send(new GetMyLearningStatsQuery(userId), cancellationToken); + + public async Task> CreateCampaignAsync( + string title, + string description, + CampaignDifficulty difficulty, + CancellationToken cancellationToken + ) => await mediator.Send(new CreateCampaignCommand(title, description, difficulty), cancellationToken); + + public async Task UpdateCampaignDetailsAsync( + Guid campaignId, + string title, + string description, + CampaignDifficulty difficulty, + string? iconKey, + CancellationToken cancellationToken + ) => + await mediator.Send( + new UpdateCampaignDetailsCommand(campaignId, title, description, difficulty, iconKey), + cancellationToken + ); + + public async Task PublishCampaignAsync(Guid campaignId, CancellationToken cancellationToken) => + await mediator.Send(new PublishCampaignCommand(campaignId), cancellationToken); + + public async Task ArchiveCampaignAsync(Guid campaignId, CancellationToken cancellationToken) => + await mediator.Send(new ArchiveCampaignCommand(campaignId), cancellationToken); + + public async Task SetCampaignPrerequisitesAsync( + Guid campaignId, + IReadOnlyList requiredCampaignIds, + CancellationToken cancellationToken + ) => await mediator.Send(new SetCampaignPrerequisitesCommand(campaignId, requiredCampaignIds), cancellationToken); + + public async Task> AddCampaignModuleAsync( + Guid campaignId, + string title, + string description, + CancellationToken cancellationToken + ) => await mediator.Send(new AddCampaignModuleCommand(campaignId, title, description), cancellationToken); + + public async Task UpdateCampaignModuleAsync( + Guid campaignId, + Guid moduleId, + string title, + string description, + CancellationToken cancellationToken + ) => + await mediator.Send( + new UpdateCampaignModuleCommand(campaignId, moduleId, title, description), + cancellationToken + ); + + public async Task> AddCampaignUnitAsync( + Guid campaignId, + Guid moduleId, + string title, + string content, + UnitType unitType, + int estimatedMinutes, + CancellationToken cancellationToken + ) => + await mediator.Send( + new AddCampaignUnitCommand(campaignId, moduleId, title, content, unitType, estimatedMinutes), + cancellationToken + ); + + public async Task UpdateCampaignUnitAsync( + Guid campaignId, + Guid moduleId, + Guid unitId, + string title, + string content, + UnitType unitType, + int estimatedMinutes, + CancellationToken cancellationToken + ) => + await mediator.Send( + new UpdateCampaignUnitCommand(campaignId, moduleId, unitId, title, content, unitType, estimatedMinutes), + cancellationToken + ); + + public async Task SetUnitProblemsAsync( + Guid campaignId, + Guid moduleId, + Guid unitId, + IReadOnlyList problemIds, + CancellationToken cancellationToken + ) => await mediator.Send(new SetUnitProblemsCommand(campaignId, moduleId, unitId, problemIds), cancellationToken); + + public async Task> EnrollInCampaignAsync( + Guid campaignId, + Guid userId, + CancellationToken cancellationToken + ) => await mediator.Send(new EnrollInCampaignCommand(campaignId, userId), cancellationToken); + + public async Task CompleteUnitAsync(Guid unitId, Guid userId, CancellationToken cancellationToken) => + await mediator.Send(new CompleteUnitCommand(unitId, userId), cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/Services/DailyChallenges/DailyChallengeService.cs b/StackDuel.Application/Services/DailyChallenges/DailyChallengeService.cs new file mode 100644 index 0000000..3ca3f2a --- /dev/null +++ b/StackDuel.Application/Services/DailyChallenges/DailyChallengeService.cs @@ -0,0 +1,42 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.DailyChallenges.UpdateDailyChallenge; +using StackDuel.Application.DailyChallenges.Dtos; +using StackDuel.Application.Queries.DailyChallenges.GetTodaysDailyChallenge; +using StackDuel.Application.Queries.DailyChallenges.GetUpcomingDailyChallenges; + +namespace StackDuel.Application.Services.DailyChallenges; + +public interface IDailyChallengeService +{ + Task> GetTodaysChallengeAsync(Guid userId, CancellationToken cancellationToken); + + Task>> GetUpcomingChallengesAsync( + CancellationToken cancellationToken + ); + + Task UpdateChallengeAsync(DateOnly date, Guid problemId, CancellationToken cancellationToken); +} + +internal sealed class DailyChallengeService(IMediator mediator) : IDailyChallengeService +{ + public async Task> GetTodaysChallengeAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetTodaysDailyChallengeQuery(userId), cancellationToken); + } + + public async Task>> GetUpcomingChallengesAsync( + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetUpcomingDailyChallengesQuery(), cancellationToken); + } + + public async Task UpdateChallengeAsync(DateOnly date, Guid problemId, CancellationToken cancellationToken) + { + return await mediator.Send(new UpdateDailyChallengeCommand(date, problemId), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Dashboard/DashboardService.cs b/StackDuel.Application/Services/Dashboard/DashboardService.cs new file mode 100644 index 0000000..e1b7c5a --- /dev/null +++ b/StackDuel.Application/Services/Dashboard/DashboardService.cs @@ -0,0 +1,25 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Dashboard.Dtos; +using StackDuel.Application.Queries.Dashboard.GetAdminDashboardStats; + +namespace StackDuel.Application.Services.Dashboard; + +public interface IDashboardService +{ + Task> GetAdminDashboardStatsAsync( + int newUsersDays, + CancellationToken cancellationToken + ); +} + +internal sealed class DashboardService(IMediator mediator) : IDashboardService +{ + public async Task> GetAdminDashboardStatsAsync( + int newUsersDays, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetAdminDashboardStatsQuery(newUsersDays), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/FeatureFlags/FeatureFlagAdminService.cs b/StackDuel.Application/Services/FeatureFlags/FeatureFlagAdminService.cs new file mode 100644 index 0000000..d6f5027 --- /dev/null +++ b/StackDuel.Application/Services/FeatureFlags/FeatureFlagAdminService.cs @@ -0,0 +1,104 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.FeatureFlags.CreateFeatureFlag; +using StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagGroupOverride; +using StackDuel.Application.Commands.FeatureFlags.RemoveFeatureFlagUserOverride; +using StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagGroupOverride; +using StackDuel.Application.Commands.FeatureFlags.SetFeatureFlagUserOverride; +using StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagDefault; +using StackDuel.Application.Commands.FeatureFlags.UpdateFeatureFlagRollout; +using StackDuel.Application.FeatureFlags.Dtos; +using StackDuel.Application.Queries.FeatureFlags.GetFeatureFlagByKey; +using StackDuel.Application.Queries.FeatureFlags.GetFeatureFlags; +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Application.Services.FeatureFlags; + +public interface IFeatureFlagAdminService +{ + Task>> GetAllAsync(CancellationToken cancellationToken); + + Task> GetByKeyAsync(string key, CancellationToken cancellationToken); + + Task> CreateAsync( + string key, + string name, + string description, + bool defaultEnabled, + CancellationToken cancellationToken + ); + + Task UpdateDefaultAsync(Guid flagId, bool defaultEnabled, CancellationToken cancellationToken); + + Task UpdateRolloutAsync(Guid flagId, int rolloutPercentage, CancellationToken cancellationToken); + + Task SetUserOverrideAsync( + Guid flagId, + Guid userId, + DecisionEffect effect, + CancellationToken cancellationToken + ); + + Task RemoveUserOverrideAsync(Guid flagId, Guid userId, CancellationToken cancellationToken); + + Task SetGroupOverrideAsync( + Guid flagId, + Guid groupId, + DecisionEffect effect, + CancellationToken cancellationToken + ); + + Task RemoveGroupOverrideAsync(Guid flagId, Guid groupId, CancellationToken cancellationToken); +} + +internal sealed class FeatureFlagAdminService(IMediator mediator) : IFeatureFlagAdminService +{ + public async Task>> GetAllAsync(CancellationToken cancellationToken) => + await mediator.Send(new GetFeatureFlagsQuery(), cancellationToken); + + public async Task> GetByKeyAsync(string key, CancellationToken cancellationToken) => + await mediator.Send(new GetFeatureFlagByKeyQuery(key), cancellationToken); + + public async Task> CreateAsync( + string key, + string name, + string description, + bool defaultEnabled, + CancellationToken cancellationToken + ) => await mediator.Send(new CreateFeatureFlagCommand(key, name, description, defaultEnabled), cancellationToken); + + public async Task UpdateDefaultAsync( + Guid flagId, + bool defaultEnabled, + CancellationToken cancellationToken + ) => await mediator.Send(new UpdateFeatureFlagDefaultCommand(flagId, defaultEnabled), cancellationToken); + + public async Task UpdateRolloutAsync( + Guid flagId, + int rolloutPercentage, + CancellationToken cancellationToken + ) => await mediator.Send(new UpdateFeatureFlagRolloutCommand(flagId, rolloutPercentage), cancellationToken); + + public async Task SetUserOverrideAsync( + Guid flagId, + Guid userId, + DecisionEffect effect, + CancellationToken cancellationToken + ) => await mediator.Send(new SetFeatureFlagUserOverrideCommand(flagId, userId, effect), cancellationToken); + + public async Task RemoveUserOverrideAsync(Guid flagId, Guid userId, CancellationToken cancellationToken) => + await mediator.Send(new RemoveFeatureFlagUserOverrideCommand(flagId, userId), cancellationToken); + + public async Task SetGroupOverrideAsync( + Guid flagId, + Guid groupId, + DecisionEffect effect, + CancellationToken cancellationToken + ) => await mediator.Send(new SetFeatureFlagGroupOverrideCommand(flagId, groupId, effect), cancellationToken); + + public async Task RemoveGroupOverrideAsync( + Guid flagId, + Guid groupId, + CancellationToken cancellationToken + ) => await mediator.Send(new RemoveFeatureFlagGroupOverrideCommand(flagId, groupId), cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/Services/FeatureFlags/FeatureFlagResolutionService.cs b/StackDuel.Application/Services/FeatureFlags/FeatureFlagResolutionService.cs new file mode 100644 index 0000000..7d9b26e --- /dev/null +++ b/StackDuel.Application/Services/FeatureFlags/FeatureFlagResolutionService.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using StackDuel.Application.Queries.FeatureFlags.GetAllFeatureFlagsForViewer; + +namespace StackDuel.Application.Services.FeatureFlags; + +public interface IFeatureFlagResolutionService +{ + Task> GetFlagsForViewerAsync( + Guid? userId, + CancellationToken cancellationToken = default + ); +} + +internal sealed class FeatureFlagResolutionService(IMediator mediator, IMemoryCache cache) + : IFeatureFlagResolutionService +{ + private static readonly IReadOnlyDictionary Empty = new Dictionary(); + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(15); + + public async Task> GetFlagsForViewerAsync( + Guid? userId, + CancellationToken cancellationToken = default + ) + { + string cacheKey = $"feature-flags:{userId?.ToString() ?? "anonymous"}"; + + if (cache.TryGetValue(cacheKey, out IReadOnlyDictionary? cached) && cached is not null) + return cached; + + var result = await mediator.Send(new GetAllFeatureFlagsForViewerQuery(userId), cancellationToken); + IReadOnlyDictionary flags = result.IsSuccess ? result.Value : Empty; + + cache.Set(cacheKey, flags, CacheDuration); + + return flags; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/FeatureFlags/FeatureFlagService.cs b/StackDuel.Application/Services/FeatureFlags/FeatureFlagService.cs new file mode 100644 index 0000000..bf2b8ed --- /dev/null +++ b/StackDuel.Application/Services/FeatureFlags/FeatureFlagService.cs @@ -0,0 +1,19 @@ +using MediatR; +using StackDuel.Application.Queries.FeatureFlags.GetFeatureFlagEnabled; + +namespace StackDuel.Application.Services.FeatureFlags; + +public interface IFeatureFlagService +{ + Task IsEnabledAsync(string flagKey, Guid? userId, CancellationToken cancellationToken = default); +} + +internal sealed class FeatureFlagService(IMediator mediator) : IFeatureFlagService +{ + public async Task IsEnabledAsync(string flagKey, Guid? userId, CancellationToken cancellationToken = default) + { + var result = await mediator.Send(new GetFeatureFlagEnabledQuery(flagKey, userId), cancellationToken); + + return result.IsSuccess && result.Value; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Feedback/FeedbackService.cs b/StackDuel.Application/Services/Feedback/FeedbackService.cs new file mode 100644 index 0000000..064b23c --- /dev/null +++ b/StackDuel.Application/Services/Feedback/FeedbackService.cs @@ -0,0 +1,86 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.Feedback.SubmitFeedback; +using StackDuel.Application.Commands.Feedback.UpdateFeedbackStatus; +using StackDuel.Application.Feedback.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Feedback.GetAdminFeedbackDetail; +using StackDuel.Application.Queries.Feedback.GetAdminFeedbackPageable; +using StackDuel.Domain.Feedback.Enums; + +namespace StackDuel.Application.Services.Feedback; + +public interface IFeedbackService +{ + Task> SubmitFeedbackAsync(SubmitFeedbackDto dto, CancellationToken cancellationToken); + + Task UpdateFeedbackStatusAsync( + Guid feedbackId, + FeedbackStatus status, + string? adminNote, + CancellationToken cancellationToken + ); + + Task>> GetAdminFeedbackPageableAsync( + PaginationRequest paginationRequest, + FeedbackType? type, + FeedbackStatus? status, + CancellationToken cancellationToken + ); + + Task> GetAdminFeedbackDetailAsync( + Guid feedbackId, + CancellationToken cancellationToken + ); +} + +internal sealed class FeedbackService(IMediator mediator) : IFeedbackService +{ + public async Task> SubmitFeedbackAsync(SubmitFeedbackDto dto, CancellationToken cancellationToken) + { + return await mediator.Send( + new SubmitFeedbackCommand( + dto.UserId, + dto.Type, + dto.Message, + dto.Rating, + dto.ContextType, + dto.ContextEntityId, + dto.PageUrl, + dto.UserAgent + ), + cancellationToken + ); + } + + public async Task UpdateFeedbackStatusAsync( + Guid feedbackId, + FeedbackStatus status, + string? adminNote, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new UpdateFeedbackStatusCommand(feedbackId, status, adminNote), cancellationToken); + } + + public async Task>> GetAdminFeedbackPageableAsync( + PaginationRequest paginationRequest, + FeedbackType? type, + FeedbackStatus? status, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new GetAdminFeedbackPageableQuery(paginationRequest, type, status), + cancellationToken + ); + } + + public async Task> GetAdminFeedbackDetailAsync( + Guid feedbackId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetAdminFeedbackDetailQuery(feedbackId), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Games/GameService.cs b/StackDuel.Application/Services/Games/GameService.cs new file mode 100644 index 0000000..f3db386 --- /dev/null +++ b/StackDuel.Application/Services/Games/GameService.cs @@ -0,0 +1,269 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.Games.CloseLobby; +using StackDuel.Application.Commands.Games.CompleteProblem; +using StackDuel.Application.Commands.Games.CreateGame; +using StackDuel.Application.Commands.Games.ForfeitGame; +using StackDuel.Application.Commands.Games.JoinGame; +using StackDuel.Application.Commands.Games.JoinGameByCode; +using StackDuel.Application.Commands.Games.LeaveGame; +using StackDuel.Application.Commands.Games.SkipProblem; +using StackDuel.Application.Commands.Games.StartGame; +using StackDuel.Application.Commands.Games.SubmitGameProblem; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Games.GetAdminGameDetail; +using StackDuel.Application.Queries.Games.GetAdminGamePlayerHistory; +using StackDuel.Application.Queries.Games.GetAdminGamesPageable; +using StackDuel.Application.Queries.Games.GetDuelHeadToHeadRecord; +using StackDuel.Application.Queries.Games.GetGameModes; +using StackDuel.Application.Queries.Games.GetGameProblems; +using StackDuel.Application.Queries.Games.GetGameState; +using StackDuel.Application.Queries.Games.GetMyActiveGames; +using StackDuel.Application.Queries.Games.GetOpenGames; +using StackDuel.Application.Queries.Tracks.GetTracks; +using StackDuel.Application.Tracks.Dtos; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Application.Services.Games; + +public interface IGameService +{ + Task> CreateGameAsync( + string gameModeKey, + IReadOnlyList trackSelections, + int timeLimitInSeconds, + Guid userId, + bool skipsEnabled, + CancellationToken cancellationToken + ); + Task StartGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken); + Task JoinGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken); + Task> JoinGameByCodeAsync(string joinCode, Guid userId, CancellationToken cancellationToken); + Task LeaveGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken); + Task CloseLobbyAsync(Guid gameId, Guid userId, CancellationToken cancellationToken); + Task ForfeitGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken); + Task> SubmitGameProblemAsync( + Guid gameId, + Guid problemId, + Guid problemSetupId, + string code, + Guid userId, + CancellationToken cancellationToken + ); + Task> CompleteProblemAsync( + Guid gameId, + Guid problemId, + Guid submissionId, + Guid userId, + CancellationToken cancellationToken + ); + Task> SkipProblemAsync( + Guid gameId, + Guid problemId, + Guid userId, + CancellationToken cancellationToken + ); + Task> GetGameStateAsync(Guid gameId, Guid userId, CancellationToken cancellationToken); + Task>> GetGameProblemsAsync( + Guid gameId, + Guid userId, + CancellationToken cancellationToken + ); + Task>> GetGameModesAsync(CancellationToken cancellationToken); + Task>> GetTracksAsync(CancellationToken cancellationToken); + Task>> GetOpenGamesAsync( + string? gameModeKey, + PaginationRequest paginationRequest, + Guid? userId, + CancellationToken cancellationToken + ); + Task>> GetMyActiveGamesAsync( + Guid userId, + CancellationToken cancellationToken + ); + Task>> GetAdminGamesPageableAsync( + GameStatus? status, + PaginationRequest paginationRequest, + CancellationToken cancellationToken + ); + Task> GetAdminGameDetailAsync(Guid gameId, CancellationToken cancellationToken); + Task>> GetAdminGamePlayerHistoryAsync( + Guid gameId, + CancellationToken cancellationToken + ); + Task> GetDuelHeadToHeadRecordAsync( + Guid userId, + Guid opponentId, + CancellationToken cancellationToken + ); +} + +internal sealed class GameService(IMediator mediator) : IGameService +{ + public async Task> CreateGameAsync( + string gameModeKey, + IReadOnlyList trackSelections, + int timeLimitInSeconds, + Guid userId, + bool skipsEnabled, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new CreateGameCommand(gameModeKey, trackSelections, timeLimitInSeconds, userId, skipsEnabled), + cancellationToken + ); + } + + public async Task StartGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken) + { + return await mediator.Send(new StartGameCommand(gameId, userId), cancellationToken); + } + + public async Task JoinGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken) + { + return await mediator.Send(new JoinGameCommand(gameId, userId), cancellationToken); + } + + public async Task> JoinGameByCodeAsync( + string joinCode, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new JoinGameByCodeCommand(joinCode, userId), cancellationToken); + } + + public async Task LeaveGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken) + { + return await mediator.Send(new LeaveGameCommand(gameId, userId), cancellationToken); + } + + public async Task CloseLobbyAsync(Guid gameId, Guid userId, CancellationToken cancellationToken) + { + return await mediator.Send(new CloseLobbyCommand(gameId, userId), cancellationToken); + } + + public async Task ForfeitGameAsync(Guid gameId, Guid userId, CancellationToken cancellationToken) + { + return await mediator.Send(new ForfeitGameCommand(gameId, userId), cancellationToken); + } + + public async Task> SubmitGameProblemAsync( + Guid gameId, + Guid problemId, + Guid problemSetupId, + string code, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new SubmitGameProblemCommand(gameId, problemId, problemSetupId, code, userId), + cancellationToken + ); + } + + public async Task> CompleteProblemAsync( + Guid gameId, + Guid problemId, + Guid submissionId, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new CompleteProblemCommand(gameId, problemId, submissionId, userId), + cancellationToken + ); + } + + public async Task> SkipProblemAsync( + Guid gameId, + Guid problemId, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new SkipProblemCommand(gameId, problemId, userId), cancellationToken); + } + + public async Task> GetGameStateAsync( + Guid gameId, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetGameStateQuery(gameId, userId), cancellationToken); + } + + public async Task>> GetGameProblemsAsync( + Guid gameId, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetGameProblemsQuery(gameId, userId), cancellationToken); + } + + public async Task>> GetGameModesAsync(CancellationToken cancellationToken) + { + return await mediator.Send(new GetGameModesQuery(), cancellationToken); + } + + public async Task>> GetTracksAsync(CancellationToken cancellationToken) + { + return await mediator.Send(new GetTracksQuery(), cancellationToken); + } + + public async Task>> GetOpenGamesAsync( + string? gameModeKey, + PaginationRequest paginationRequest, + Guid? userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetOpenGamesQuery(gameModeKey, paginationRequest, userId), cancellationToken); + } + + public async Task>> GetMyActiveGamesAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetMyActiveGamesQuery(userId), cancellationToken); + } + + public async Task>> GetAdminGamesPageableAsync( + GameStatus? status, + PaginationRequest paginationRequest, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetAdminGamesPageableQuery(status, paginationRequest), cancellationToken); + } + + public async Task> GetAdminGameDetailAsync(Guid gameId, CancellationToken cancellationToken) + { + return await mediator.Send(new GetAdminGameDetailQuery(gameId), cancellationToken); + } + + public async Task>> GetAdminGamePlayerHistoryAsync( + Guid gameId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetAdminGamePlayerHistoryQuery(gameId), cancellationToken); + } + + public async Task> GetDuelHeadToHeadRecordAsync( + Guid userId, + Guid opponentId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetDuelHeadToHeadRecordQuery(userId, opponentId), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Groups/GroupService.cs b/StackDuel.Application/Services/Groups/GroupService.cs new file mode 100644 index 0000000..b394818 --- /dev/null +++ b/StackDuel.Application/Services/Groups/GroupService.cs @@ -0,0 +1,20 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Groups.Dtos; +using StackDuel.Application.Queries.Groups.GetGroups; + +namespace StackDuel.Application.Services.Groups; + +public interface IGroupService +{ + Task>> GetAllAsync(CancellationToken cancellationToken); +} + +internal sealed class GroupService(IMediator mediator) : IGroupService +{ + public async Task>> GetAllAsync(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetGroupsQuery(), cancellationToken); + return result; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Languages/LanguageService.cs b/StackDuel.Application/Services/Languages/LanguageService.cs new file mode 100644 index 0000000..029fd2b --- /dev/null +++ b/StackDuel.Application/Services/Languages/LanguageService.cs @@ -0,0 +1,20 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Languages.Dtos; +using StackDuel.Application.Queries.Languages.GetLanguages; + +namespace StackDuel.Application.Services.Languages; + +public interface ILanguageService +{ + Task>> GetAllAsync(CancellationToken cancellationToken); +} + +internal sealed class LanguageService(IMediator mediator) : ILanguageService +{ + public async Task>> GetAllAsync(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetLanguagesQuery(), cancellationToken); + return result; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Leaderboards/LeaderboardService.cs b/StackDuel.Application/Services/Leaderboards/LeaderboardService.cs new file mode 100644 index 0000000..ce370a6 --- /dev/null +++ b/StackDuel.Application/Services/Leaderboards/LeaderboardService.cs @@ -0,0 +1,56 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Leaderboards.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Leaderboards.GetLeaderboard; +using StackDuel.Application.Queries.Leaderboards.GetMyLeaderboardEntry; + +namespace StackDuel.Application.Services.Leaderboards; + +public interface ILeaderboardService +{ + Task>> GetLeaderboardAsync( + string gameModeKey, + int timeLimitInSeconds, + PaginationRequest paginationRequest, + Guid? requestedByUserId, + CancellationToken cancellationToken + ); + + Task> GetMyEntryAsync( + string gameModeKey, + int timeLimitInSeconds, + Guid userId, + CancellationToken cancellationToken + ); +} + +internal sealed class LeaderboardService(IMediator mediator) : ILeaderboardService +{ + public async Task>> GetLeaderboardAsync( + string gameModeKey, + int timeLimitInSeconds, + PaginationRequest paginationRequest, + Guid? requestedByUserId, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new GetLeaderboardQuery(gameModeKey, timeLimitInSeconds, paginationRequest, requestedByUserId), + cancellationToken + ); + } + + public async Task> GetMyEntryAsync( + string gameModeKey, + int timeLimitInSeconds, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new GetMyLeaderboardEntryQuery(gameModeKey, timeLimitInSeconds, userId), + cancellationToken + ); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Problems/ProblemPoolService.cs b/StackDuel.Application/Services/Problems/ProblemPoolService.cs new file mode 100644 index 0000000..aaa1695 --- /dev/null +++ b/StackDuel.Application/Services/Problems/ProblemPoolService.cs @@ -0,0 +1,140 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.ProblemPools.AddProblemsToPool; +using StackDuel.Application.Commands.ProblemPools.AddProblemToPool; +using StackDuel.Application.Commands.ProblemPools.CreateProblemPool; +using StackDuel.Application.Commands.ProblemPools.RemoveProblemFromPool; +using StackDuel.Application.Commands.ProblemPools.ReorderProblemPool; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Queries.ProblemPools.GetOrderedProblemPoolMembers; +using StackDuel.Application.Queries.ProblemPools.GetProblemPoolMemberIds; +using StackDuel.Application.Queries.ProblemPools.GetProblemPoolMembersPaged; +using StackDuel.Application.Queries.ProblemPools.GetProblemPools; + +namespace StackDuel.Application.Services.Problems; + +public interface IProblemPoolService +{ + Task>> GetProblemPoolsAsync(CancellationToken cancellationToken); + + Task>> GetProblemPoolMemberIdsAsync(string poolKey, CancellationToken cancellationToken); + + Task>> GetProblemPoolMembersPagedAsync( + string poolKey, + PaginationRequest paginationRequest, + CancellationToken cancellationToken + ); + + Task> CreateProblemPoolAsync( + string key, + string name, + string? description, + CancellationToken cancellationToken + ); + + Task AddProblemToPoolAsync(string poolKey, Guid problemId, CancellationToken cancellationToken); + + Task> AddProblemsToPoolAsync( + string poolKey, + IReadOnlyList problemIds, + bool selectAllMatching, + string? search, + IReadOnlyList excludedProblemIds, + CancellationToken cancellationToken + ); + + Task RemoveProblemFromPoolAsync(string poolKey, Guid problemId, CancellationToken cancellationToken); + + Task>> GetOrderedProblemPoolMembersAsync( + string poolKey, + CancellationToken cancellationToken + ); + + Task ReorderProblemPoolAsync( + string poolKey, + IReadOnlyList problemIds, + CancellationToken cancellationToken + ); +} + +internal sealed class ProblemPoolService(IMediator mediator) : IProblemPoolService +{ + public async Task>> GetProblemPoolsAsync(CancellationToken cancellationToken) + { + return await mediator.Send(new GetProblemPoolsQuery(), cancellationToken); + } + + public async Task>> GetProblemPoolMemberIdsAsync( + string poolKey, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetProblemPoolMemberIdsQuery(poolKey), cancellationToken); + } + + public async Task>> GetProblemPoolMembersPagedAsync( + string poolKey, + PaginationRequest paginationRequest, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetProblemPoolMembersPagedQuery(poolKey, paginationRequest), cancellationToken); + } + + public async Task> CreateProblemPoolAsync( + string key, + string name, + string? description, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new CreateProblemPoolCommand(key, name, description), cancellationToken); + } + + public async Task AddProblemToPoolAsync(string poolKey, Guid problemId, CancellationToken cancellationToken) + { + return await mediator.Send(new AddProblemToPoolCommand(poolKey, problemId), cancellationToken); + } + + public async Task> AddProblemsToPoolAsync( + string poolKey, + IReadOnlyList problemIds, + bool selectAllMatching, + string? search, + IReadOnlyList excludedProblemIds, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new AddProblemsToPoolCommand(poolKey, problemIds, selectAllMatching, search, excludedProblemIds), + cancellationToken + ); + } + + public async Task RemoveProblemFromPoolAsync( + string poolKey, + Guid problemId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new RemoveProblemFromPoolCommand(poolKey, problemId), cancellationToken); + } + + public async Task>> GetOrderedProblemPoolMembersAsync( + string poolKey, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetOrderedProblemPoolMembersQuery(poolKey), cancellationToken); + } + + public async Task ReorderProblemPoolAsync( + string poolKey, + IReadOnlyList problemIds, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new ReorderProblemPoolCommand(poolKey, problemIds), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Problems/ProblemService.cs b/StackDuel.Application/Services/Problems/ProblemService.cs new file mode 100644 index 0000000..b960b57 --- /dev/null +++ b/StackDuel.Application/Services/Problems/ProblemService.cs @@ -0,0 +1,314 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.Problems.AddProblemSetup; +using StackDuel.Application.Commands.Problems.CreateProblemDraft; +using StackDuel.Application.Commands.Problems.SetProblemGenerationParameters; +using StackDuel.Application.Commands.Problems.SetProblemReaction; +using StackDuel.Application.Commands.Problems.SetProblemSampleTestCases; +using StackDuel.Application.Commands.Problems.SubmitProblemForValidation; +using StackDuel.Application.Commands.Problems.UpdateProblem; +using StackDuel.Application.Commands.Problems.UpsertProblemSetupReferenceSolution; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Application.Queries.Problems.GetAdminProblemDetail; +using StackDuel.Application.Queries.Problems.GetAdminProblemsPageable; +using StackDuel.Application.Queries.Problems.GetProblemById; +using StackDuel.Application.Queries.Problems.GetProblemBySlug; +using StackDuel.Application.Queries.Problems.GetProblemReactionSummary; +using StackDuel.Application.Queries.Problems.GetProblemSetup; +using StackDuel.Application.Queries.Problems.GetProblemsPageable; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Application.Services.Problems; + +public interface IProblemService +{ + Task> GetProblemSetupAsync( + string slug, + Guid languageVersionId, + CancellationToken cancellationToken + ); + + Task> GetProblemReactionSummaryAsync( + Guid problemId, + Guid? userId, + CancellationToken cancellationToken + ); + + Task> SetProblemReactionAsync( + Guid problemId, + Guid userId, + string reactionTypeKey, + CancellationToken cancellationToken + ); + + Task>> GetProblemsPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ); + + Task> GetProblemWithSetupsBySlug(string slug, CancellationToken cancellationToken); + + Task> GetProblemWithSetupsById(Guid id, CancellationToken cancellationToken); + + Task>> GetAdminProblemsPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ); + + Task> GetAdminProblemDetailAsync(Guid problemId, CancellationToken cancellationToken); + + Task UpdateProblemAsync( + Guid problemId, + string title, + string question, + int difficulty, + int timeLimitMs, + int memoryLimitMb, + IReadOnlyCollection tags, + ProblemStatus? status, + CancellationToken cancellationToken + ); + + Task> CreateProblemDraftAsync(CreateProblemDraftInput input, CancellationToken cancellationToken); + + Task> AddProblemSetupAsync( + Guid problemId, + Guid languageVersionId, + CancellationToken cancellationToken + ); + + Task> UpsertProblemSetupReferenceSolutionAsync( + Guid problemId, + Guid languageVersionId, + string initialCode, + string? functionName, + string referenceSolutionCode, + CancellationToken cancellationToken + ); + + Task SetProblemGenerationParametersAsync( + Guid problemId, + IReadOnlyList parameters, + string outputValueType, + int targetCaseCount, + int seed, + CancellationToken cancellationToken + ); + + Task SetProblemSampleTestCasesAsync( + Guid problemId, + IReadOnlyList testCases, + CancellationToken cancellationToken + ); + + Task SubmitProblemForValidationAsync(Guid problemId, CancellationToken cancellationToken); +} + +internal sealed class ProblemService(IMediator mediator) : IProblemService +{ + public async Task> GetProblemSetupAsync( + string slug, + Guid languageVersionId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetProblemSetupQuery(slug, languageVersionId), cancellationToken); + + return result; + } + + public async Task>> GetProblemsPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetProblemsPageableQuery(paginationRequest, search), cancellationToken); + + return result; + } + + public async Task> GetProblemWithSetupsBySlug( + string slug, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetProblemBySlugQuery(slug), cancellationToken); + + return result; + } + + public async Task> GetProblemWithSetupsById( + Guid id, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetProblemByIdQuery(id), cancellationToken); + + return result; + } + + public async Task> GetProblemReactionSummaryAsync( + Guid problemId, + Guid? userId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetProblemReactionSummaryQuery(problemId, userId), cancellationToken); + + return result; + } + + public async Task> SetProblemReactionAsync( + Guid problemId, + Guid userId, + string reactionTypeKey, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new SetProblemReactionCommand(problemId, userId, reactionTypeKey), + cancellationToken + ); + + return result; + } + + public async Task>> GetAdminProblemsPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new GetAdminProblemsPageableQuery(paginationRequest, search), + cancellationToken + ); + + return result; + } + + public async Task> GetAdminProblemDetailAsync( + Guid problemId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetAdminProblemDetailQuery(problemId), cancellationToken); + + return result; + } + + public async Task UpdateProblemAsync( + Guid problemId, + string title, + string question, + int difficulty, + int timeLimitMs, + int memoryLimitMb, + IReadOnlyCollection tags, + ProblemStatus? status, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new UpdateProblemCommand(problemId, title, question, difficulty, timeLimitMs, memoryLimitMb, tags, status), + cancellationToken + ); + + return result; + } + + public async Task> CreateProblemDraftAsync( + CreateProblemDraftInput input, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new CreateProblemDraftCommand( + input.Title, + input.Question, + input.Difficulty, + input.TimeLimitMs, + input.MemoryLimitMb, + input.Tags, + input.TrackId + ), + cancellationToken + ); + + return result; + } + + public async Task> AddProblemSetupAsync( + Guid problemId, + Guid languageVersionId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new AddProblemSetupCommand(problemId, languageVersionId), cancellationToken); + + return result; + } + + public async Task> UpsertProblemSetupReferenceSolutionAsync( + Guid problemId, + Guid languageVersionId, + string initialCode, + string? functionName, + string referenceSolutionCode, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new UpsertProblemSetupReferenceSolutionCommand( + problemId, + languageVersionId, + initialCode, + functionName, + referenceSolutionCode + ), + cancellationToken + ); + + return result; + } + + public async Task SetProblemGenerationParametersAsync( + Guid problemId, + IReadOnlyList parameters, + string outputValueType, + int targetCaseCount, + int seed, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new SetProblemGenerationParametersCommand(problemId, parameters, outputValueType, targetCaseCount, seed), + cancellationToken + ); + + return result; + } + + public async Task SetProblemSampleTestCasesAsync( + Guid problemId, + IReadOnlyList testCases, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new SetProblemSampleTestCasesCommand(problemId, testCases), cancellationToken); + + return result; + } + + public async Task SubmitProblemForValidationAsync(Guid problemId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new SubmitProblemForValidationCommand(problemId), cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Problems/RequiredProblemLanguageAdminService.cs b/StackDuel.Application/Services/Problems/RequiredProblemLanguageAdminService.cs new file mode 100644 index 0000000..b338b16 --- /dev/null +++ b/StackDuel.Application/Services/Problems/RequiredProblemLanguageAdminService.cs @@ -0,0 +1,30 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.Problems.RequiredLanguages.AddRequiredProblemLanguage; +using StackDuel.Application.Commands.Problems.RequiredLanguages.RemoveRequiredProblemLanguage; +using StackDuel.Application.Problems.RequiredLanguages.Dtos; +using StackDuel.Application.Queries.Problems.GetRequiredProblemLanguages; + +namespace StackDuel.Application.Services.Problems; + +public interface IRequiredProblemLanguageAdminService +{ + Task>> GetAllAsync(CancellationToken cancellationToken); + + Task> AddAsync(Guid languageVersionId, CancellationToken cancellationToken); + + Task RemoveAsync(Guid id, CancellationToken cancellationToken); +} + +internal sealed class RequiredProblemLanguageAdminService(IMediator mediator) : IRequiredProblemLanguageAdminService +{ + public async Task>> GetAllAsync( + CancellationToken cancellationToken + ) => await mediator.Send(new GetRequiredProblemLanguagesQuery(), cancellationToken); + + public async Task> AddAsync(Guid languageVersionId, CancellationToken cancellationToken) => + await mediator.Send(new AddRequiredProblemLanguageCommand(languageVersionId), cancellationToken); + + public async Task RemoveAsync(Guid id, CancellationToken cancellationToken) => + await mediator.Send(new RemoveRequiredProblemLanguageCommand(id), cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Submissions/SubmissionService.cs b/StackDuel.Application/Services/Submissions/SubmissionService.cs new file mode 100644 index 0000000..be99322 --- /dev/null +++ b/StackDuel.Application/Services/Submissions/SubmissionService.cs @@ -0,0 +1,114 @@ +using Ardalis.Result; +using MediatR; +using StackDuel.Application.Commands.Submissions.CreateSubmission; +using StackDuel.Application.Commands.Submissions.ReceiveJudge0Callback; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Submissions.GetAdminSubmissionDetail; +using StackDuel.Application.Queries.Submissions.GetAdminSubmissionsPageable; +using StackDuel.Application.Queries.Submissions.GetSubmissionsByProblemSlug; +using StackDuel.Application.Queries.Submissions.GetSubmissionStatus; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; + +namespace StackDuel.Application.Services.Submissions; + +public interface ISubmissionService +{ + Task> CreateSubmissionAsync(CreateSubmissionDto dto, CancellationToken cancellationToken); + + Task> GetSubmissionStatusAsync( + Guid submissionId, + Guid userId, + CancellationToken cancellationToken + ); + + Task>> GetSubmissionsByProblemSlugAsync( + string slug, + PaginationRequest paginationRequest, + Guid? userId, + SubmissionFilter type, + SubmissionSortOrder sortOrder, + CancellationToken cancellationToken + ); + + Task>> GetAdminSubmissionsPageableAsync( + PaginationRequest paginationRequest, + Guid? id, + CancellationToken cancellationToken + ); + + Task> GetAdminSubmissionDetailAsync( + Guid submissionId, + CancellationToken cancellationToken + ); + + Task ReceiveJudge0CallbackAsync(string? key, Guid submissionId, CancellationToken cancellationToken); +} + +internal sealed class SubmissionService(IMediator mediator) : ISubmissionService +{ + public async Task> CreateSubmissionAsync(CreateSubmissionDto dto, CancellationToken cancellationToken) + { + return await mediator.Send( + new CreateSubmissionCommand( + dto.ProblemSetupId, + dto.Type, + dto.Code, + dto.CreatedById, + dto.CustomTestCases, + dto.AdditionalFiles + ), + cancellationToken + ); + } + + public async Task>> GetSubmissionsByProblemSlugAsync( + string slug, + PaginationRequest paginationRequest, + Guid? userId, + SubmissionFilter type, + SubmissionSortOrder sortOrder, + CancellationToken cancellationToken + ) + { + return await mediator.Send( + new GetSubmissionsByProblemSlugQuery(slug, paginationRequest, userId, type, sortOrder), + cancellationToken + ); + } + + public async Task> GetSubmissionStatusAsync( + Guid submissionId, + Guid userId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetSubmissionStatusQuery(submissionId, userId), cancellationToken); + } + + public async Task>> GetAdminSubmissionsPageableAsync( + PaginationRequest paginationRequest, + Guid? id, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetAdminSubmissionsPageableQuery(paginationRequest, id), cancellationToken); + } + + public async Task> GetAdminSubmissionDetailAsync( + Guid submissionId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new GetAdminSubmissionDetailQuery(submissionId), cancellationToken); + } + + public async Task ReceiveJudge0CallbackAsync( + string? key, + Guid submissionId, + CancellationToken cancellationToken + ) + { + return await mediator.Send(new ReceiveJudge0CallbackCommand(key, submissionId), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Users/AvatarRetentionPolicy.cs b/StackDuel.Application/Services/Users/AvatarRetentionPolicy.cs new file mode 100644 index 0000000..35ab640 --- /dev/null +++ b/StackDuel.Application/Services/Users/AvatarRetentionPolicy.cs @@ -0,0 +1,33 @@ +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Application.Services.Users; + +public static class AvatarRetentionPolicy +{ + public const int AvatarsToKeep = 3; + + public static IReadOnlyList SelectStale( + IReadOnlyList avatarsNewestFirst, + string? currentImageUrl + ) + { + var stale = new List(); + int keptOthers = 0; + + foreach (UserAvatar avatar in avatarsNewestFirst) + { + if (avatar.ImageUrl.Value == currentImageUrl) + continue; + + if (keptOthers < AvatarsToKeep - 1) + { + keptOthers++; + continue; + } + + stale.Add(avatar); + } + + return stale; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Users/IAvatarBlobStorage.cs b/StackDuel.Application/Services/Users/IAvatarBlobStorage.cs new file mode 100644 index 0000000..5a16590 --- /dev/null +++ b/StackDuel.Application/Services/Users/IAvatarBlobStorage.cs @@ -0,0 +1,14 @@ +namespace StackDuel.Application.Services.Users; + +public interface IAvatarBlobStorage +{ + Task UploadAsync( + Guid userId, + Guid avatarId, + byte[] content, + string contentType, + CancellationToken cancellationToken = default + ); + + Task DeleteAsync(string blobUrl, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Users/UserService.cs b/StackDuel.Application/Services/Users/UserService.cs new file mode 100644 index 0000000..bdedcf0 --- /dev/null +++ b/StackDuel.Application/Services/Users/UserService.cs @@ -0,0 +1,201 @@ +using Ardalis.Result; +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using StackDuel.Application.Commands.Users.SelectUserAvatar; +using StackDuel.Application.Commands.Users.UpdateProfilePrivacy; +using StackDuel.Application.Commands.Users.UpdateUserGroups; +using StackDuel.Application.Commands.Users.UploadUserAvatar; +using StackDuel.Application.Commands.Users.UpsertUser; +using StackDuel.Application.Pagination; +using StackDuel.Application.Queries.Permissions.GetUserAccessContext; +using StackDuel.Application.Queries.Users.GetAdminUserDetail; +using StackDuel.Application.Queries.Users.GetAdminUsersPageable; +using StackDuel.Application.Queries.Users.GetUserAvatarHistory; +using StackDuel.Application.Queries.Users.GetUserBySub; +using StackDuel.Application.Queries.Users.GetUserGameStats; +using StackDuel.Application.Queries.Users.GetUserProfileByUsername; +using StackDuel.Application.Users.Dtos; +using StackDuel.Application.Users.Dtos.Admin; + +namespace StackDuel.Application.Services.Users; + +public interface IUserService +{ + Task> GetBySubAsync(string sub, CancellationToken cancellationToken); + + Task> GetAccessContextBySubAsync(string sub, CancellationToken cancellationToken); + + void InvalidateAccessContext(string sub); + + Task> GetProfileByUsernameAsync( + string username, + Guid? requestingUserId, + CancellationToken cancellationToken + ); + + Task> GetGameStatsByUsernameAsync( + string username, + Guid? requestingUserId, + CancellationToken cancellationToken + ); + + Task UpsertAccountAsync(string sub, UpsertUserDto request, CancellationToken cancellationToken); + + Task>> GetAdminUsersPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ); + + Task> GetAdminUserDetailAsync(Guid userId, CancellationToken cancellationToken); + + Task UpdateUserGroupsAsync( + Guid userId, + IReadOnlyCollection groupIds, + CancellationToken cancellationToken + ); + + Task UpdateProfilePrivacyAsync(Guid userId, bool isPrivate, CancellationToken cancellationToken); + + Task> UploadAvatarAsync(Guid userId, byte[] content, CancellationToken cancellationToken); + + Task SelectAvatarAsync(Guid userId, Guid avatarId, CancellationToken cancellationToken); + + Task>> GetAvatarHistoryAsync(Guid userId, CancellationToken cancellationToken); +} + +internal sealed class UserService(IMediator mediator, IMemoryCache cache) : IUserService +{ + private static readonly TimeSpan AccessContextCacheDuration = TimeSpan.FromSeconds(60); + + private static string AccessContextCacheKey(string sub) => $"user-access-context:{sub}"; + + public async Task> GetAccessContextBySubAsync( + string sub, + CancellationToken cancellationToken + ) + { + string cacheKey = AccessContextCacheKey(sub); + + if (cache.TryGetValue(cacheKey, out Result? cached) && cached is not null) + return cached; + + var result = await mediator.Send(new GetUserAccessContextQuery(sub), cancellationToken); + + if (result.IsSuccess) + cache.Set(cacheKey, result, AccessContextCacheDuration); + + return result; + } + + public void InvalidateAccessContext(string sub) => cache.Remove(AccessContextCacheKey(sub)); + + public async Task> GetBySubAsync(string sub, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserBySubQuery(sub), cancellationToken); + return result; + } + + public async Task> GetProfileByUsernameAsync( + string username, + Guid? requestingUserId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send( + new GetUserProfileByUsernameQuery(username, requestingUserId), + cancellationToken + ); + return result; + } + + public async Task> GetGameStatsByUsernameAsync( + string username, + Guid? requestingUserId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetUserGameStatsQuery(username, requestingUserId), cancellationToken); + return result; + } + + public async Task UpsertAccountAsync(string sub, UpsertUserDto request, CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpsertUserCommand( + sub, + request.Username, + request.ImageUrl, + request.Bio, + request.LanguageIds, + request.TenantId + ), + cancellationToken + ); + return result; + } + + public async Task>> GetAdminUsersPageableAsync( + PaginationRequest paginationRequest, + string? search, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetAdminUsersPageableQuery(paginationRequest, search), cancellationToken); + return result; + } + + public async Task> GetAdminUserDetailAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetAdminUserDetailQuery(userId), cancellationToken); + return result; + } + + public async Task UpdateUserGroupsAsync( + Guid userId, + IReadOnlyCollection groupIds, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new UpdateUserGroupsCommand(userId, groupIds), cancellationToken); + return result; + } + + public async Task UpdateProfilePrivacyAsync( + Guid userId, + bool isPrivate, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new UpdateProfilePrivacyCommand(userId, isPrivate), cancellationToken); + return result; + } + + public async Task> UploadAvatarAsync( + Guid userId, + byte[] content, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new UploadUserAvatarCommand(userId, content), cancellationToken); + return result; + } + + public async Task SelectAvatarAsync(Guid userId, Guid avatarId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new SelectUserAvatarCommand(userId, avatarId), cancellationToken); + return result; + } + + public async Task>> GetAvatarHistoryAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + var result = await mediator.Send(new GetUserAvatarHistoryQuery(userId), cancellationToken); + return result; + } +} \ No newline at end of file diff --git a/StackDuel.Application/Services/Users/UsernameGeneratorService.cs b/StackDuel.Application/Services/Users/UsernameGeneratorService.cs new file mode 100644 index 0000000..21ce984 --- /dev/null +++ b/StackDuel.Application/Services/Users/UsernameGeneratorService.cs @@ -0,0 +1,34 @@ +using Bogus; + +namespace StackDuel.Application.Services.Users; + +public interface IUsernameGeneratorService +{ + string Generate(); +} + +internal sealed class UsernameGeneratorService : IUsernameGeneratorService +{ + private readonly Faker _faker = new(); + + public string Generate() + { + string adjective = Clean(_faker.Hacker.Adjective()); + string noun = Clean(_faker.Hacker.Noun()); + int number = _faker.Random.Int(10, 99); + + return $"{Truncate(adjective, 8)}_{Truncate(noun, 7)}{number}"; + } + + private static string Clean(string value) + { + char[] chars = + [ + .. value.Select(c => c == ' ' ? '_' : c).Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-'), + ]; + return new string(chars); + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength]; +} \ No newline at end of file diff --git a/StackDuel.Application/StackDuel.Application.csproj b/StackDuel.Application/StackDuel.Application.csproj index b760144..e474b4d 100644 --- a/StackDuel.Application/StackDuel.Application.csproj +++ b/StackDuel.Application/StackDuel.Application.csproj @@ -1,9 +1,24 @@  - net10.0 enable enable + + + + + + + + + + + + + + + + diff --git a/StackDuel.Application/Submissions/Dtos/AdminSubmissionDetailDto.cs b/StackDuel.Application/Submissions/Dtos/AdminSubmissionDetailDto.cs new file mode 100644 index 0000000..46fa52b --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/AdminSubmissionDetailDto.cs @@ -0,0 +1,78 @@ +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SubmissionJobs.Enums; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record AdminSubmissionDetailDto( + Guid Id, + SubmissionType Type, + SubmissionStatus Status, + string SourceCode, + DateTime CreatedAt, + Guid ProblemSetupId, + Guid ProblemId, + string ProblemTitle, + string ProblemSlug, + SubmissionLanguageDto Language, + SubmissionUserDto User, + int? MemoryUsage, + int? ExecutionTime, + IReadOnlyList Results, + AdminSubmissionJobDto? Job +); + +public sealed record AdminSubmissionResultDto( + Guid TestCaseId, + SubmissionResultStatus Status, + int? Runtime, + int? MemoryUsed, + string? ActualOutput, + string? StandardOutput, + string? StandardError, + string? CompileOutput +); + +public sealed record AdminSubmissionJobDto( + Guid Id, + SubmissionJobStatus Status, + string? FailureReason, + DateTime CreatedAt, + DateTime? CompletedAt, + Guid? CurrentStepId, + IReadOnlyList Steps +); + +public enum AdminSubmissionJobStepStatus +{ + Pending, + Running, + Succeeded, + Failed, +} + +public sealed record AdminSubmissionJobStepDto( + Guid StepId, + string Name, + ExecutionPipelineStepType StepType, + int StepOrder, + int MaxAttempts, + int TimeoutSeconds, + bool IsPolling, + bool IsCurrent, + AdminSubmissionJobStepStatus Status, + int AttemptCount, + int? TotalDurationMs, + IReadOnlyList Attempts +); + +public sealed record AdminSubmissionJobAttemptDto( + int AttemptNumber, + SubmissionJobAttemptStatus Status, + string? RequestPayload, + string? ResponsePayload, + string? Error, + DateTime StartedAt, + DateTime? CompletedAt, + int? DurationMs +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/Dtos/AdminSubmissionListItemDto.cs b/StackDuel.Application/Submissions/Dtos/AdminSubmissionListItemDto.cs new file mode 100644 index 0000000..7d431f7 --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/AdminSubmissionListItemDto.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record AdminSubmissionListItemDto( + Guid Id, + SubmissionType Type, + SubmissionStatus Status, + Guid ProblemSetupId, + Guid ProblemId, + string ProblemTitle, + string ProblemSlug, + SubmissionLanguageDto Language, + SubmissionUserDto User, + DateTime CreatedAt, + int? MemoryUsage, + int? ExecutionTime +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/Dtos/CreateSubmissionDto.cs b/StackDuel.Application/Submissions/Dtos/CreateSubmissionDto.cs new file mode 100644 index 0000000..fc1cbf0 --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/CreateSubmissionDto.cs @@ -0,0 +1,16 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record CreateSubmissionCustomTestCaseDto(IReadOnlyCollection Inputs); + +public sealed record CreateSubmissionFileDto(string Path, string Content); + +public sealed record CreateSubmissionDto( + Guid ProblemSetupId, + SubmissionType Type, + string Code, + Guid CreatedById, + IReadOnlyCollection? CustomTestCases, + IReadOnlyCollection? AdditionalFiles = null +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/Dtos/GameSubmissionEventDto.cs b/StackDuel.Application/Submissions/Dtos/GameSubmissionEventDto.cs new file mode 100644 index 0000000..1230858 --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/GameSubmissionEventDto.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record GameSubmissionEventDto( + Guid Id, + Guid UserId, + Guid ProblemId, + string ProblemTitle, + string ProblemSlug, + SubmissionStatus Status, + DateTime CreatedAt, + string LanguageName +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/Dtos/ProblemSubmissionDto.cs b/StackDuel.Application/Submissions/Dtos/ProblemSubmissionDto.cs new file mode 100644 index 0000000..31e7a7e --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/ProblemSubmissionDto.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record SubmissionUserDto(string Username, string? ImageUrl); + +public sealed record SubmissionLanguageDto(Guid Id, string Name, string Version); + +public sealed record ProblemSubmissionDto( + Guid Id, + SubmissionStatus Status, + SubmissionLanguageDto Language, + string Code, + DateTime CreatedAt, + SubmissionUserDto User, + int? MemoryUsage, + int? ExecutionTime +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/Dtos/SubmissionStatusDto.cs b/StackDuel.Application/Submissions/Dtos/SubmissionStatusDto.cs new file mode 100644 index 0000000..3ab9e51 --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/SubmissionStatusDto.cs @@ -0,0 +1,24 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record SubmissionResultStatusDto( + SubmissionResultStatus Status, + int? Runtime, + int? MemoryUsed, + string? Input, + string? ActualOutput, + string? ExpectedOutput, + string? StandardOutput, + string? StandardError, + string? CompileOutput +); + +public sealed record SubmissionStatusDto( + Guid SubmissionId, + Guid ProblemSetupId, + SubmissionStatus Status, + string? CurrentStepName, + string Code, + IReadOnlyCollection Results +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/Dtos/SubmissionSummaryDto.cs b/StackDuel.Application/Submissions/Dtos/SubmissionSummaryDto.cs new file mode 100644 index 0000000..6728e98 --- /dev/null +++ b/StackDuel.Application/Submissions/Dtos/SubmissionSummaryDto.cs @@ -0,0 +1,10 @@ +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Submissions.Dtos; + +public sealed record SubmissionSummaryDto( + Guid UserId, + Guid ProblemSetupId, + SubmissionType Type, + SubmissionStatus Status +); \ No newline at end of file diff --git a/StackDuel.Application/Submissions/ISubmissionReadRepository.cs b/StackDuel.Application/Submissions/ISubmissionReadRepository.cs new file mode 100644 index 0000000..1dd7dd3 --- /dev/null +++ b/StackDuel.Application/Submissions/ISubmissionReadRepository.cs @@ -0,0 +1,68 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application.Submissions; + +public interface ISubmissionReadRepository +{ + Task> GetProblemSubmissionsPagedAsync( + Guid problemId, + PaginationRequest paginationRequest, + Guid? userId, + SubmissionFilter filter, + SubmissionSortOrder sort, + CancellationToken cancellationToken = default + ); + + Task> GetAdminSubmissionsPagedAsync( + PaginationRequest paginationRequest, + Guid? id, + CancellationToken cancellationToken = default + ); + + Task FindAdminSubmissionByIdAsync( + Guid submissionId, + CancellationToken cancellationToken = default + ); + + /// + /// A user's most recent "Submit"-type submissions (never "Run"/test submissions), newest + /// first — backs the profile's recent-submissions list. Metadata only, source code is + /// deliberately never projected here: this is shown to other visitors, not just the owner. + /// + Task> GetRecentSubmissionsForUserAsync( + Guid userId, + int limit, + CancellationToken cancellationToken = default + ); + + Task> GetSubmissionCountsByDayForUserAsync( + Guid userId, + DateOnly fromDate, + CancellationToken cancellationToken = default + ); + + Task FindSummaryAsync(Guid submissionId, CancellationToken cancellationToken = default); + + /// + /// Every terminal (Accepted/WrongAnswer) "Submit"-type submission made in a game, across all + /// participants, oldest first — the raw material for the admin game history timeline. Queued + /// and Running submissions are excluded since they're transient in-flight state, not a + /// meaningful past event. + /// + Task> GetSubmissionsForGameAsync( + Guid gameId, + CancellationToken cancellationToken = default + ); + + /// + /// Which of the given problem ids the user has an accepted "Submit"-type submission for — + /// used to determine which daily challenges a user has solved. + /// + Task> GetAcceptedProblemIdsForUserAsync( + Guid userId, + IReadOnlyCollection problemIds, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/Submissions/SubmissionFilter.cs b/StackDuel.Application/Submissions/SubmissionFilter.cs new file mode 100644 index 0000000..188caf2 --- /dev/null +++ b/StackDuel.Application/Submissions/SubmissionFilter.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Application.Submissions; + +public enum SubmissionFilter +{ + MySubmissions, + UserSolutions, +} + +public enum SubmissionSortOrder +{ + Newest, + Oldest, +} \ No newline at end of file diff --git a/StackDuel.Application/TestCaseGeneration/ITestCaseGenerationService.cs b/StackDuel.Application/TestCaseGeneration/ITestCaseGenerationService.cs new file mode 100644 index 0000000..b24bad0 --- /dev/null +++ b/StackDuel.Application/TestCaseGeneration/ITestCaseGenerationService.cs @@ -0,0 +1,36 @@ +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Application.TestCaseGeneration; + +public sealed record GenerateTestCasesRequest( + Guid ProblemSetupId, + string ReferenceSolutionCode, + IReadOnlyList Parameters, + string OutputValueType, + int TargetCaseCount, + int Seed +); + +/// +/// is the target number of successful cases (not attempts — +/// generation keeps trying new random inputs, up to a safety cap, until this many succeed +/// or it gives up). is how many attempts failed along the way. +/// +public sealed record GenerateTestCasesResult(int Requested, int Generated, int Skipped, IReadOnlyList Warnings) +{ + public bool Succeeded => Generated >= Requested; +} + +/// +/// Generates a problem's random hidden test case pool offline (triggered by whoever authors +/// the problem — the Seeder today), verified against a trusted reference solution run through +/// the same sandboxed Judge0 pipeline used for real submissions. Does not touch the live +/// grading path. +/// +public interface ITestCaseGenerationService +{ + Task GenerateAsync( + GenerateTestCasesRequest request, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Application/TestCaseGeneration/ITestCaseGeneratorService.cs b/StackDuel.Application/TestCaseGeneration/ITestCaseGeneratorService.cs new file mode 100644 index 0000000..a966b7b --- /dev/null +++ b/StackDuel.Application/TestCaseGeneration/ITestCaseGeneratorService.cs @@ -0,0 +1,17 @@ +using StackDuel.Domain.TestCaseGeneration.Entities; + +namespace StackDuel.Application.TestCaseGeneration; + +public sealed record GeneratedValue(string Value, string ValueType); + +/// +/// Deterministically produces input values for a generated test case from a +/// . Same spec + case index always yields the same +/// values, so a generated only +/// needs to persist which spec and index it came from — the input itself is regenerated +/// on demand instead of stored. +/// +public interface ITestCaseGeneratorService +{ + IReadOnlyList GenerateInputs(TestCaseGenerationSpec spec, int caseIndex); +} \ No newline at end of file diff --git a/StackDuel.Application/TestCaseGeneration/ITestCaseRefreshService.cs b/StackDuel.Application/TestCaseGeneration/ITestCaseRefreshService.cs new file mode 100644 index 0000000..c6a96b5 --- /dev/null +++ b/StackDuel.Application/TestCaseGeneration/ITestCaseRefreshService.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Application.TestCaseGeneration; + +/// +/// Re-rolls the random hidden test case pool for every eligible problem setup by enqueueing a +/// fresh per setup — the +/// same queue a seeder uses for a problem's first generation. Enqueuing only: the existing +/// Quartz-driven sweep (TestCaseGenerationJobProcessorJob) does the actual Judge0 work in +/// throttled batches, so this stays cheap and safe to run against an arbitrarily large catalog. +/// +public interface ITestCaseRefreshService +{ + Task EnqueueRefreshesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/Tracks/Dtos/TrackDto.cs b/StackDuel.Application/Tracks/Dtos/TrackDto.cs new file mode 100644 index 0000000..fc2078f --- /dev/null +++ b/StackDuel.Application/Tracks/Dtos/TrackDto.cs @@ -0,0 +1,11 @@ +namespace StackDuel.Application.Tracks.Dtos; + +public sealed record TrackLanguageDto(Guid Id, string Name); + +public sealed record TrackDto( + Guid Id, + string Key, + string Name, + bool AllowsLanguageSelection, + IReadOnlyList Languages +); \ No newline at end of file diff --git a/StackDuel.Application/Tracks/ITrackReadRepository.cs b/StackDuel.Application/Tracks/ITrackReadRepository.cs new file mode 100644 index 0000000..76995ab --- /dev/null +++ b/StackDuel.Application/Tracks/ITrackReadRepository.cs @@ -0,0 +1,16 @@ +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Application.Tracks; + +public interface ITrackReadRepository +{ + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task FindByKeyAsync(string key, CancellationToken cancellationToken = default); + + Task> FindByKeysAsync(IEnumerable keys, CancellationToken cancellationToken = default); + + Task> FindByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default); + + Task> GetActiveTracksAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Application/UserContext.cs b/StackDuel.Application/UserContext.cs new file mode 100644 index 0000000..b90009f --- /dev/null +++ b/StackDuel.Application/UserContext.cs @@ -0,0 +1,20 @@ +using StackDuel.Application.Users.Dtos; + +namespace StackDuel.Application; + +public sealed class UserContext +{ + public UserDto? User { get; set; } + + public IReadOnlyList Permissions { get; set; } = []; + + public IReadOnlyList Roles { get; set; } = []; + + public IReadOnlyDictionary Flags { get; set; } = new Dictionary(); + + public bool IsAuthenticated => User is not null; + + public bool HasPermission(string permission) => Permissions.Contains(permission); + + public bool HasFeature(string key) => Flags.TryGetValue(key, out bool enabled) && enabled; +} \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/Admin/AdminUserDetailDto.cs b/StackDuel.Application/Users/Dtos/Admin/AdminUserDetailDto.cs new file mode 100644 index 0000000..b135e79 --- /dev/null +++ b/StackDuel.Application/Users/Dtos/Admin/AdminUserDetailDto.cs @@ -0,0 +1,15 @@ +using StackDuel.Application.Groups.Dtos; + +namespace StackDuel.Application.Users.Dtos.Admin; + +public sealed record AdminUserDetailDto( + Guid Id, + string Username, + string? ImageUrl, + string? Bio, + bool IsPrivate, + DateTime? UsernameLastChangedAt, + DateTime CreatedAt, + DateTime? SetupCompletedAt, + IReadOnlyList Groups +); \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/Admin/AdminUserDto.cs b/StackDuel.Application/Users/Dtos/Admin/AdminUserDto.cs new file mode 100644 index 0000000..c109f10 --- /dev/null +++ b/StackDuel.Application/Users/Dtos/Admin/AdminUserDto.cs @@ -0,0 +1,13 @@ +using StackDuel.Application.Groups.Dtos; + +namespace StackDuel.Application.Users.Dtos.Admin; + +public sealed record AdminUserDto( + Guid Id, + string Username, + string? ImageUrl, + DateTime? UsernameLastChangedAt, + DateTime CreatedAt, + DateTime? SetupCompletedAt, + IReadOnlyList Groups +); \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/UpsertUserDto.cs b/StackDuel.Application/Users/Dtos/UpsertUserDto.cs new file mode 100644 index 0000000..051350d --- /dev/null +++ b/StackDuel.Application/Users/Dtos/UpsertUserDto.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Application.Users.Dtos; + +public sealed record UpsertUserDto( + string? Username, + string? ImageUrl, + string? Bio, + IReadOnlyList? LanguageIds = null, + string? TenantId = null +); \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/UserAccessContextDto.cs b/StackDuel.Application/Users/Dtos/UserAccessContextDto.cs new file mode 100644 index 0000000..100cd22 --- /dev/null +++ b/StackDuel.Application/Users/Dtos/UserAccessContextDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Users.Dtos; + +public sealed record UserAccessContextDto(UserDto User, IReadOnlyList Permissions, IReadOnlyList Roles); \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/UserAvatarDto.cs b/StackDuel.Application/Users/Dtos/UserAvatarDto.cs new file mode 100644 index 0000000..c3b013d --- /dev/null +++ b/StackDuel.Application/Users/Dtos/UserAvatarDto.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Application.Users.Dtos; + +public sealed record UserAvatarDto(Guid Id, string Url, DateTime CreatedAt, bool IsCurrent); \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/UserDto.cs b/StackDuel.Application/Users/Dtos/UserDto.cs new file mode 100644 index 0000000..661a29e --- /dev/null +++ b/StackDuel.Application/Users/Dtos/UserDto.cs @@ -0,0 +1,14 @@ +namespace StackDuel.Application.Users.Dtos; + +public sealed record UserDto( + Guid Id, + string Sub, + string Username, + string? ImageUrl, + string? Bio, + bool IsPrivate, + DateTime? UsernameLastChangedAt, + DateTime CreatedAt, + DateTime? SetupCompletedAt, + IReadOnlyList LanguagePreferenceIds +); \ No newline at end of file diff --git a/StackDuel.Application/Users/Dtos/UserProfileDto.cs b/StackDuel.Application/Users/Dtos/UserProfileDto.cs new file mode 100644 index 0000000..1985db3 --- /dev/null +++ b/StackDuel.Application/Users/Dtos/UserProfileDto.cs @@ -0,0 +1,59 @@ +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Application.Users.Dtos; + +public sealed record GameModeStatDto( + string GameModeKey, + string GameModeName, + bool HasOpponents, + int GamesPlayed, + int Wins, + int Losses, + int Draws, + int BestScore +); + +public sealed record ProfileGameParticipantDto(string Username, string? ImageUrl, int Score); + +public sealed record ProfileGameDto( + Guid GameId, + string GameModeKey, + string GameModeName, + DateTime? EndedAt, + IReadOnlyList Participants +); + +public sealed record ProfileSubmissionDto( + Guid Id, + SubmissionStatus Status, + string ProblemTitle, + string ProblemSlug, + SubmissionLanguageDto Language, + DateTime CreatedAt +); + +public sealed record SubmissionDayCountDto(DateOnly Date, int Count); + +public sealed record UserProfileDto( + Guid Id, + string Username, + string? Bio, + string? ImageUrl, + DateTime CreatedAt, + bool IsPrivate, + bool IsOwnProfile, + IReadOnlyList? GameModeStats, + IReadOnlyList? RecentGames, + IReadOnlyList? RecentSubmissions, + IReadOnlyList? SubmissionCalendar = null, + DateOnly? SubmissionCalendarRangeStart = null, + DateOnly? SubmissionCalendarRangeEnd = null +); + +public sealed record UserGameStatsDto( + Guid Id, + string Username, + bool IsOwnProfile, + IReadOnlyList? GameModeStats +); \ No newline at end of file diff --git a/StackDuel.Application/Users/IUserReadRepository.cs b/StackDuel.Application/Users/IUserReadRepository.cs new file mode 100644 index 0000000..9d68713 --- /dev/null +++ b/StackDuel.Application/Users/IUserReadRepository.cs @@ -0,0 +1,19 @@ +using StackDuel.Application.Pagination; +using StackDuel.Application.Users.Dtos; +using StackDuel.Application.Users.Dtos.Admin; + +namespace StackDuel.Application.Users; + +public interface IUserReadRepository +{ + Task FindBySubAsync(string sub, CancellationToken cancellationToken); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); + Task> FindByIdsAsync(IEnumerable ids, CancellationToken cancellationToken); + Task FindProfileByUsernameAsync(string username, CancellationToken cancellationToken); + Task> GetAdminUsersPageableAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ); + Task FindAdminUserDetailByIdAsync(Guid id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.ArchitectureTests/LayerBoundaryTests.cs b/StackDuel.ArchitectureTests/LayerBoundaryTests.cs new file mode 100644 index 0000000..a150d97 --- /dev/null +++ b/StackDuel.ArchitectureTests/LayerBoundaryTests.cs @@ -0,0 +1,78 @@ +using NetArchTest.Rules; +using StackDuel.Application.Commands; +using StackDuel.Domain.SeedWork; +using StackDuel.Infrastructure; +using System.Reflection; + +namespace StackDuel.ArchitectureTests; + +public class LayerBoundaryTests +{ + private static readonly Assembly DomainAssembly = typeof(Entity).Assembly; + private static readonly Assembly ApplicationAssembly = typeof(ICommand).Assembly; + private static readonly Assembly InfrastructureAssembly = typeof(InfrastructureServiceRegistration).Assembly; + + [Fact] + public void Domain_Should_Not_Depend_On_Application() + { + var result = Types.InAssembly(DomainAssembly).ShouldNot().HaveDependencyOn("StackDuel.Application").GetResult(); + + AssertPasses(result); + } + + [Fact] + public void Domain_Should_Not_Depend_On_Infrastructure() + { + var result = Types.InAssembly(DomainAssembly).ShouldNot().HaveDependencyOn("StackDuel.Infrastructure").GetResult(); + + AssertPasses(result); + } + + [Fact] + public void Domain_Should_Not_Depend_On_Api() + { + var result = Types.InAssembly(DomainAssembly).ShouldNot().HaveDependencyOn("StackDuel.Api").GetResult(); + + AssertPasses(result); + } + + [Fact] + public void Domain_Should_Not_Depend_On_Application_Level_Frameworks() + { + var result = Types.InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOnAny("Mediator", "FluentValidation", "Ardalis.Result", "Microsoft.EntityFrameworkCore") + .GetResult(); + + AssertPasses(result); + } + + [Fact] + public void Application_Should_Not_Depend_On_Infrastructure() + { + var result = Types.InAssembly(ApplicationAssembly) + .ShouldNot() + .HaveDependencyOn("StackDuel.Infrastructure") + .GetResult(); + + AssertPasses(result); + } + + [Fact] + public void Application_Should_Not_Depend_On_Api() + { + var result = Types.InAssembly(ApplicationAssembly).ShouldNot().HaveDependencyOn("StackDuel.Api").GetResult(); + + AssertPasses(result); + } + + [Fact] + public void Infrastructure_Should_Not_Depend_On_Api() + { + var result = Types.InAssembly(InfrastructureAssembly).ShouldNot().HaveDependencyOn("StackDuel.Api").GetResult(); + + AssertPasses(result); + } + + private static void AssertPasses(TestResult result) => Assert.True(result.IsSuccessful); +} \ No newline at end of file diff --git a/StackDuel.ArchitectureTests/StackDuel.ArchitectureTests.csproj b/StackDuel.ArchitectureTests/StackDuel.ArchitectureTests.csproj new file mode 100644 index 0000000..c3e9182 --- /dev/null +++ b/StackDuel.ArchitectureTests/StackDuel.ArchitectureTests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StackDuel.ArchitectureTests/xunit.runner.json b/StackDuel.ArchitectureTests/xunit.runner.json new file mode 100644 index 0000000..a8a4636 --- /dev/null +++ b/StackDuel.ArchitectureTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": true, + "parallelizeTestCollections": true +} diff --git a/StackDuel.Domain.Tests/FeatureFlags/Entities/FeatureFlagTests.cs b/StackDuel.Domain.Tests/FeatureFlags/Entities/FeatureFlagTests.cs new file mode 100644 index 0000000..dc0576b --- /dev/null +++ b/StackDuel.Domain.Tests/FeatureFlags/Entities/FeatureFlagTests.cs @@ -0,0 +1,111 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Domain.Tests.FeatureFlags.Entities; + +public class FeatureFlagTests +{ + private static FeatureFlag CreateFlag(bool defaultEnabled = false) => + FeatureFlag.Create(new FeatureFlagKey("leaderboards"), "Leaderboards", "Global kill-switch.", defaultEnabled); + + [Test] + public void Create_SetsRolloutPercentageToZero() + { + FeatureFlag flag = CreateFlag(); + Assert.That(flag.RolloutPercentage, Is.EqualTo(RolloutPercentage.Zero)); + } + + [Test] + public void Create_SetsCreatedAtAndUpdatedAtToSameInstant() + { + FeatureFlag flag = CreateFlag(); + Assert.That(flag.CreatedAt, Is.EqualTo(flag.UpdatedAt)); + } + + [Test] + public void Create_BlankName_Throws() + { + Assert.Throws(() => + FeatureFlag.Create(new FeatureFlagKey("leaderboards"), " ", "desc", false) + ); + } + + [Test] + public void SetDefaultEnabled_UpdatesValueAndBumpsUpdatedAt() + { + FeatureFlag flag = CreateFlag(defaultEnabled: false); + DateTimeOffset originalUpdatedAt = flag.UpdatedAt; + + flag.SetDefaultEnabled(true); + + Assert.Multiple(() => + { + Assert.That(flag.DefaultEnabled, Is.True); + Assert.That(flag.UpdatedAt, Is.GreaterThanOrEqualTo(originalUpdatedAt)); + }); + } + + [Test] + public void SetUserOverride_NewUser_AddsOverride() + { + FeatureFlag flag = CreateFlag(); + UserId userId = new(Guid.NewGuid()); + + flag.SetUserOverride(userId, DecisionEffect.Allow); + + Assert.That(flag.UserOverrides, Has.Count.EqualTo(1)); + Assert.That(flag.UserOverrides.Single().Effect, Is.EqualTo(DecisionEffect.Allow)); + } + + [Test] + public void SetUserOverride_ExistingUser_UpdatesEffectInPlaceRatherThanDuplicating() + { + FeatureFlag flag = CreateFlag(); + UserId userId = new(Guid.NewGuid()); + + flag.SetUserOverride(userId, DecisionEffect.Allow); + flag.SetUserOverride(userId, DecisionEffect.Deny); + + Assert.That(flag.UserOverrides, Has.Count.EqualTo(1)); + Assert.That(flag.UserOverrides.Single().Effect, Is.EqualTo(DecisionEffect.Deny)); + } + + [Test] + public void RemoveUserOverride_RemovesMatchingOverride() + { + FeatureFlag flag = CreateFlag(); + UserId userId = new(Guid.NewGuid()); + flag.SetUserOverride(userId, DecisionEffect.Allow); + + flag.RemoveUserOverride(userId); + + Assert.That(flag.UserOverrides, Is.Empty); + } + + [Test] + public void SetGroupOverride_ExistingGroup_UpdatesEffectInPlaceRatherThanDuplicating() + { + FeatureFlag flag = CreateFlag(); + GroupId groupId = new(Guid.NewGuid()); + + flag.SetGroupOverride(groupId, DecisionEffect.Deny); + flag.SetGroupOverride(groupId, DecisionEffect.Allow); + + Assert.That(flag.GroupOverrides, Has.Count.EqualTo(1)); + Assert.That(flag.GroupOverrides.Single().Effect, Is.EqualTo(DecisionEffect.Allow)); + } + + [Test] + public void RemoveGroupOverride_RemovesMatchingOverride() + { + FeatureFlag flag = CreateFlag(); + GroupId groupId = new(Guid.NewGuid()); + flag.SetGroupOverride(groupId, DecisionEffect.Allow); + + flag.RemoveGroupOverride(groupId); + + Assert.That(flag.GroupOverrides, Is.Empty); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/FeatureFlags/ValueObjects/FeatureFlagKeyTests.cs b/StackDuel.Domain.Tests/FeatureFlags/ValueObjects/FeatureFlagKeyTests.cs new file mode 100644 index 0000000..366c502 --- /dev/null +++ b/StackDuel.Domain.Tests/FeatureFlags/ValueObjects/FeatureFlagKeyTests.cs @@ -0,0 +1,42 @@ +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Domain.Tests.FeatureFlags.ValueObjects; + +public class FeatureFlagKeyTests +{ + [TestCase("leaderboards")] + [TestCase("solo-rush-canary")] + [TestCase("a")] + [TestCase("a1-b2")] + public void Constructor_ValidKebabCase_Succeeds(string value) + { + Assert.That(new FeatureFlagKey(value).Value, Is.EqualTo(value)); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("Leaderboards")] + [TestCase("leader_boards")] + [TestCase("leader boards")] + [TestCase("-leaderboards")] + [TestCase("leaderboards-")] + [TestCase("leader--boards")] + public void Constructor_InvalidFormat_Throws(string value) + { + Assert.Throws(() => new FeatureFlagKey(value)); + } + + [Test] + public void Constructor_TooLong_Throws() + { + string tooLong = new('a', FeatureFlagKey.MaxLength + 1); + Assert.Throws(() => new FeatureFlagKey(tooLong)); + } + + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string maxLength = new('a', FeatureFlagKey.MaxLength); + Assert.That(new FeatureFlagKey(maxLength).Value, Is.EqualTo(maxLength)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/FeatureFlags/ValueObjects/RolloutPercentageTests.cs b/StackDuel.Domain.Tests/FeatureFlags/ValueObjects/RolloutPercentageTests.cs new file mode 100644 index 0000000..2dc1b58 --- /dev/null +++ b/StackDuel.Domain.Tests/FeatureFlags/ValueObjects/RolloutPercentageTests.cs @@ -0,0 +1,29 @@ +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Domain.Tests.FeatureFlags.ValueObjects; + +public class RolloutPercentageTests +{ + [TestCase(0)] + [TestCase(1)] + [TestCase(50)] + [TestCase(99)] + [TestCase(100)] + public void Constructor_ValueInRange_Succeeds(int value) + { + Assert.That(new RolloutPercentage(value).Value, Is.EqualTo(value)); + } + + [TestCase(-1)] + [TestCase(101)] + public void Constructor_ValueOutOfRange_Throws(int value) + { + Assert.Throws(() => new RolloutPercentage(value)); + } + + [Test] + public void Zero_HasValueZero() + { + Assert.That(RolloutPercentage.Zero.Value, Is.EqualTo(0)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Feedback/Entities/FeedbackSubmissionTests.cs b/StackDuel.Domain.Tests/Feedback/Entities/FeedbackSubmissionTests.cs new file mode 100644 index 0000000..73f5afe --- /dev/null +++ b/StackDuel.Domain.Tests/Feedback/Entities/FeedbackSubmissionTests.cs @@ -0,0 +1,75 @@ +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.Feedback.ValueObjects; + +namespace StackDuel.Domain.Tests.Feedback.Entities; + +public class FeedbackSubmissionTests +{ + private static FeedbackSubmission CreateFeedback( + FeedbackType type = FeedbackType.General, + FeedbackRating? rating = null, + FeedbackContextType contextType = FeedbackContextType.None, + Guid? contextEntityId = null + ) => + new( + Guid.NewGuid(), + type, + new FeedbackMessage("Something is broken."), + rating, + contextType, + contextEntityId, + "https://stackduel.dev/problems/two-sum", + "Mozilla/5.0" + ); + + [Test] + public void Constructor_SetsStatusToNew() + { + var feedback = CreateFeedback(); + Assert.That(feedback.Status, Is.EqualTo(FeedbackStatus.New)); + } + + [Test] + public void Constructor_SetsCreatedAt() + { + var before = DateTime.UtcNow; + var feedback = CreateFeedback(); + var after = DateTime.UtcNow; + + Assert.That(feedback.CreatedAt, Is.InRange(before, after)); + } + + [Test] + public void Constructor_UpdatedAtIsNull() + { + var feedback = CreateFeedback(); + Assert.That(feedback.UpdatedAt, Is.Null); + } + + [Test] + public void UpdateStatus_SetsStatusAndAdminNoteAndUpdatedAt() + { + var feedback = CreateFeedback(); + + feedback.UpdateStatus(FeedbackStatus.Triaged, "Looking into it."); + + Assert.Multiple(() => + { + Assert.That(feedback.Status, Is.EqualTo(FeedbackStatus.Triaged)); + Assert.That(feedback.AdminNote, Is.EqualTo("Looking into it.")); + Assert.That(feedback.UpdatedAt, Is.Not.Null); + }); + } + + [Test] + public void UpdateStatus_NullAdminNote_ClearsAdminNote() + { + var feedback = CreateFeedback(); + feedback.UpdateStatus(FeedbackStatus.Triaged, "Initial note."); + + feedback.UpdateStatus(FeedbackStatus.Resolved, null); + + Assert.That(feedback.AdminNote, Is.Null); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Feedback/ValueObjects/FeedbackMessageTests.cs b/StackDuel.Domain.Tests/Feedback/ValueObjects/FeedbackMessageTests.cs new file mode 100644 index 0000000..755d6ec --- /dev/null +++ b/StackDuel.Domain.Tests/Feedback/ValueObjects/FeedbackMessageTests.cs @@ -0,0 +1,52 @@ +using StackDuel.Domain.Feedback.Exceptions; +using StackDuel.Domain.Feedback.ValueObjects; + +namespace StackDuel.Domain.Tests.Feedback.ValueObjects; + +public class FeedbackMessageTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string atMax = new('a', FeedbackMessage.MaxLength); + Assert.DoesNotThrow(() => new FeedbackMessage(atMax)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidFeedbackMessageException( + [Values("", " ", " ", null)] string? value + ) + { + Assert.Throws(() => new FeedbackMessage(value!)); + } + + [Test] + public void Constructor_OneAboveMaxLength_ThrowsInvalidFeedbackMessageException() + { + string tooLong = new('a', FeedbackMessage.MaxLength + 1); + Assert.Throws(() => new FeedbackMessage(tooLong)); + } + + [Test] + public void Constructor_ValidValue_SetsValue() + { + var message = new FeedbackMessage("The problem statement has a typo."); + Assert.That(message.Value, Is.EqualTo("The problem statement has a typo.")); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var message = new FeedbackMessage("Hello world."); + string value = message; + Assert.That(value, Is.EqualTo("Hello world.")); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new FeedbackMessage("Hello world."); + var b = new FeedbackMessage("Hello world."); + Assert.That(a, Is.EqualTo(b)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Feedback/ValueObjects/FeedbackRatingTests.cs b/StackDuel.Domain.Tests/Feedback/ValueObjects/FeedbackRatingTests.cs new file mode 100644 index 0000000..48590c8 --- /dev/null +++ b/StackDuel.Domain.Tests/Feedback/ValueObjects/FeedbackRatingTests.cs @@ -0,0 +1,33 @@ +using StackDuel.Domain.Feedback.Exceptions; +using StackDuel.Domain.Feedback.ValueObjects; + +namespace StackDuel.Domain.Tests.Feedback.ValueObjects; + +public class FeedbackRatingTests +{ + [Test] + public void Constructor_WithinRange_Succeeds([Values(1, 2, 3, 4, 5)] int value) + { + Assert.DoesNotThrow(() => new FeedbackRating(value)); + } + + [Test] + public void Constructor_BelowMinimum_ThrowsInvalidFeedbackRatingException() + { + Assert.Throws(() => new FeedbackRating(0)); + } + + [Test] + public void Constructor_AboveMaximum_ThrowsInvalidFeedbackRatingException() + { + Assert.Throws(() => new FeedbackRating(6)); + } + + [Test] + public void ImplicitOperator_ReturnsIntValue() + { + var rating = new FeedbackRating(4); + int value = rating; + Assert.That(value, Is.EqualTo(4)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Games/Entities/GameParticipantTests.cs b/StackDuel.Domain.Tests/Games/Entities/GameParticipantTests.cs new file mode 100644 index 0000000..0daedb8 --- /dev/null +++ b/StackDuel.Domain.Tests/Games/Entities/GameParticipantTests.cs @@ -0,0 +1,255 @@ +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Domain.Tests.Games.Entities; + +public class GameParticipantTests +{ + private static GameParticipant CreateParticipantWithProblem(out Guid problemId) + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + return participant; + } + + [Test] + public void Constructor_StartsWithTotalSkipsRemaining() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + Assert.That(participant.SkipsRemaining, Is.EqualTo(GameParticipant.TotalSkips)); + } + + [Test] + public void SkipToProblem_DecrementsSkipsRemaining() + { + var participant = CreateParticipantWithProblem(out _); + + participant.SkipToProblem(Guid.NewGuid()); + + Assert.That(participant.SkipsRemaining, Is.EqualTo(GameParticipant.TotalSkips - 1)); + } + + [Test] + public void SkipToProblem_MovesCurrentProblemToSkipped_NotSolved() + { + var participant = CreateParticipantWithProblem(out Guid problemId); + + participant.SkipToProblem(Guid.NewGuid()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(participant.ProblemSession!.SkippedProblemIds, Does.Contain(problemId)); + Assert.That(participant.ProblemSession.SolvedProblemIds, Does.Not.Contain(problemId)); + } + } + + [Test] + public void SkipToProblem_SetsCurrentProblemToTheNewProblem() + { + var participant = CreateParticipantWithProblem(out _); + Guid nextProblemId = Guid.NewGuid(); + + participant.SkipToProblem(nextProblemId); + + Assert.That(participant.ProblemSession!.CurrentProblemId, Is.EqualTo(nextProblemId)); + } + + [Test] + public void SkipToProblem_DoesNotAffectScore() + { + var participant = CreateParticipantWithProblem(out _); + + participant.SkipToProblem(Guid.NewGuid()); + + Assert.That(participant.Score, Is.EqualTo(0)); + } + + [Test] + public void SkipToProblem_NoSkipsRemaining_Throws() + { + var participant = CreateParticipantWithProblem(out _); + for (int i = 0; i < GameParticipant.TotalSkips; i++) + participant.SkipToProblem(Guid.NewGuid()); + + Assert.Throws(() => participant.SkipToProblem(Guid.NewGuid())); + } + + [Test] + public void SkipToProblem_ProblemSessionNotInitialized_Throws() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + Assert.Throws(() => participant.SkipToProblem(Guid.NewGuid())); + } + + [Test] + public void ExcludedProblemIds_IncludesSkippedProblems_NotJustSolved() + { + var participant = CreateParticipantWithProblem(out Guid firstProblemId); + var secondProblemId = Guid.NewGuid(); + participant.SkipToProblem(secondProblemId); + + Assert.That(participant.ProblemSession!.ExcludedProblemIds, Does.Contain(firstProblemId)); + } + + [Test] + public void ExcludedProblemIds_IncludesTheCurrentProblem() + { + var participant = CreateParticipantWithProblem(out Guid problemId); + + Assert.That(participant.ProblemSession!.ExcludedProblemIds, Does.Contain(problemId)); + } + + [Test] + public void AdvanceProblem_PreservesPreviouslySkippedProblems() + { + var participant = CreateParticipantWithProblem(out _); + var skippedProblemId = Guid.NewGuid(); + participant.SkipToProblem(skippedProblemId); + // Now on skippedProblemId; skip again so it lands in SkippedProblemIds, then solve + // the one after that and confirm the earlier skip survives the AdvanceProblem rebuild. + var solvedProblemId = Guid.NewGuid(); + participant.SkipToProblem(solvedProblemId); + + participant.AdvanceProblem(Guid.NewGuid()); + + Assert.That(participant.ProblemSession!.SkippedProblemIds, Does.Contain(skippedProblemId)); + } + + [Test] + public void SetActiveSubmission_PreservesPreviouslySkippedProblems() + { + var participant = CreateParticipantWithProblem(out Guid skippedProblemId); + participant.SkipToProblem(Guid.NewGuid()); + + participant.SetActiveSubmission(Guid.NewGuid()); + + Assert.That(participant.ProblemSession!.SkippedProblemIds, Does.Contain(skippedProblemId)); + } + + [Test] + public void UseSkip_DecrementsSkipsRemaining() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + participant.UseSkip(); + + Assert.That(participant.SkipsRemaining, Is.EqualTo(GameParticipant.TotalSkips - 1)); + } + + [Test] + public void UseSkip_NoSkipsRemaining_Throws() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + for (int i = 0; i < GameParticipant.TotalSkips; i++) + participant.UseSkip(); + + Assert.Throws(() => participant.UseSkip()); + } + + [Test] + public void PlayState_FreshParticipant_IsInProgress() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + Assert.That(participant.PlayState, Is.EqualTo(ParticipantPlayState.InProgress)); + } + + [Test] + public void PlayState_AfterForfeit_IsForfeited() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + participant.Forfeit(); + + Assert.That(participant.PlayState, Is.EqualTo(ParticipantPlayState.Forfeited)); + } + + [Test] + public void PlayState_AfterFinishProblems_IsFinished() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + participant.FinishProblems(); + + Assert.That(participant.PlayState, Is.EqualTo(ParticipantPlayState.Finished)); + } + + [Test] + [TestCase(ParticipantAction.CompleteProblem)] + [TestCase(ParticipantAction.SkipProblem)] + [TestCase(ParticipantAction.Forfeit)] + public void CanPerform_WhileInProgress_AllowsEveryAction(ParticipantAction action) + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + Assert.That(participant.CanPerform(action), Is.True); + } + + [Test] + [TestCase(ParticipantAction.CompleteProblem)] + [TestCase(ParticipantAction.SkipProblem)] + [TestCase(ParticipantAction.Forfeit)] + public void CanPerform_AfterForfeit_DisallowsEveryAction(ParticipantAction action) + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + participant.Forfeit(); + + Assert.That(participant.CanPerform(action), Is.False); + } + + [Test] + [TestCase(ParticipantAction.CompleteProblem)] + [TestCase(ParticipantAction.SkipProblem)] + [TestCase(ParticipantAction.Forfeit)] + public void CanPerform_AfterFinishProblems_DisallowsEveryAction(ParticipantAction action) + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + participant.FinishProblems(); + + Assert.That(participant.CanPerform(action), Is.False); + } + + [Test] + public void CanAttemptProblem_OnCurrentProblem_ReturnsEligible() + { + var participant = CreateParticipantWithProblem(out Guid problemId); + + var eligibility = participant.CanAttemptProblem(ParticipantAction.CompleteProblem, problemId); + + Assert.That(eligibility, Is.EqualTo(ProblemAttemptEligibility.Eligible)); + } + + [Test] + public void CanAttemptProblem_NoSessionInitialized_ReturnsProblemSessionNotInitialized() + { + var participant = new GameParticipant(Guid.NewGuid(), seatNo: 1); + + var eligibility = participant.CanAttemptProblem(ParticipantAction.CompleteProblem, Guid.NewGuid()); + + Assert.That(eligibility, Is.EqualTo(ProblemAttemptEligibility.ProblemSessionNotInitialized)); + } + + [Test] + public void CanAttemptProblem_DifferentProblemId_ReturnsProblemMismatch() + { + var participant = CreateParticipantWithProblem(out _); + + var eligibility = participant.CanAttemptProblem(ParticipantAction.CompleteProblem, Guid.NewGuid()); + + Assert.That(eligibility, Is.EqualTo(ProblemAttemptEligibility.ProblemMismatch)); + } + + [Test] + public void CanAttemptProblem_AfterForfeit_ReturnsParticipantStopped() + { + var participant = CreateParticipantWithProblem(out Guid problemId); + participant.Forfeit(); + + var eligibility = participant.CanAttemptProblem(ParticipantAction.CompleteProblem, problemId); + + Assert.That(eligibility, Is.EqualTo(ProblemAttemptEligibility.ParticipantStopped)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Games/Entities/GameTests.cs b/StackDuel.Domain.Tests/Games/Entities/GameTests.cs new file mode 100644 index 0000000..62b7714 --- /dev/null +++ b/StackDuel.Domain.Tests/Games/Entities/GameTests.cs @@ -0,0 +1,675 @@ +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Games.Events; +using GameEntity = StackDuel.Domain.Games.Entities.Game; + +namespace StackDuel.Domain.Tests.Games.Entities; + +public class GameTests +{ + private static readonly Guid GameModeId = Guid.NewGuid(); + private static readonly Guid HostId = Guid.NewGuid(); + private static readonly Guid OpponentId = Guid.NewGuid(); + + private static readonly Guid TrackId = Guid.NewGuid(); + + private static GameEntity CreateRunningDuel() + { + GameEntity game = new(GameModeId, Guid.NewGuid(), [TrackId], [HostId], timeLimitInSeconds: 300); + game.Join(OpponentId, maxPlayers: 2); + game.Start(); + game.PopDomainEvents(); + return game; + } + + private static GameEntity CreatePendingLobby(Guid hostId) => + new(GameModeId, Guid.NewGuid(), [TrackId], [hostId], timeLimitInSeconds: 300); + + [Test] + public void Constructor_GeneratesASevenCharacterJoinCode() + { + GameEntity game = CreatePendingLobby(HostId); + + Assert.That(game.JoinCode, Has.Length.EqualTo(7)); + } + + [Test] + public void Constructor_DefaultsSkipsEnabledToTrue() + { + GameEntity game = CreatePendingLobby(HostId); + + Assert.That(game.SkipsEnabled, Is.True); + } + + [Test] + public void Constructor_SkipsEnabledFalse_IsHonored() + { + GameEntity game = new( + GameModeId, + Guid.NewGuid(), + [TrackId], + [HostId], + timeLimitInSeconds: 300, + skipsEnabled: false + ); + + Assert.That(game.SkipsEnabled, Is.False); + } + + [Test] + public void Start_WithCountdownSeconds_DelaysStartedAtByThatMany() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 2); + DateTime beforeStart = DateTime.UtcNow; + + game.Start(countdownSeconds: 6); + + Assert.That(game.StartedAt, Is.GreaterThanOrEqualTo(beforeStart.AddSeconds(6))); + } + + [Test] + public void Start_RaisesGameStartedAndGameLobbyUpdatedDomainEvents() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 2); + game.PopDomainEvents(); + + game.Start(); + + var domainEvents = game.PopDomainEvents(); + Assert.That(domainEvents.OfType().Single().GameId, Is.EqualTo(game.Id)); + Assert.That( + domainEvents.OfType().Single().GameId, + Is.EqualTo(game.Id), + "participants still sitting in the lobby need a push to learn the game just started, " + + "not just the players who joined/left it" + ); + } + + [Test] + public void RecordProblemSolved_IncrementsOnlyThatParticipantsScore() + { + GameEntity game = CreateRunningDuel(); + + game.RecordProblemSolved(OpponentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Participants.Single(p => p.UserId == OpponentId).Score, Is.EqualTo(1)); + Assert.That(game.Participants.Single(p => p.UserId == HostId).Score, Is.EqualTo(0)); + } + } + + [Test] + public void RecordProblemSolved_RaisesGameProgressUpdatedDomainEvent() + { + GameEntity game = CreateRunningDuel(); + + game.RecordProblemSolved(OpponentId); + + var domainEvent = game.PopDomainEvents().OfType().Single(); + Assert.That(domainEvent.GameId, Is.EqualTo(game.Id)); + } + + [Test] + public void RecordProblemSolved_AccumulatesAcrossMultipleCalls() + { + GameEntity game = CreateRunningDuel(); + + game.RecordProblemSolved(OpponentId); + game.RecordProblemSolved(OpponentId); + game.RecordProblemSolved(OpponentId); + + Assert.That(game.Participants.Single(p => p.UserId == OpponentId).Score, Is.EqualTo(3)); + } + + [Test] + public void RecordProblemSolved_GameNotRunning_Throws() + { + GameEntity game = CreatePendingLobby(HostId); + + Assert.Throws(() => game.RecordProblemSolved(HostId)); + } + + [Test] + public void RecordProblemSolved_UnknownUser_Throws() + { + GameEntity game = CreateRunningDuel(); + + Assert.Throws(() => game.RecordProblemSolved(Guid.NewGuid())); + } + + [Test] + public void Forfeit_OneOfTwoParticipants_MarksThatParticipantForfeited_GameStaysRunning() + { + GameEntity game = CreateRunningDuel(); + + game.Forfeit(OpponentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Status, Is.EqualTo(GameStatus.Running)); + Assert.That(game.EndedAt, Is.Null); + Assert.That(game.Participants.Single(p => p.UserId == OpponentId).HasForfeited, Is.True); + Assert.That(game.Participants.Single(p => p.UserId == HostId).HasForfeited, Is.False); + } + } + + [Test] + public void Forfeit_OneOfTwoParticipants_DoesNotRaiseGameCompletedDomainEvent() + { + GameEntity game = CreateRunningDuel(); + + game.Forfeit(OpponentId); + + Assert.That(game.PopDomainEvents(), Is.Empty); + } + + [Test] + public void Forfeit_EveryParticipant_CompletesTheGame() + { + GameEntity game = CreateRunningDuel(); + + game.Forfeit(OpponentId); + game.Forfeit(HostId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Status, Is.EqualTo(GameStatus.Completed)); + Assert.That(game.EndedAt, Is.Not.Null); + } + } + + [Test] + public void Forfeit_EveryParticipant_RaisesGameCompletedDomainEvent() + { + GameEntity game = CreateRunningDuel(); + + game.Forfeit(OpponentId); + game.Forfeit(HostId); + + var domainEvent = game.PopDomainEvents().OfType().Single(); + Assert.That(domainEvent.Status, Is.EqualTo(GameStatus.Completed)); + } + + [Test] + public void Forfeit_GameNotRunning_Throws() + { + GameEntity game = new(GameModeId, Guid.NewGuid(), [TrackId], [HostId], timeLimitInSeconds: 300); + + Assert.Throws(() => game.Forfeit(HostId)); + } + + [Test] + public void Forfeit_UnknownUser_Throws() + { + GameEntity game = CreateRunningDuel(); + + Assert.Throws(() => game.Forfeit(Guid.NewGuid())); + } + + [Test] + public void Forfeit_SameParticipantTwice_Throws() + { + GameEntity game = CreateRunningDuel(); + game.Forfeit(OpponentId); + + Assert.Throws(() => game.Forfeit(OpponentId)); + } + + [Test] + public void Forfeit_ParticipantWhoAlreadyFinishedProblems_Throws() + { + GameEntity game = CreateRunningDuel(); + game.FinishProblemsFor(OpponentId); + + Assert.Throws(() => game.Forfeit(OpponentId)); + } + + [Test] + public void FinishProblemsFor_OneOfTwoParticipants_MarksThatParticipantFinished_GameStaysRunning() + { + GameEntity game = CreateRunningDuel(); + + game.FinishProblemsFor(OpponentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Status, Is.EqualTo(GameStatus.Running)); + Assert.That(game.EndedAt, Is.Null); + Assert.That(game.Participants.Single(p => p.UserId == OpponentId).HasFinishedProblems, Is.True); + Assert.That(game.Participants.Single(p => p.UserId == HostId).HasFinishedProblems, Is.False); + } + } + + [Test] + public void FinishProblemsFor_EveryParticipant_CompletesTheGame() + { + GameEntity game = CreateRunningDuel(); + + game.FinishProblemsFor(OpponentId); + game.FinishProblemsFor(HostId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Status, Is.EqualTo(GameStatus.Completed)); + Assert.That(game.EndedAt, Is.Not.Null); + } + } + + [Test] + public void FinishProblemsFor_EveryParticipant_RaisesGameCompletedDomainEvent() + { + GameEntity game = CreateRunningDuel(); + + game.FinishProblemsFor(OpponentId); + game.FinishProblemsFor(HostId); + + var domainEvent = game.PopDomainEvents().OfType().Single(); + Assert.That(domainEvent.Status, Is.EqualTo(GameStatus.Completed)); + } + + [Test] + public void FinishProblemsFor_MixedWithForfeit_CoveringEveryone_CompletesTheGame() + { + GameEntity game = CreateRunningDuel(); + + game.Forfeit(OpponentId); + game.FinishProblemsFor(HostId); + + Assert.That(game.Status, Is.EqualTo(GameStatus.Completed)); + } + + [Test] + public void FinishProblemsFor_GameNotRunning_Throws() + { + GameEntity game = new(GameModeId, Guid.NewGuid(), [TrackId], [HostId], timeLimitInSeconds: 300); + + Assert.Throws(() => game.FinishProblemsFor(HostId)); + } + + [Test] + public void FinishProblemsFor_UnknownUser_Throws() + { + GameEntity game = CreateRunningDuel(); + + Assert.Throws(() => game.FinishProblemsFor(Guid.NewGuid())); + } + + [Test] + public void FinishProblemsFor_SameParticipantTwice_Throws() + { + GameEntity game = CreateRunningDuel(); + game.FinishProblemsFor(OpponentId); + + Assert.Throws(() => game.FinishProblemsFor(OpponentId)); + } + + [Test] + public void FinishProblemsFor_ParticipantWhoAlreadyForfeited_Throws() + { + GameEntity game = CreateRunningDuel(); + game.Forfeit(OpponentId); + + Assert.Throws(() => game.FinishProblemsFor(OpponentId)); + } + + [Test] + public void CompleteIfEveryoneStopped_NotEveryoneStopped_ReturnsFalse_GameStaysRunning() + { + GameEntity game = CreateRunningDuel(); + game.Forfeit(OpponentId); + + bool completed = game.CompleteIfEveryoneStopped(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(completed, Is.False); + Assert.That(game.Status, Is.EqualTo(GameStatus.Running)); + } + } + + [Test] + public void CompleteIfEveryoneStopped_EveryoneStopped_CompletesGame_AndReturnsTrue() + { + GameEntity game = CreateRunningDuel(); + // Simulates the race this method exists to close: two concurrent Forfeit/FinishProblemsFor + // calls, each reading the game before the other's write lands, so neither one's own + // internal "is everyone done" check ever sees the full picture — mutating the participants + // directly here (bypassing Game.Forfeit/FinishProblemsFor's own auto-complete) reproduces + // that end state: everyone individually stopped, but the game itself never got completed. + game.Participants.Single(p => p.UserId == OpponentId).Forfeit(); + game.Participants.Single(p => p.UserId == HostId).FinishProblems(); + + bool completed = game.CompleteIfEveryoneStopped(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(completed, Is.True); + Assert.That(game.Status, Is.EqualTo(GameStatus.Completed)); + } + } + + [Test] + public void CompleteIfEveryoneStopped_GameNotRunning_ReturnsFalse() + { + GameEntity game = new(GameModeId, Guid.NewGuid(), [TrackId], [HostId], timeLimitInSeconds: 300); + + Assert.That(game.CompleteIfEveryoneStopped(), Is.False); + } + + [Test] + public void HostUserId_IsWhoeverHasTheLowestSeat() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 3); + + Assert.That(game.HostUserId, Is.EqualTo(HostId)); + } + + [Test] + public void Join_RaisesGameLobbyUpdatedDomainEvent() + { + GameEntity game = CreatePendingLobby(HostId); + + game.Join(OpponentId, maxPlayers: 2); + + var domainEvent = game.PopDomainEvents().OfType().Single(); + Assert.That(domainEvent.GameId, Is.EqualTo(game.Id)); + } + + [Test] + public void Leave_WithRemainingParticipants_RaisesGameLobbyUpdatedDomainEvent() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 2); + game.PopDomainEvents(); + + game.Leave(OpponentId); + + var domainEvent = game.PopDomainEvents().OfType().Single(); + Assert.That(domainEvent.GameId, Is.EqualTo(game.Id)); + } + + [Test] + public void Leave_LastParticipant_DoesNotRaiseGameLobbyUpdatedDomainEvent() + { + GameEntity game = CreatePendingLobby(HostId); + + game.Leave(HostId); + + Assert.That(game.PopDomainEvents().OfType(), Is.Empty); + } + + [Test] + public void Leave_NonHostParticipant_RemovesThem_HostUnchanged() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 2); + + game.Leave(OpponentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Participants.Select(p => p.UserId), Does.Not.Contain(OpponentId)); + Assert.That(game.HostUserId, Is.EqualTo(HostId)); + Assert.That(game.Status, Is.EqualTo(GameStatus.Pending)); + } + } + + [Test] + public void Leave_HostParticipant_HandsHostToNextLowestSeat_WithoutRenumbering() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 3); + Guid thirdPlayerId = Guid.NewGuid(); + game.Join(thirdPlayerId, maxPlayers: 3); + + game.Leave(HostId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.HostUserId, Is.EqualTo(OpponentId)); + Assert.That( + game.Participants.Single(p => p.UserId == OpponentId).SeatNo, + Is.EqualTo(2), + "seats are never renumbered — the new host keeps the seat they already had" + ); + } + } + + [Test] + public void Leave_LastParticipant_CancelsTheGame() + { + GameEntity game = CreatePendingLobby(HostId); + + game.Leave(HostId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Status, Is.EqualTo(GameStatus.Cancelled)); + Assert.That(game.Participants, Is.Empty); + Assert.That(game.HostUserId, Is.Null); + } + } + + [Test] + public void Join_AfterHostLeaves_AssignsASeatThatDoesNotCollide() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 3); + // OpponentId is seat 2. HostId (seat 1) leaves, freeing seat 1 — but it must not be + // reissued, since seat 2 is still taken and _participants.Count + 1 would collide with it. + game.Leave(HostId); + + Guid thirdPlayerId = Guid.NewGuid(); + game.Join(thirdPlayerId, maxPlayers: 3); + + var seats = game.Participants.Select(p => p.SeatNo).ToList(); + using (Assert.EnterMultipleScope()) + { + Assert.That(seats, Is.EquivalentTo(new[] { 2, 3 })); + Assert.That(seats, Is.Unique); + } + } + + [Test] + public void Leave_RunningGame_Throws() + { + GameEntity game = CreateRunningDuel(); + + Assert.Throws(() => game.Leave(HostId)); + } + + [Test] + public void Leave_UnknownUser_Throws() + { + GameEntity game = CreatePendingLobby(HostId); + + Assert.Throws(() => game.Leave(Guid.NewGuid())); + } + + [Test] + public void CloseLobby_CancelsTheGame_EvenWithParticipantsRemaining() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 2); + + game.CloseLobby(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(game.Status, Is.EqualTo(GameStatus.Cancelled)); + Assert.That(game.EndedAt, Is.Not.Null); + Assert.That( + game.Participants, + Has.Count.EqualTo(2), + "closing doesn't clear the roster, just ends the game" + ); + } + } + + [Test] + public void CloseLobby_RaisesGameLobbyUpdatedDomainEvent() + { + GameEntity game = CreatePendingLobby(HostId); + game.Join(OpponentId, maxPlayers: 2); + game.PopDomainEvents(); + + game.CloseLobby(); + + var domainEvent = game.PopDomainEvents().OfType().Single(); + Assert.That(domainEvent.GameId, Is.EqualTo(game.Id)); + } + + [Test] + public void CloseLobby_RunningGame_Throws() + { + GameEntity game = CreateRunningDuel(); + + Assert.Throws(() => game.CloseLobby()); + } + + [Test] + public void CloseLobby_AlreadyClosed_Throws() + { + GameEntity game = CreatePendingLobby(HostId); + game.CloseLobby(); + + Assert.Throws(() => game.CloseLobby()); + } + + [Test] + public void ProblemIdAtPosition_NothingAppendedYet_ReturnsNull() + { + GameEntity game = CreatePendingLobby(HostId); + + Assert.That(game.ProblemIdAtPosition(0), Is.Null); + } + + [Test] + public void AppendProblem_FirstCall_OccupiesPositionZero() + { + GameEntity game = CreatePendingLobby(HostId); + var problemId = Guid.NewGuid(); + + game.AppendProblem(problemId); + + Assert.That(game.ProblemIdAtPosition(0), Is.EqualTo(problemId)); + } + + [Test] + public void AppendProblem_SecondCall_OccupiesTheNextPosition() + { + GameEntity game = CreatePendingLobby(HostId); + var firstProblemId = Guid.NewGuid(); + var secondProblemId = Guid.NewGuid(); + + game.AppendProblem(firstProblemId); + game.AppendProblem(secondProblemId); + + Assert.Multiple(() => + { + Assert.That(game.ProblemIdAtPosition(0), Is.EqualTo(firstProblemId)); + Assert.That(game.ProblemIdAtPosition(1), Is.EqualTo(secondProblemId)); + }); + } + + [Test] + public void AppendProblem_EmptyGuid_Throws() + { + GameEntity game = CreatePendingLobby(HostId); + + Assert.Throws(() => game.AppendProblem(Guid.Empty)); + } + + [Test] + public void EvaluateProblemAttempt_UserNotParticipant_ReturnsParticipantNotFound() + { + GameEntity game = CreateRunningDuel(); + + var attempt = game.EvaluateProblemAttempt( + Guid.NewGuid(), + Guid.NewGuid(), + ParticipantAction.CompleteProblem, + DateTime.UtcNow + ); + + Assert.That(attempt.Eligibility, Is.EqualTo(ProblemAttemptEligibility.ParticipantNotFound)); + } + + [Test] + public void EvaluateProblemAttempt_GameNotRunning_ReturnsGameNotRunning() + { + GameEntity game = CreatePendingLobby(HostId); + + var attempt = game.EvaluateProblemAttempt( + HostId, + Guid.NewGuid(), + ParticipantAction.CompleteProblem, + DateTime.UtcNow + ); + + Assert.That(attempt.Eligibility, Is.EqualTo(ProblemAttemptEligibility.GameNotRunning)); + } + + [Test] + public void EvaluateProblemAttempt_ParticipantOnCurrentProblem_ReturnsEligible() + { + GameEntity game = CreateRunningDuel(); + var participant = game.Participants.First(p => p.UserId == HostId); + var problemId = Guid.NewGuid(); + participant.InitializeProblemSession(problemId); + + var attempt = game.EvaluateProblemAttempt( + HostId, + problemId, + ParticipantAction.CompleteProblem, + DateTime.UtcNow + ); + + Assert.Multiple(() => + { + Assert.That(attempt.IsEligible, Is.True); + Assert.That(attempt.Participant, Is.SameAs(participant)); + }); + } + + [Test] + public void EvaluateProblemAttempt_ProblemIdDoesNotMatchCurrentProblem_ReturnsProblemMismatch() + { + GameEntity game = CreateRunningDuel(); + var participant = game.Participants.First(p => p.UserId == HostId); + participant.InitializeProblemSession(Guid.NewGuid()); + + var attempt = game.EvaluateProblemAttempt( + HostId, + Guid.NewGuid(), + ParticipantAction.CompleteProblem, + DateTime.UtcNow + ); + + Assert.That(attempt.Eligibility, Is.EqualTo(ProblemAttemptEligibility.ProblemMismatch)); + } + + [Test] + public void EvaluateProblemAttempt_GameClockHasElapsed_ReportsGameJustExpiredAndGameNotRunning() + { + GameEntity game = CreateRunningDuel(); + DateTime pastExpiry = game.StartedAt!.Value.AddSeconds(301); + + var attempt = game.EvaluateProblemAttempt( + HostId, + Guid.NewGuid(), + ParticipantAction.CompleteProblem, + pastExpiry + ); + + Assert.Multiple(() => + { + Assert.That(attempt.GameJustExpired, Is.True); + Assert.That(attempt.Eligibility, Is.EqualTo(ProblemAttemptEligibility.GameNotRunning)); + Assert.That(game.Status, Is.EqualTo(GameStatus.Completed)); + }); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Games/Entities/LeaderboardTests.cs b/StackDuel.Domain.Tests/Games/Entities/LeaderboardTests.cs new file mode 100644 index 0000000..8680c66 --- /dev/null +++ b/StackDuel.Domain.Tests/Games/Entities/LeaderboardTests.cs @@ -0,0 +1,82 @@ +using LeaderboardEntity = StackDuel.Domain.Games.Entities.Leaderboard; + +namespace StackDuel.Domain.Tests.Games.Entities; + +public class LeaderboardTests +{ + private static readonly Guid GameModeId = Guid.NewGuid(); + private static readonly Guid UserId = Guid.NewGuid(); + + private static LeaderboardEntity CreateLeaderboard() => new(GameModeId, timeLimitInSeconds: 300); + + [Test] + public void RecordScore_AddsNewParticipant_WhenUserHasNoExistingEntry() + { + LeaderboardEntity leaderboard = CreateLeaderboard(); + + leaderboard.RecordScore(UserId, 10); + + Assert.That(leaderboard.Rankings.Single().UserId, Is.EqualTo(UserId)); + Assert.That(leaderboard.Rankings.Single().HighScore, Is.EqualTo(10)); + } + + [Test] + public void RecordScore_UpdatesHighScore_WhenNewScoreIsGreater() + { + LeaderboardEntity leaderboard = CreateLeaderboard(); + leaderboard.RecordScore(UserId, 10); + + leaderboard.RecordScore(UserId, 15); + + Assert.That(leaderboard.Rankings.Single().HighScore, Is.EqualTo(15)); + } + + [Test] + public void RecordScore_KeepsExistingHighScore_WhenNewScoreIsLower() + { + LeaderboardEntity leaderboard = CreateLeaderboard(); + leaderboard.RecordScore(UserId, 15); + + leaderboard.RecordScore(UserId, 10); + + Assert.That(leaderboard.Rankings.Single().HighScore, Is.EqualTo(15)); + } + + [Test] + public void RecordScore_DoesNotDuplicateParticipant_WhenCalledMultipleTimesForSameUser() + { + LeaderboardEntity leaderboard = CreateLeaderboard(); + + leaderboard.RecordScore(UserId, 5); + leaderboard.RecordScore(UserId, 20); + + Assert.That(leaderboard.Rankings, Has.Count.EqualTo(1)); + } + + [Test] + public void RecordScore_ThrowsArgumentException_WhenUserIdIsEmpty() + { + LeaderboardEntity leaderboard = CreateLeaderboard(); + + Assert.Throws(() => leaderboard.RecordScore(Guid.Empty, 10)); + } + + [Test] + public void Rankings_OrdersParticipantsByHighScoreDescending() + { + LeaderboardEntity leaderboard = CreateLeaderboard(); + Guid lowScorer = Guid.NewGuid(); + Guid highScorer = Guid.NewGuid(); + + leaderboard.RecordScore(lowScorer, 5); + leaderboard.RecordScore(highScorer, 25); + + Assert.That(leaderboard.Rankings.Select(p => p.UserId), Is.EqualTo(new[] { highScorer, lowScorer })); + } + + [Test] + public void Constructor_ThrowsArgumentException_WhenTimeLimitIsNotPositive() + { + Assert.Throws(() => new LeaderboardEntity(GameModeId, timeLimitInSeconds: 0)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Language/Entities/LanguageTests.cs b/StackDuel.Domain.Tests/Language/Entities/LanguageTests.cs new file mode 100644 index 0000000..3091690 --- /dev/null +++ b/StackDuel.Domain.Tests/Language/Entities/LanguageTests.cs @@ -0,0 +1,149 @@ +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Languages.Exceptions; +using StackDuel.Domain.Languages.ValueObjects; +using LanguageEntity = StackDuel.Domain.Languages.Entities.Language; + +namespace StackDuel.Domain.Tests.Language.Entities; + +public class LanguageTests +{ + private static readonly LanguageName ValidName = new("Python"); + private static readonly LanguageSlug ValidSlug = new("python"); + private static readonly LanguageVersion ValidVersion = new("3.11"); + private static readonly Judge0Id ValidJudge0Id = new(109); + + private static LanguageEntity CreateLanguage() => new(ValidName, ValidSlug, Guid.NewGuid()); + + [Test] + public void Activate_WhenInactive_SetsStatusToActive() + { + var language = CreateLanguage(); + language.Deactivate(); + + language.Activate(); + + Assert.That(language.Status, Is.EqualTo(LanguageStatus.Active)); + } + + [Test] + public void AddVersion_AddsToVersionsCollection() + { + var language = CreateLanguage(); + + language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(language.Versions, Has.Count.EqualTo(1)); + } + + [Test] + public void AddVersion_MultipleVersions_AllAdded() + { + var language = CreateLanguage(); + + language.AddVersion(new LanguageVersion("3.10"), new Judge0Id(100)); + language.AddVersion(new LanguageVersion("3.11"), new Judge0Id(109)); + + Assert.That(language.Versions, Has.Count.EqualTo(2)); + } + + [Test] + public void AddVersion_ReturnsEntryWithActiveStatus() + { + var language = CreateLanguage(); + + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + using (Assert.EnterMultipleScope()) + { + Assert.That(entry.IsActive, Is.True); + Assert.That(entry.Version, Is.EqualTo(ValidVersion)); + Assert.That(entry.Judge0Id, Is.EqualTo(ValidJudge0Id)); + } + } + + [Test] + public void Constructor_SetsNameAndSlug() + { + var language = CreateLanguage(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(language.Name, Is.EqualTo(ValidName)); + Assert.That(language.Slug, Is.EqualTo(ValidSlug)); + } + } + + [Test] + public void Constructor_SetsStatusToActive() + { + var language = CreateLanguage(); + + Assert.That(language.Status, Is.EqualTo(LanguageStatus.Active)); + } + + [Test] + public void Constructor_VersionsIsEmpty() + { + var language = CreateLanguage(); + + Assert.That(language.Versions, Is.Empty); + } + + [Test] + public void Deactivate_SetsStatusToInactive() + { + var language = CreateLanguage(); + + language.Deactivate(); + + Assert.That(language.Status, Is.EqualTo(LanguageStatus.Inactive)); + } + + [Test] + public void DeprecateVersion_DoesNotRemoveVersion() + { + var language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + language.DeprecateVersion(entry.Id); + + Assert.That(language.Versions, Has.Count.EqualTo(1)); + } + + [Test] + public void DeprecateVersion_SetsVersionToDeprecated() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + language.DeprecateVersion(entry.Id); + + Assert.That(entry.Status, Is.EqualTo(LanguageVersionStatus.Deprecated)); + } + + [Test] + public void DeprecateVersion_UnknownVersionId_ThrowsLanguageVersionNotFoundException() + { + var language = CreateLanguage(); + + Assert.Throws(() => language.DeprecateVersion(Guid.NewGuid())); + } + + [Test] + public void IsActive_WhenActive_IsTrue() + { + var language = CreateLanguage(); + + Assert.That(language.IsActive, Is.True); + } + + [Test] + public void IsActive_WhenInactive_IsFalse() + { + var language = CreateLanguage(); + language.Deactivate(); + + Assert.That(language.IsActive, Is.False); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Language/Entities/LanguageVersionEntryTests.cs b/StackDuel.Domain.Tests/Language/Entities/LanguageVersionEntryTests.cs new file mode 100644 index 0000000..6d51e9f --- /dev/null +++ b/StackDuel.Domain.Tests/Language/Entities/LanguageVersionEntryTests.cs @@ -0,0 +1,65 @@ +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Languages.ValueObjects; +using LanguageEntity = StackDuel.Domain.Languages.Entities.Language; + +namespace StackDuel.Domain.Tests.Language.Entities; + +public class LanguageVersionEntryTests +{ + private static readonly LanguageName ValidName = new("Python"); + private static readonly LanguageSlug ValidSlug = new("python"); + private static readonly LanguageVersion ValidVersion = new("3.11"); + private static readonly Judge0Id ValidJudge0Id = new(109); + + private static LanguageEntity CreateLanguage() => new(ValidName, ValidSlug, Guid.NewGuid()); + + [Test] + public void Deprecate_SetsIsActiveToFalse() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + entry.Deprecate(); + + Assert.That(entry.IsActive, Is.False); + } + + [Test] + public void Deprecate_SetsStatusToDeprecated() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + entry.Deprecate(); + + Assert.That(entry.Status, Is.EqualTo(LanguageVersionStatus.Deprecated)); + } + + [Test] + public void InitialStatus_IsActive() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(entry.Status, Is.EqualTo(LanguageVersionStatus.Active)); + } + + [Test] + public void IsActive_WhenActive_IsTrue() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(entry.IsActive, Is.True); + } + + [Test] + public void Version_SetCorrectly() + { + LanguageEntity language = CreateLanguage(); + LanguageVersionEntry entry = language.AddVersion(ValidVersion, ValidJudge0Id); + + Assert.That(entry.Version, Is.EqualTo(ValidVersion)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Language/ValueObjects/LanguageNameTests.cs b/StackDuel.Domain.Tests/Language/ValueObjects/LanguageNameTests.cs new file mode 100644 index 0000000..02a9379 --- /dev/null +++ b/StackDuel.Domain.Tests/Language/ValueObjects/LanguageNameTests.cs @@ -0,0 +1,77 @@ +using StackDuel.Domain.Languages.Exceptions; +using StackDuel.Domain.Languages.ValueObjects; + +namespace StackDuel.Domain.Tests.Language.ValueObjects; + +public class LanguageNameTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', LanguageName.MaxLength); + + Assert.That(() => new LanguageName(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + Assert.That(() => new LanguageName("C"), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidLanguageNameException() + { + Assert.Throws(() => new LanguageName(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidLanguageNameException() + { + string value = new('a', LanguageName.MaxLength + 1); + + Assert.Throws(() => new LanguageName(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidLanguageNameException() + { + Assert.Throws(() => new LanguageName(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new LanguageName("Python"); + var b = new LanguageName("JavaScript"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new LanguageName("Python"); + var b = new LanguageName("Python"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var name = new LanguageName("Python"); + + string result = name; + + Assert.That(result, Is.EqualTo("Python")); + } + + [Test] + public void ToString_ReturnsValue() + { + var name = new LanguageName("Python"); + + Assert.That(name.ToString(), Is.EqualTo("Python")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Language/ValueObjects/LanguageSlugTests.cs b/StackDuel.Domain.Tests/Language/ValueObjects/LanguageSlugTests.cs new file mode 100644 index 0000000..e3eca66 --- /dev/null +++ b/StackDuel.Domain.Tests/Language/ValueObjects/LanguageSlugTests.cs @@ -0,0 +1,127 @@ +using StackDuel.Domain.Languages.Exceptions; +using StackDuel.Domain.Languages.ValueObjects; + +namespace StackDuel.Domain.Tests.Language.ValueObjects; + +public class LanguageSlugTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', LanguageSlug.MaxLength); + + Assert.That(() => new LanguageSlug(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + Assert.That(() => new LanguageSlug("c"), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidLanguageSlugException() + { + Assert.Throws(() => new LanguageSlug(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidLanguageSlugException() + { + string value = new('a', LanguageSlug.MaxLength + 1); + + Assert.Throws(() => new LanguageSlug(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidLanguageSlugException() + { + Assert.Throws(() => new LanguageSlug(" ")); + } + + [TestCase("Python")] + [TestCase("PYTHON")] + [TestCase("-python")] + [TestCase("python-")] + [TestCase("python--311")] + [TestCase("python 311")] + public void Constructor_InvalidFormat_ThrowsInvalidLanguageSlugException(string value) + { + Assert.Throws(() => new LanguageSlug(value)); + } + + [TestCase("python")] + [TestCase("cpp")] + [TestCase("python-311")] + [TestCase("c")] + public void Constructor_ValidFormat_Succeeds(string value) + { + Assert.That(() => new LanguageSlug(value), Throws.Nothing); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new LanguageSlug("python"); + var b = new LanguageSlug("cpp"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new LanguageSlug("python"); + var b = new LanguageSlug("python"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void FromName_CollapseMultipleSpaces() + { + var name = new LanguageName("C Sharp"); + + var slug = LanguageSlug.FromName(name); + + Assert.That(slug.Value, Is.EqualTo("c-sharp")); + } + + [Test] + public void FromName_GeneratesValidSlug() + { + var name = new LanguageName("Python"); + + var slug = LanguageSlug.FromName(name); + + Assert.That(slug.Value, Is.EqualTo("python")); + } + + [Test] + public void FromName_StripsSpecialCharacters() + { + var name = new LanguageName("C++"); + + var slug = LanguageSlug.FromName(name); + + Assert.That(slug.Value, Is.EqualTo("c")); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var slug = new LanguageSlug("python"); + + string result = slug; + + Assert.That(result, Is.EqualTo("python")); + } + + [Test] + public void ToString_ReturnsValue() + { + var slug = new LanguageSlug("python"); + + Assert.That(slug.ToString(), Is.EqualTo("python")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Language/ValueObjects/LanguageVersionTests.cs b/StackDuel.Domain.Tests/Language/ValueObjects/LanguageVersionTests.cs new file mode 100644 index 0000000..2199c06 --- /dev/null +++ b/StackDuel.Domain.Tests/Language/ValueObjects/LanguageVersionTests.cs @@ -0,0 +1,71 @@ +using StackDuel.Domain.Languages.Exceptions; +using StackDuel.Domain.Languages.ValueObjects; + +namespace StackDuel.Domain.Tests.Language.ValueObjects; + +public class LanguageVersionTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', LanguageVersion.MaxLength); + + Assert.That(() => new LanguageVersion(value), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidLanguageVersionException() + { + Assert.Throws(() => new LanguageVersion(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidLanguageVersionException() + { + string value = new('a', LanguageVersion.MaxLength + 1); + + Assert.Throws(() => new LanguageVersion(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidLanguageVersionException() + { + Assert.Throws(() => new LanguageVersion(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new LanguageVersion("3.11"); + var b = new LanguageVersion("3.12"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new LanguageVersion("3.11"); + var b = new LanguageVersion("3.11"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var version = new LanguageVersion("3.11"); + + string result = version; + + Assert.That(result, Is.EqualTo("3.11")); + } + + [Test] + public void ToString_ReturnsValue() + { + var version = new LanguageVersion("3.11"); + + Assert.That(version.ToString(), Is.EqualTo("3.11")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/Entities/ProblemPoolTests.cs b/StackDuel.Domain.Tests/Problem/Entities/ProblemPoolTests.cs new file mode 100644 index 0000000..dda3b8f --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/Entities/ProblemPoolTests.cs @@ -0,0 +1,188 @@ +using ProblemPoolEntity = StackDuel.Domain.Problems.Entities.ProblemPool; + +namespace StackDuel.Domain.Tests.Problem.Entities; + +public class ProblemPoolTests +{ + private static ProblemPoolEntity CreatePool() => new("all-problems", "All Problems", "Every published problem."); + + [Test] + public void Constructor_NormalizesKeyToLowercase() + { + ProblemPoolEntity pool = new(" Game-Rotation ", "Game Rotation"); + + Assert.That(pool.Key, Is.EqualTo("game-rotation")); + } + + [Test] + public void Constructor_TrimsName() + { + ProblemPoolEntity pool = new("featured", " Featured "); + + Assert.That(pool.Name, Is.EqualTo("Featured")); + } + + [Test] + public void Constructor_WithNullDescription_LeavesDescriptionNull() + { + ProblemPoolEntity pool = new("featured", "Featured"); + + Assert.That(pool.Description, Is.Null); + } + + [Test] + public void Constructor_WithEmptyKey_Throws() + { + Assert.Throws(() => new ProblemPoolEntity(" ", "Featured")); + } + + [Test] + public void Constructor_WithEmptyName_Throws() + { + Assert.Throws(() => new ProblemPoolEntity("featured", " ")); + } + + [Test] + public void Constructor_StartsWithNoProblems() + { + ProblemPoolEntity pool = CreatePool(); + + Assert.That(pool.ProblemIds, Is.Empty); + } + + [Test] + public void AddProblem_AddsProblemId() + { + ProblemPoolEntity pool = CreatePool(); + Guid problemId = Guid.NewGuid(); + + pool.AddProblem(problemId); + + Assert.That(pool.ProblemIds, Does.Contain(problemId)); + } + + [Test] + public void AddProblem_CalledTwiceWithSameId_IsIdempotent() + { + ProblemPoolEntity pool = CreatePool(); + Guid problemId = Guid.NewGuid(); + + pool.AddProblem(problemId); + pool.AddProblem(problemId); + + Assert.That(pool.ProblemIds.Count(id => id == problemId), Is.EqualTo(1)); + } + + [Test] + public void AddProblem_WithEmptyGuid_Throws() + { + ProblemPoolEntity pool = CreatePool(); + + Assert.Throws(() => pool.AddProblem(Guid.Empty)); + } + + [Test] + public void RemoveProblem_RemovesExistingMember() + { + ProblemPoolEntity pool = CreatePool(); + Guid problemId = Guid.NewGuid(); + pool.AddProblem(problemId); + + pool.RemoveProblem(problemId); + + Assert.That(pool.ProblemIds, Does.Not.Contain(problemId)); + } + + [Test] + public void RemoveProblem_WhenNotAMember_DoesNotThrow() + { + ProblemPoolEntity pool = CreatePool(); + + Assert.DoesNotThrow(() => pool.RemoveProblem(Guid.NewGuid())); + } + + [Test] + public void AddProblem_AssignsIncreasingPositions() + { + ProblemPoolEntity pool = CreatePool(); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + Guid third = Guid.NewGuid(); + + pool.AddProblem(first); + pool.AddProblem(second); + pool.AddProblem(third); + + Assert.That(pool.ProblemIds, Is.EqualTo(new[] { first, second, third })); + } + + [Test] + public void Reorder_ReassignsPositionsToMatchGivenOrder() + { + ProblemPoolEntity pool = CreatePool(); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + Guid third = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(second); + pool.AddProblem(third); + + pool.Reorder([third, first, second]); + + Assert.That(pool.ProblemIds, Is.EqualTo(new[] { third, first, second })); + } + + [Test] + public void Reorder_MissingAMember_Throws() + { + ProblemPoolEntity pool = CreatePool(); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(second); + + Assert.Throws(() => pool.Reorder([first])); + } + + [Test] + public void Reorder_WithUnknownId_Throws() + { + ProblemPoolEntity pool = CreatePool(); + Guid member = Guid.NewGuid(); + pool.AddProblem(member); + + Assert.Throws(() => pool.Reorder([Guid.NewGuid()])); + } + + [Test] + public void Reorder_WithDuplicateId_Throws() + { + ProblemPoolEntity pool = CreatePool(); + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + pool.AddProblem(first); + pool.AddProblem(second); + + Assert.Throws(() => pool.Reorder([first, first])); + } + + [Test] + public void Rename_UpdatesName() + { + ProblemPoolEntity pool = CreatePool(); + + pool.Rename("Renamed Pool"); + + Assert.That(pool.Name, Is.EqualTo("Renamed Pool")); + } + + [Test] + public void UpdateDescription_WithWhitespace_ClearsDescription() + { + ProblemPoolEntity pool = CreatePool(); + + pool.UpdateDescription(" "); + + Assert.That(pool.Description, Is.Null); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/Entities/ProblemTests.cs b/StackDuel.Domain.Tests/Problem/Entities/ProblemTests.cs new file mode 100644 index 0000000..2334656 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/Entities/ProblemTests.cs @@ -0,0 +1,181 @@ +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.ValueObjects; +using ProblemEntity = StackDuel.Domain.Problems.Entities.Problem; + +namespace StackDuel.Domain.Tests.Problem.Entities; + +public class ProblemTests +{ + private static readonly Slug ValidSlug = new("two-sum"); + private static readonly Title ValidTitle = new("Two Sum"); + private static readonly Question ValidQuestion = new(new string('a', Question.MinLength)); + private static readonly Difficulty ValidDifficulty = new(500); + private static readonly TimeLimit ValidTimeLimit = new(1000); + private static readonly MemoryLimit ValidMemoryLimit = new(64); + + private static ProblemEntity CreateProblem() => + new(ValidSlug, ValidTitle, ValidQuestion, ValidDifficulty, ValidTimeLimit, ValidMemoryLimit); + + [Test] + public void Constructor_SetsSlug() + { + ProblemEntity problem = CreateProblem(); + + Assert.That(problem.Slug, Is.EqualTo(ValidSlug)); + } + + [Test] + public void Constructor_SetsContentFields() + { + ProblemEntity problem = CreateProblem(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(problem.Title, Is.EqualTo(ValidTitle)); + Assert.That(problem.Question, Is.EqualTo(ValidQuestion)); + Assert.That(problem.Difficulty, Is.EqualTo(ValidDifficulty)); + Assert.That(problem.TimeLimit, Is.EqualTo(ValidTimeLimit)); + Assert.That(problem.MemoryLimit, Is.EqualTo(ValidMemoryLimit)); + } + } + + [Test] + public void Constructor_SetsStatusToDraft() + { + ProblemEntity problem = CreateProblem(); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Draft)); + } + + [Test] + public void Constructor_SetsCreatedAt() + { + DateTime before = DateTime.UtcNow; + ProblemEntity problem = CreateProblem(); + + Assert.That(problem.CreatedAt, Is.GreaterThanOrEqualTo(before)); + } + + [Test] + public void Archive_SetsStatusToArchived() + { + ProblemEntity problem = CreateProblem(); + + problem.Archive(); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Archived)); + } + + [Test] + public void Publish_SetsStatusToPublished() + { + ProblemEntity problem = CreateProblem(); + + problem.Publish(); + + Assert.That(problem.Status, Is.EqualTo(ProblemStatus.Published)); + } + + [Test] + public void UpdateContent_UpdatesAllContentFields() + { + ProblemEntity problem = CreateProblem(); + Title newTitle = new("Three Sum"); + Question newQuestion = new(new string('b', Question.MinLength)); + Difficulty newDifficulty = new(1500); + TimeLimit newTimeLimit = new(2000); + MemoryLimit newMemoryLimit = new(128); + + problem.UpdateContent(newTitle, newQuestion, newDifficulty, newTimeLimit, newMemoryLimit); + + using (Assert.EnterMultipleScope()) + { + Assert.That(problem.Title, Is.EqualTo(newTitle)); + Assert.That(problem.Question, Is.EqualTo(newQuestion)); + Assert.That(problem.Difficulty, Is.EqualTo(newDifficulty)); + Assert.That(problem.TimeLimit, Is.EqualTo(newTimeLimit)); + Assert.That(problem.MemoryLimit, Is.EqualTo(newMemoryLimit)); + } + } + + [Test] + public void UpdateContent_AddsHistoryEntry() + { + ProblemEntity problem = CreateProblem(); + + problem.UpdateContent( + new Title("Three Sum"), + new Question(new string('b', Question.MinLength)), + new Difficulty(1500), + new TimeLimit(2000), + new MemoryLimit(128) + ); + + Assert.That(problem.History, Has.Count.EqualTo(1)); + } + + [Test] + public void UpdateContent_MultipleUpdates_AddsMultipleHistoryEntries() + { + ProblemEntity problem = CreateProblem(); + + problem.UpdateContent( + new Title("Three Sum"), + new Question(new string('b', Question.MinLength)), + new Difficulty(1500), + new TimeLimit(2000), + new MemoryLimit(128) + ); + problem.UpdateContent( + new Title("Four Sum"), + new Question(new string('c', Question.MinLength)), + new Difficulty(2500), + new TimeLimit(3000), + new MemoryLimit(256) + ); + + Assert.That(problem.History, Has.Count.EqualTo(2)); + } + + [Test] + public void UpdateSlug_ChangesSlug() + { + ProblemEntity problem = CreateProblem(); + Slug newSlug = new("three-sum"); + + problem.UpdateSlug(newSlug); + + Assert.That(problem.Slug, Is.EqualTo(newSlug)); + } + + [Test] + public void AddSetup_AddsToSetups() + { + ProblemEntity problem = CreateProblem(); + + problem.AddSetup(Guid.NewGuid(), "def twoSum():", "twoSum", Guid.NewGuid()); + + Assert.That(problem.Setups, Has.Count.EqualTo(1)); + } + + [Test] + public void AddSetup_SetsProperties() + { + ProblemEntity problem = CreateProblem(); + Guid langVersionId = Guid.NewGuid(); + + StackDuel.Domain.Problems.Entities.ProblemSetup setup = problem.AddSetup( + langVersionId, + "def twoSum():", + "twoSum", + Guid.NewGuid() + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That(setup.LanguageVersionId, Is.EqualTo(langVersionId)); + Assert.That(setup.InitialCode, Is.EqualTo("def twoSum():")); + Assert.That(setup.FunctionName, Is.EqualTo("twoSum")); + } + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/ValueObjects/DifficultyTests.cs b/StackDuel.Domain.Tests/Problem/ValueObjects/DifficultyTests.cs new file mode 100644 index 0000000..a4bbe85 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/ValueObjects/DifficultyTests.cs @@ -0,0 +1,69 @@ +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Domain.Tests.Problem.ValueObjects; + +public class DifficultyTests +{ + [Test] + public void Constructor_AtMinValue_Succeeds() + { + var difficulty = new Difficulty(Difficulty.MinValue); + + Assert.That(difficulty.Value, Is.EqualTo(Difficulty.MinValue)); + } + + [Test] + public void Constructor_BelowMinValue_ThrowsInvalidDifficultyException() + { + Assert.Throws(() => new Difficulty(Difficulty.MinValue - 1)); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Difficulty(100); + var b = new Difficulty(200); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Difficulty(500); + var b = new Difficulty(500); + + Assert.That(a, Is.EqualTo(b)); + } + + [TestCase(0, DifficultyTier.Beginner)] + [TestCase(100, DifficultyTier.Beginner)] + [TestCase(200, DifficultyTier.Beginner)] + [TestCase(201, DifficultyTier.Easy)] + [TestCase(350, DifficultyTier.Easy)] + [TestCase(500, DifficultyTier.Easy)] + [TestCase(501, DifficultyTier.Intermediate)] + [TestCase(750, DifficultyTier.Intermediate)] + [TestCase(1000, DifficultyTier.Intermediate)] + [TestCase(1001, DifficultyTier.Advanced)] + [TestCase(1500, DifficultyTier.Advanced)] + [TestCase(2000, DifficultyTier.Advanced)] + [TestCase(2001, DifficultyTier.Expert)] + [TestCase(3000, DifficultyTier.Expert)] + public void Tier_ReturnsCorrectTierForValue(int value, DifficultyTier expectedTier) + { + var difficulty = new Difficulty(value); + + Assert.That(difficulty.Tier, Is.EqualTo(expectedTier)); + } + + [Test] + public void ToString_IncludesValueAndTier() + { + var difficulty = new Difficulty(100); + + Assert.That(difficulty.ToString(), Does.Contain("100").And.Contain("Beginner")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/ValueObjects/MemoryLimitTests.cs b/StackDuel.Domain.Tests/Problem/ValueObjects/MemoryLimitTests.cs new file mode 100644 index 0000000..adf4ec4 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/ValueObjects/MemoryLimitTests.cs @@ -0,0 +1,57 @@ +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Domain.Tests.Problem.ValueObjects; + +public class MemoryLimitTests +{ + [Test] + public void Constructor_AtMaxMegabytes_Succeeds() + { + Assert.That(() => new MemoryLimit(MemoryLimit.MaxMegabytes), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinMegabytes_Succeeds() + { + Assert.That(() => new MemoryLimit(MemoryLimit.MinMegabytes), Throws.Nothing); + } + + [Test] + public void Constructor_AboveMaxMegabytes_ThrowsInvalidMemoryLimitException() + { + Assert.Throws(() => new MemoryLimit(MemoryLimit.MaxMegabytes + 1)); + } + + [Test] + public void Constructor_BelowMinMegabytes_ThrowsInvalidMemoryLimitException() + { + Assert.Throws(() => new MemoryLimit(MemoryLimit.MinMegabytes - 1)); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new MemoryLimit(64); + var b = new MemoryLimit(128); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new MemoryLimit(64); + var b = new MemoryLimit(64); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ToString_IncludesMegabytes() + { + var memoryLimit = new MemoryLimit(64); + + Assert.That(memoryLimit.ToString(), Is.EqualTo("64MB")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/ValueObjects/QuestionTests.cs b/StackDuel.Domain.Tests/Problem/ValueObjects/QuestionTests.cs new file mode 100644 index 0000000..4ee13e0 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/ValueObjects/QuestionTests.cs @@ -0,0 +1,87 @@ +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Domain.Tests.Problem.ValueObjects; + +public class QuestionTests +{ + private static string ValidQuestion => new('a', Question.MinLength); + + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', Question.MaxLength); + + Assert.That(() => new Question(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + Assert.That(() => new Question(ValidQuestion), Throws.Nothing); + } + + [Test] + public void Constructor_BelowMinLength_ThrowsInvalidQuestionException() + { + string value = new('a', Question.MinLength - 1); + + Assert.Throws(() => new Question(value)); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidQuestionException() + { + Assert.Throws(() => new Question(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidQuestionException() + { + string value = new('a', Question.MaxLength + 1); + + Assert.Throws(() => new Question(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidQuestionException() + { + Assert.Throws(() => new Question(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Question(new string('a', Question.MinLength)); + var b = new Question(new string('b', Question.MinLength)); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Question(ValidQuestion); + var b = new Question(ValidQuestion); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var question = new Question(ValidQuestion); + + string result = question; + + Assert.That(result, Is.EqualTo(ValidQuestion)); + } + + [Test] + public void ToString_ReturnsValue() + { + var question = new Question(ValidQuestion); + + Assert.That(question.ToString(), Is.EqualTo(ValidQuestion)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/ValueObjects/SlugTests.cs b/StackDuel.Domain.Tests/Problem/ValueObjects/SlugTests.cs new file mode 100644 index 0000000..58f3203 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/ValueObjects/SlugTests.cs @@ -0,0 +1,139 @@ +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Domain.Tests.Problem.ValueObjects; + +public class SlugTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string repeated = string.Concat(Enumerable.Repeat("a", Slug.MaxLength)); + + Assert.That(() => new Slug(repeated), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + string value = new('a', Slug.MinLength); + + Assert.That(() => new Slug(value), Throws.Nothing); + } + + [Test] + public void Constructor_BelowMinLength_ThrowsInvalidSlugException() + { + string value = new('a', Slug.MinLength - 1); + + Assert.Throws(() => new Slug(value)); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidSlugException() + { + Assert.Throws(() => new Slug(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidSlugException() + { + string value = new('a', Slug.MaxLength + 1); + + Assert.Throws(() => new Slug(value)); + } + + [Test] + [TestCase("Two-Sum")] + [TestCase("TWOSUM")] + [TestCase("-two-sum")] + [TestCase("two-sum-")] + [TestCase("two--sum")] + [TestCase("two sum")] + public void Constructor_InvalidFormat_ThrowsInvalidSlugException(string value) + { + Assert.Throws(() => new Slug(value)); + } + + [Test] + [TestCase("two-sum")] + [TestCase("twosum")] + [TestCase("two-sum-123")] + [TestCase("abc")] + public void Constructor_ValidFormat_Succeeds(string value) + { + Assert.That(() => new Slug(value), Throws.Nothing); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidSlugException() + { + Assert.Throws(() => new Slug(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Slug("two-sum"); + var b = new Slug("three-sum"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Slug("two-sum"); + var b = new Slug("two-sum"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void FromTitle_GeneratesValidSlug() + { + var title = new Title("Two Sum"); + + var slug = Slug.FromTitle(title); + + Assert.That(slug.Value, Is.EqualTo("two-sum")); + } + + [Test] + public void FromTitle_StripsSpecialCharacters() + { + var title = new Title("Two Sum!"); + + var slug = Slug.FromTitle(title); + + Assert.That(slug.Value, Is.EqualTo("two-sum")); + } + + [Test] + public void FromTitle_CollapseMultipleSpaces() + { + var title = new Title("Two Sum"); + + var slug = Slug.FromTitle(title); + + Assert.That(slug.Value, Is.EqualTo("two-sum")); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var slug = new Slug("two-sum"); + + string result = slug; + + Assert.That(result, Is.EqualTo("two-sum")); + } + + [Test] + public void ToString_ReturnsValue() + { + var slug = new Slug("two-sum"); + + Assert.That(slug.ToString(), Is.EqualTo("two-sum")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/ValueObjects/TimeLimitTests.cs b/StackDuel.Domain.Tests/Problem/ValueObjects/TimeLimitTests.cs new file mode 100644 index 0000000..acc0358 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/ValueObjects/TimeLimitTests.cs @@ -0,0 +1,57 @@ +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Domain.Tests.Problem.ValueObjects; + +public class TimeLimitTests +{ + [Test] + public void Constructor_AtMaxMilliseconds_Succeeds() + { + Assert.That(() => new TimeLimit(TimeLimit.MaxMilliseconds), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinMilliseconds_Succeeds() + { + Assert.That(() => new TimeLimit(TimeLimit.MinMilliseconds), Throws.Nothing); + } + + [Test] + public void Constructor_AboveMaxMilliseconds_ThrowsInvalidTimeLimitException() + { + Assert.Throws(() => new TimeLimit(TimeLimit.MaxMilliseconds + 1)); + } + + [Test] + public void Constructor_BelowMinMilliseconds_ThrowsInvalidTimeLimitException() + { + Assert.Throws(() => new TimeLimit(TimeLimit.MinMilliseconds - 1)); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new TimeLimit(1000); + var b = new TimeLimit(2000); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new TimeLimit(1000); + var b = new TimeLimit(1000); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ToString_IncludesMilliseconds() + { + var timeLimit = new TimeLimit(1000); + + Assert.That(timeLimit.ToString(), Is.EqualTo("1000ms")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Problem/ValueObjects/TitleTests.cs b/StackDuel.Domain.Tests/Problem/ValueObjects/TitleTests.cs new file mode 100644 index 0000000..27311b0 --- /dev/null +++ b/StackDuel.Domain.Tests/Problem/ValueObjects/TitleTests.cs @@ -0,0 +1,87 @@ +using StackDuel.Domain.Problems.Exceptions; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Domain.Tests.Problem.ValueObjects; + +public class TitleTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', Title.MaxLength); + + Assert.That(() => new Title(value), Throws.Nothing); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + string value = new('a', Title.MinLength); + + Assert.That(() => new Title(value), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidTitleException() + { + Assert.Throws(() => new Title(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidTitleException() + { + string value = new('a', Title.MaxLength + 1); + + Assert.Throws(() => new Title(value)); + } + + [Test] + public void Constructor_BelowMinLength_ThrowsInvalidTitleException() + { + string value = new('a', Title.MinLength - 1); + + Assert.Throws(() => new Title(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidTitleException() + { + Assert.Throws(() => new Title(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new Title("Two Sum"); + var b = new Title("Three Sum"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Title("Two Sum"); + var b = new Title("Two Sum"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var title = new Title("Two Sum"); + + string result = title; + + Assert.That(result, Is.EqualTo("Two Sum")); + } + + [Test] + public void ToString_ReturnsValue() + { + var title = new Title("Two Sum"); + + Assert.That(title.ToString(), Is.EqualTo("Two Sum")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/StackDuel.Domain.Tests.csproj b/StackDuel.Domain.Tests/StackDuel.Domain.Tests.csproj index edb299f..92dc0e5 100644 --- a/StackDuel.Domain.Tests/StackDuel.Domain.Tests.csproj +++ b/StackDuel.Domain.Tests/StackDuel.Domain.Tests.csproj @@ -1,7 +1,7 @@  - net10.0 + latest enable enable false @@ -10,12 +10,16 @@ - - + + + - + - \ No newline at end of file + + + + diff --git a/StackDuel.Domain.Tests/Submissions/Entities/SubmissionResultTests.cs b/StackDuel.Domain.Tests/Submissions/Entities/SubmissionResultTests.cs new file mode 100644 index 0000000..4603eb1 --- /dev/null +++ b/StackDuel.Domain.Tests/Submissions/Entities/SubmissionResultTests.cs @@ -0,0 +1,134 @@ +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.ValueObjects; + +namespace StackDuel.Domain.Tests.Submissions.Entities; + +public class SubmissionResultTests +{ + private static readonly SourceCode ValidSourceCode = new("int main() {}"); + + private static Submission CreateSubmission(params Guid[] testCaseIds) => + new(Guid.NewGuid(), Guid.NewGuid(), SubmissionType.Submit, ValidSourceCode, testCaseIds); + + [Test] + public void InitialStatus_IsPending() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + var result = submission.Results.First(); + + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Pending)); + } + + [Test] + public void IsTerminal_WhenPending_IsFalse() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + Assert.That(submission.Results.First().IsTerminal, Is.False); + } + + [Test] + public void IsTerminal_WhenProcessing_IsFalse() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + submission.UpdateResult(testCaseId, SubmissionResultStatus.Processing); + + Assert.That(submission.Results.First().IsTerminal, Is.False); + } + + [TestCase(SubmissionResultStatus.Accepted)] + [TestCase(SubmissionResultStatus.WrongAnswer)] + [TestCase(SubmissionResultStatus.TimeLimitExceeded)] + [TestCase(SubmissionResultStatus.MemoryLimitExceeded)] + [TestCase(SubmissionResultStatus.RuntimeError)] + [TestCase(SubmissionResultStatus.CompileError)] + public void IsTerminal_WhenTerminalStatus_IsTrue(SubmissionResultStatus status) + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + submission.UpdateResult(testCaseId, status); + + Assert.That(submission.Results.First().IsTerminal, Is.True); + } + + [Test] + public void Update_SetsAllOutputFields() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + submission.UpdateResult( + testCaseId, + SubmissionResultStatus.WrongAnswer, + runtime: 150, + memoryUsed: 64, + actualOutput: "wrong", + standardOutput: "stdout", + standardError: "stderr", + compileOutput: "compile" + ); + + var result = submission.Results.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.WrongAnswer)); + Assert.That(result.Runtime, Is.EqualTo(150)); + Assert.That(result.MemoryUsed, Is.EqualTo(64)); + Assert.That(result.ActualOutput, Is.EqualTo("wrong")); + Assert.That(result.StandardOutput, Is.EqualTo("stdout")); + Assert.That(result.StandardError, Is.EqualTo("stderr")); + Assert.That(result.CompileOutput, Is.EqualTo("compile")); + } + } + + [Test] + public void Update_NullableFields_DefaultToNull() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + submission.UpdateResult(testCaseId, SubmissionResultStatus.Accepted); + + var result = submission.Results.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Runtime, Is.Null); + Assert.That(result.MemoryUsed, Is.Null); + Assert.That(result.ActualOutput, Is.Null); + Assert.That(result.StandardOutput, Is.Null); + Assert.That(result.StandardError, Is.Null); + Assert.That(result.CompileOutput, Is.Null); + } + } + + [Test] + public void Update_CalledTwice_OverwritesPreviousValues() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + submission.UpdateResult(testCaseId, SubmissionResultStatus.Processing, runtime: 100); + + submission.UpdateResult(testCaseId, SubmissionResultStatus.Accepted, runtime: 200); + + var result = submission.Results.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + Assert.That(result.Runtime, Is.EqualTo(200)); + } + } + + [Test] + public void TestCaseId_SetCorrectly() + { + var testCaseId = Guid.NewGuid(); + var submission = CreateSubmission(testCaseId); + + Assert.That(submission.Results.First().TestCaseId, Is.EqualTo(testCaseId)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Submissions/Entities/SubmissionTests.cs b/StackDuel.Domain.Tests/Submissions/Entities/SubmissionTests.cs new file mode 100644 index 0000000..5d55afd --- /dev/null +++ b/StackDuel.Domain.Tests/Submissions/Entities/SubmissionTests.cs @@ -0,0 +1,236 @@ +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.Events; +using StackDuel.Domain.Submissions.Exceptions; +using StackDuel.Domain.Submissions.ValueObjects; + +namespace StackDuel.Domain.Tests.Submissions.Entities; + +public class SubmissionTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly Guid ProblemSetupId = Guid.NewGuid(); + private static readonly SourceCode ValidSourceCode = new("int main() {}"); + private static readonly Guid TestCaseId1 = Guid.NewGuid(); + private static readonly Guid TestCaseId2 = Guid.NewGuid(); + + private static Submission CreateSubmission( + SubmissionType type = SubmissionType.Submit, + IEnumerable? testCaseIds = null, + Guid? gameId = null + ) => new(UserId, ProblemSetupId, type, ValidSourceCode, testCaseIds ?? [TestCaseId1, TestCaseId2], gameId: gameId); + + [Test] + public void Complete_AllAccepted_SetsStatusToAccepted() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.Accepted); + + submission.Complete(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.Accepted)); + } + + [Test] + public void Complete_AnyNonAccepted_SetsStatusToWrongAnswer() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.WrongAnswer); + + submission.Complete(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.WrongAnswer)); + } + + [TestCase(SubmissionResultStatus.TimeLimitExceeded)] + [TestCase(SubmissionResultStatus.MemoryLimitExceeded)] + [TestCase(SubmissionResultStatus.RuntimeError)] + [TestCase(SubmissionResultStatus.CompileError)] + public void Complete_AnyFailureStatus_SetsStatusToWrongAnswer(SubmissionResultStatus failureStatus) + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, failureStatus); + + submission.Complete(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.WrongAnswer)); + } + + [Test] + public void Complete_RaisesSubmissionCompletedDomainEventWithStatusAndGameId() + { + var gameId = Guid.NewGuid(); + var submission = CreateSubmission(gameId: gameId); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.Accepted); + submission.PopDomainEvents(); + + submission.Complete(); + + var domainEvent = submission.PopDomainEvents().OfType().Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(domainEvent.UserId, Is.EqualTo(UserId)); + Assert.That(domainEvent.SubmissionId, Is.EqualTo(submission.Id)); + Assert.That(domainEvent.Status, Is.EqualTo(SubmissionStatus.Accepted)); + Assert.That(domainEvent.GameId, Is.EqualTo(gameId)); + } + } + + [Test] + public void Fail_RaisesSubmissionCompletedDomainEventWithWrongAnswerAndGameId() + { + var gameId = Guid.NewGuid(); + var submission = CreateSubmission(gameId: gameId); + submission.PopDomainEvents(); + + submission.Fail(); + + var domainEvent = submission.PopDomainEvents().OfType().Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(domainEvent.Status, Is.EqualTo(SubmissionStatus.WrongAnswer)); + Assert.That(domainEvent.GameId, Is.EqualTo(gameId)); + } + } + + [Test] + public void Complete_WithPendingResult_ThrowsSubmissionNotCompleteException() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + + Assert.Throws(() => submission.Complete()); + } + + [Test] + public void Complete_WithProcessingResult_ThrowsSubmissionNotCompleteException() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + submission.UpdateResult(TestCaseId2, SubmissionResultStatus.Processing); + + Assert.Throws(() => submission.Complete()); + } + + [Test] + public void Constructor_SetsInitialProperties() + { + var submission = CreateSubmission(SubmissionType.Run); + + using (Assert.EnterMultipleScope()) + { + Assert.That(submission.UserId, Is.EqualTo(UserId)); + Assert.That(submission.ProblemSetupId, Is.EqualTo(ProblemSetupId)); + Assert.That(submission.Type, Is.EqualTo(SubmissionType.Run)); + Assert.That(submission.SourceCode, Is.EqualTo(ValidSourceCode)); + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.Queued)); + Assert.That(submission.GameId, Is.Null); + } + } + + [Test] + public void Constructor_WithGameId_SetsGameId() + { + var gameId = Guid.NewGuid(); + + var submission = CreateSubmission(gameId: gameId); + + Assert.That(submission.GameId, Is.EqualTo(gameId)); + } + + [Test] + public void Constructor_CreatesResultPerTestCaseId() + { + var submission = CreateSubmission(); + + Assert.That(submission.Results, Has.Count.EqualTo(2)); + } + + [Test] + public void Constructor_AllResultsInitiallyPending() + { + var submission = CreateSubmission(); + + Assert.That(submission.Results.All(r => r.Status == SubmissionResultStatus.Pending), Is.True); + } + + [Test] + public void Constructor_WithNoTestCases_ResultsIsEmpty() + { + var submission = CreateSubmission(testCaseIds: []); + + Assert.That(submission.Results, Is.Empty); + } + + [Test] + public void StartRunning_FromQueued_SetsStatusToRunning() + { + var submission = CreateSubmission(); + + submission.StartRunning(); + + Assert.That(submission.Status, Is.EqualTo(SubmissionStatus.Running)); + } + + [Test] + public void StartRunning_WhenAlreadyRunning_ThrowsInvalidSubmissionStateException() + { + var submission = CreateSubmission(); + submission.StartRunning(); + + Assert.Throws(() => submission.StartRunning()); + } + + [Test] + public void UpdateResult_UnknownTestCaseId_ThrowsSubmissionResultNotFoundException() + { + var submission = CreateSubmission(); + + Assert.Throws(() => + submission.UpdateResult(Guid.NewGuid(), SubmissionResultStatus.Accepted) + ); + } + + [Test] + public void UpdateResult_UpdatesMatchingResult() + { + var submission = CreateSubmission(); + + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted, runtime: 100, memoryUsed: 32); + + var result = submission.Results.First(r => r.TestCaseId == TestCaseId1); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + Assert.That(result.Runtime, Is.EqualTo(100)); + Assert.That(result.MemoryUsed, Is.EqualTo(32)); + } + } + + [Test] + public void UpdateResult_Overwrite_UpdatesExistingResult() + { + var submission = CreateSubmission(); + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Processing); + + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted, runtime: 50); + + var result = submission.Results.First(r => r.TestCaseId == TestCaseId1); + Assert.That(result.Status, Is.EqualTo(SubmissionResultStatus.Accepted)); + } + + [Test] + public void UpdateResult_DoesNotAffectOtherResults() + { + var submission = CreateSubmission(); + + submission.UpdateResult(TestCaseId1, SubmissionResultStatus.Accepted); + + var other = submission.Results.First(r => r.TestCaseId == TestCaseId2); + Assert.That(other.Status, Is.EqualTo(SubmissionResultStatus.Pending)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/Submissions/ValueObjects/SourceCodeTests.cs b/StackDuel.Domain.Tests/Submissions/ValueObjects/SourceCodeTests.cs new file mode 100644 index 0000000..67137ab --- /dev/null +++ b/StackDuel.Domain.Tests/Submissions/ValueObjects/SourceCodeTests.cs @@ -0,0 +1,71 @@ +using StackDuel.Domain.Submissions.Exceptions; +using StackDuel.Domain.Submissions.ValueObjects; + +namespace StackDuel.Domain.Tests.Submissions.ValueObjects; + +public class SourceCodeTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string value = new('a', SourceCode.MaxLength); + + Assert.That(() => new SourceCode(value), Throws.Nothing); + } + + [Test] + public void Constructor_EmptyString_ThrowsInvalidSourceCodeException() + { + Assert.Throws(() => new SourceCode(string.Empty)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidSourceCodeException() + { + string value = new('a', SourceCode.MaxLength + 1); + + Assert.Throws(() => new SourceCode(value)); + } + + [Test] + public void Constructor_WhitespaceOnly_ThrowsInvalidSourceCodeException() + { + Assert.Throws(() => new SourceCode(" ")); + } + + [Test] + public void Equality_DifferentValues_AreNotEqual() + { + var a = new SourceCode("int main() {}"); + var b = new SourceCode("def solve(): pass"); + + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new SourceCode("int main() {}"); + var b = new SourceCode("int main() {}"); + + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitConversion_ReturnsValue() + { + var code = new SourceCode("int main() {}"); + + string result = code; + + Assert.That(result, Is.EqualTo("int main() {}")); + } + + [Test] + public void ToString_ReturnsValue() + { + var code = new SourceCode("int main() {}"); + + Assert.That(code.ToString(), Is.EqualTo("int main() {}")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/TestCaseGeneration/Entities/TestCaseGenerationJobTests.cs b/StackDuel.Domain.Tests/TestCaseGeneration/Entities/TestCaseGenerationJobTests.cs new file mode 100644 index 0000000..4fc0954 --- /dev/null +++ b/StackDuel.Domain.Tests/TestCaseGeneration/Entities/TestCaseGenerationJobTests.cs @@ -0,0 +1,77 @@ +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.Enums; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Domain.Tests.TestCaseGeneration.Entities; + +public class TestCaseGenerationJobTests +{ + private static readonly GenerationParameterSpec[] ValidParameters = + [ + new("n", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + ]; + + private static TestCaseGenerationJob CreateJob() => + new(Guid.NewGuid(), "def f(n): return n", ValidParameters, "integer", targetCaseCount: 20, seed: 42); + + [Test] + public void Constructor_ValidInputs_StartsPending() + { + var job = CreateJob(); + + Assert.Multiple(() => + { + Assert.That(job.Status, Is.EqualTo(TestCaseGenerationJobStatus.Pending)); + Assert.That(job.TargetCaseCount, Is.EqualTo(20)); + Assert.That(job.CompletedAt, Is.Null); + Assert.That(job.ResultSummary, Is.Null); + Assert.That(job.FailureReason, Is.Null); + }); + } + + [Test] + public void Complete_SetsStatusAndSummary() + { + var job = CreateJob(); + + job.Complete("Generated 20/20 (0 attempts skipped, 0 warnings)."); + + Assert.Multiple(() => + { + Assert.That(job.Status, Is.EqualTo(TestCaseGenerationJobStatus.Completed)); + Assert.That(job.ResultSummary, Is.EqualTo("Generated 20/20 (0 attempts skipped, 0 warnings).")); + Assert.That(job.CompletedAt, Is.Not.Null); + }); + } + + [Test] + public void Fail_SetsStatusAndReason() + { + var job = CreateJob(); + + job.Fail("Reference solution failed the sanity check."); + + Assert.Multiple(() => + { + Assert.That(job.Status, Is.EqualTo(TestCaseGenerationJobStatus.Failed)); + Assert.That(job.FailureReason, Is.EqualTo("Reference solution failed the sanity check.")); + Assert.That(job.CompletedAt, Is.Not.Null); + }); + } + + [Test] + public void Constructor_NoParameters_Throws() + { + Assert.Throws(() => + new TestCaseGenerationJob(Guid.NewGuid(), "code", [], "integer", 20, 42) + ); + } + + [Test] + public void Constructor_NonPositiveTargetCaseCount_Throws() + { + Assert.Throws(() => + new TestCaseGenerationJob(Guid.NewGuid(), "code", ValidParameters, "integer", 0, 42) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/TestCaseGeneration/Entities/TestCaseGenerationSpecTests.cs b/StackDuel.Domain.Tests/TestCaseGeneration/Entities/TestCaseGenerationSpecTests.cs new file mode 100644 index 0000000..dd9d982 --- /dev/null +++ b/StackDuel.Domain.Tests/TestCaseGeneration/Entities/TestCaseGenerationSpecTests.cs @@ -0,0 +1,52 @@ +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Domain.Tests.TestCaseGeneration.Entities; + +public class TestCaseGenerationSpecTests +{ + private static readonly GenerationParameterSpec[] ValidParameters = + [ + new("nums", "integer_array", Min: -10, Max: 10, LengthMin: 2, LengthMax: 6, Charset: null), + new("target", "integer", Min: -10, Max: 10, LengthMin: null, LengthMax: null, Charset: null), + ]; + + private static TestCaseGenerationSpec CreateSpec( + IReadOnlyList? parameters = null, + int targetCaseCount = 30, + int version = 1 + ) => new(Guid.NewGuid(), parameters ?? ValidParameters, "integer_array", targetCaseCount, seed: 123, version); + + [Test] + public void Constructor_ValidInputs_SetsProperties() + { + var spec = CreateSpec(); + + Assert.Multiple(() => + { + Assert.That(spec.Parameters, Has.Count.EqualTo(2)); + Assert.That(spec.OutputValueType, Is.EqualTo("integer_array")); + Assert.That(spec.TargetCaseCount, Is.EqualTo(30)); + Assert.That(spec.Seed, Is.EqualTo(123)); + Assert.That(spec.Version, Is.EqualTo(1)); + }); + } + + [Test] + public void Constructor_NoParameters_Throws() + { + Assert.Throws(() => CreateSpec(parameters: [])); + } + + [Test] + public void Constructor_NonPositiveTargetCaseCount_Throws() + { + Assert.Throws(() => CreateSpec(targetCaseCount: 0)); + } + + [Test] + public void Constructor_NonPositiveVersion_Throws() + { + Assert.Throws(() => CreateSpec(version: 0)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/TestSuites/Entities/TestCaseTests.cs b/StackDuel.Domain.Tests/TestSuites/Entities/TestCaseTests.cs new file mode 100644 index 0000000..9b47f19 --- /dev/null +++ b/StackDuel.Domain.Tests/TestSuites/Entities/TestCaseTests.cs @@ -0,0 +1,84 @@ +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Domain.Tests.TestSuites.Entities; + +public class TestCaseTests +{ + [Test] + public void AddTestCase_DefaultsToAuthoredSource() + { + var suite = new TestSuite("Suite", TestSuiteType.Hidden); + + var testCase = suite.AddTestCase("Case 1"); + + Assert.That(testCase.Source, Is.EqualTo(TestCaseSource.Authored)); + Assert.That(testCase.GenerationSpecId, Is.Null); + Assert.That(testCase.GenerationCaseIndex, Is.Null); + } + + [Test] + public void AddInput_AssignsPositionsInCallOrder() + { + var suite = new TestSuite("Suite", TestSuiteType.Hidden); + var testCase = suite.AddTestCase("Case 1"); + + var nums = testCase.AddInput("[2,7,11,15]", "integer_array"); + var target = testCase.AddInput("9", "integer"); + + Assert.That(nums.Position, Is.EqualTo(0)); + Assert.That(target.Position, Is.EqualTo(1)); + } + + [Test] + public void AddGeneratedTestCase_SetsGeneratedSourceAndRecipe() + { + var suite = new TestSuite("Suite", TestSuiteType.Hidden); + Guid specId = Guid.NewGuid(); + + var testCase = suite.AddGeneratedTestCase("Generated 3", specId, 3); + + Assert.That(testCase.Source, Is.EqualTo(TestCaseSource.Generated)); + Assert.That(testCase.GenerationSpecId, Is.EqualTo(specId)); + Assert.That(testCase.GenerationCaseIndex, Is.EqualTo(3)); + Assert.That(testCase.Inputs, Is.Empty, "generated cases store a recipe, not materialized inputs"); + } + + [Test] + public void ClearGeneratedTestCases_RemovesOnlyGeneratedCases() + { + var suite = new TestSuite("Suite", TestSuiteType.Hidden); + suite.AddTestCase("Authored 1"); + suite.AddGeneratedTestCase("Generated 1", Guid.NewGuid(), 0); + suite.AddGeneratedTestCase("Generated 2", Guid.NewGuid(), 1); + + suite.ClearGeneratedTestCases(); + + Assert.That(suite.TestCases, Has.Count.EqualTo(1)); + Assert.That(suite.TestCases.Single().Source, Is.EqualTo(TestCaseSource.Authored)); + } + + [Test] + public void Retire_SetsRetiredAt() + { + var suite = new TestSuite("Suite", TestSuiteType.Hidden); + var testCase = suite.AddGeneratedTestCase("Generated 1", Guid.NewGuid(), 0); + + testCase.Retire(); + + Assert.That(testCase.RetiredAt, Is.Not.Null); + } + + [Test] + public void Retire_CalledTwice_KeepsOriginalTimestamp() + { + var suite = new TestSuite("Suite", TestSuiteType.Hidden); + var testCase = suite.AddGeneratedTestCase("Generated 1", Guid.NewGuid(), 0); + + testCase.Retire(); + DateTime? firstRetiredAt = testCase.RetiredAt; + testCase.Retire(); + + Assert.That(testCase.RetiredAt, Is.EqualTo(firstRetiredAt)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/UnitTest1.cs b/StackDuel.Domain.Tests/UnitTest1.cs deleted file mode 100644 index 5e2c749..0000000 --- a/StackDuel.Domain.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StackDuel.Domain.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/User/Entities/UserTests.cs b/StackDuel.Domain.Tests/User/Entities/UserTests.cs new file mode 100644 index 0000000..2a06509 --- /dev/null +++ b/StackDuel.Domain.Tests/User/Entities/UserTests.cs @@ -0,0 +1,243 @@ +using StackDuel.Domain.Users.Exceptions; +using StackDuel.Domain.Users.ValueObjects; +using UserEntity = StackDuel.Domain.Users.Entities.User; + +namespace StackDuel.Domain.Tests.User.Entities; + +public class UserTests +{ + private static readonly Username ValidUsername = new("alice"); + private const string ValidSub = "auth0|abc123"; + + [Test] + public void ChangeUsername_DoesNotAffectOtherProperties() + { + var user = new UserEntity(ValidUsername, ValidSub); + var originalId = user.Id; + string originalSub = user.Sub; + + user.ChangeUsername(new Username("bob")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.Id, Is.EqualTo(originalId)); + Assert.That(user.Sub, Is.EqualTo(originalSub)); + } + } + + [Test] + public void ChangeUsername_FirstChange_Succeeds() + { + var user = new UserEntity(ValidUsername, ValidSub); + var newUsername = new Username("bob"); + + user.ChangeUsername(newUsername); + + Assert.That(user.Username, Is.EqualTo(newUsername)); + } + + [Test] + public void ChangeUsername_SetsUsernameLastChangedAt() + { + var user = new UserEntity(ValidUsername, ValidSub); + var before = DateTime.UtcNow; + + user.ChangeUsername(new Username("bob")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.UsernameLastChangedAt, Is.Not.Null); + Assert.That(user.UsernameLastChangedAt, Is.GreaterThanOrEqualTo(before)); + } + } + + [Test] + public void ChangeUsername_ValidUsername_UpdatesUsername() + { + var user = new UserEntity(ValidUsername, ValidSub); + var newUsername = new Username("bob"); + + user.ChangeUsername(newUsername); + + Assert.That(user.Username, Is.EqualTo(newUsername)); + } + + [Test] + public void ChangeUsername_WithinCooldown_ThrowsUsernameCooldownException() + { + var user = new UserEntity(ValidUsername, ValidSub); + user.ChangeUsername(new Username("bob")); + + Assert.Throws(() => user.ChangeUsername(new Username("charlie"))); + } + + [Test] + public void Constructor_BioIsNullByDefault() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Bio, Is.Null); + } + + [Test] + public void Constructor_EmptyOrWhitespaceSub_ThrowsInvalidUserSubException([Values("", " ", " ")] string sub) + { + Assert.Throws(() => new UserEntity(ValidUsername, sub)); + } + + [Test] + public void Constructor_GeneratesNonEmptyId() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Id, Is.Not.EqualTo(Guid.Empty)); + } + + [Test] + public void Constructor_GeneratesUniqueIds() + { + var user1 = new UserEntity(ValidUsername, ValidSub); + var user2 = new UserEntity(ValidUsername, ValidSub); + + Assert.That(user1.Id, Is.Not.EqualTo(user2.Id)); + } + + [Test] + public void Constructor_ImageUrlIsNullByDefault() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.ImageUrl, Is.Null); + } + + [Test] + public void Constructor_NullUsername_ThrowsInvalidUsernameException() + { + Assert.Throws(() => new UserEntity(null!, ValidSub)); + } + + [Test] + public void Constructor_SetsSubCorrectly() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Sub, Is.EqualTo(ValidSub)); + } + + [Test] + public void Constructor_SubWithSpecialCharacters_Succeeds() + { + var user = new UserEntity(ValidUsername, "google-oauth2|abc.123-xyz"); + Assert.That(user.Sub, Is.EqualTo("google-oauth2|abc.123-xyz")); + } + + [Test] + public void Constructor_ValidArguments_CreatesUser() + { + var user = new UserEntity(ValidUsername, ValidSub); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.Id, Is.Not.EqualTo(Guid.Empty)); + Assert.That(user.Sub, Is.EqualTo(ValidSub)); + Assert.That(user.Username, Is.EqualTo(ValidUsername)); + } + } + + [Test] + public void Equals_DifferentInstances_AreNotEqual() + { + var user1 = new UserEntity(ValidUsername, ValidSub); + var user2 = new UserEntity(ValidUsername, ValidSub); + + Assert.That(user1, Is.Not.EqualTo(user2)); + } + + [Test] + public void Equals_Null_IsNotEqual() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Equals(null), Is.False); + } + + [Test] + public void Equals_SameInstance_IsEqual() + { + var user = new UserEntity(ValidUsername, ValidSub); + Assert.That(user.Equals(user), Is.True); + } + + [Test] + public void GetHashCode_DifferentUsers_ReturnDifferentHashes() + { + var user1 = new UserEntity(ValidUsername, ValidSub); + var user2 = new UserEntity(ValidUsername, ValidSub); + + Assert.That(user1.GetHashCode(), Is.Not.EqualTo(user2.GetHashCode())); + } + + [Test] + public void GetHashCode_SameUser_ReturnsSameHash() + { + var user = new UserEntity(ValidUsername, ValidSub); + int hash1 = user.GetHashCode(); + int hash2 = user.GetHashCode(); + Assert.That(hash1, Is.EqualTo(hash2)); + } + + [Test] + public void UpdateBio_DoesNotAffectOtherProperties() + { + var user = new UserEntity(ValidUsername, ValidSub); + var originalId = user.Id; + string originalSub = user.Sub; + + user.UpdateBio(new Bio("Some bio.")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(user.Id, Is.EqualTo(originalId)); + Assert.That(user.Sub, Is.EqualTo(originalSub)); + } + } + + [Test] + public void UpdateBio_Null_ClearsBio() + { + var user = new UserEntity(ValidUsername, ValidSub); + user.UpdateBio(new Bio("Some bio.")); + + user.UpdateBio(null); + + Assert.That(user.Bio, Is.Null); + } + + [Test] + public void UpdateBio_ValidBio_SetsBio() + { + var user = new UserEntity(ValidUsername, ValidSub); + var bio = new Bio("I love competitive programming."); + + user.UpdateBio(bio); + + Assert.That(user.Bio, Is.EqualTo(bio)); + } + + [Test] + public void UpdateImageUrl_Null_ClearsImageUrl() + { + var user = new UserEntity(ValidUsername, ValidSub); + user.UpdateImageUrl(new ImageUrl("https://example.com/avatar.png")); + + user.UpdateImageUrl(null); + + Assert.That(user.ImageUrl, Is.Null); + } + + [Test] + public void UpdateImageUrl_ValidUrl_SetsImageUrl() + { + var user = new UserEntity(ValidUsername, ValidSub); + var imageUrl = new ImageUrl("https://example.com/avatar.png"); + + user.UpdateImageUrl(imageUrl); + + Assert.That(user.ImageUrl, Is.EqualTo(imageUrl)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/User/ValueObjects/BioTests.cs b/StackDuel.Domain.Tests/User/ValueObjects/BioTests.cs new file mode 100644 index 0000000..a3a8711 --- /dev/null +++ b/StackDuel.Domain.Tests/User/ValueObjects/BioTests.cs @@ -0,0 +1,79 @@ +using StackDuel.Domain.Users.Exceptions; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Tests.User.ValueObjects; + +public class BioTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string atMax = new('a', Bio.MaxLength); + Assert.DoesNotThrow(() => new Bio(atMax)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidBioException([Values("", " ", " ", null)] string? value) + { + Assert.Throws(() => new Bio(value!)); + } + + [Test] + public void Constructor_FarExceedsMaxLength_ThrowsInvalidBioException() + { + string veryLong = new('a', 10000); + Assert.Throws(() => new Bio(veryLong)); + } + + [Test] + public void Constructor_OneAboveMaxLength_ThrowsInvalidBioException() + { + string tooLong = new('a', Bio.MaxLength + 1); + Assert.Throws(() => new Bio(tooLong)); + } + + [Test] + public void Constructor_ValidValue_SetsValue() + { + var bio = new Bio("I love competitive programming."); + Assert.That(bio.Value, Is.EqualTo("I love competitive programming.")); + } + + [Test] + public void Equality_DifferentValue_AreNotEqual() + { + var a = new Bio("Hello world."); + var b = new Bio("Goodbye world."); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Bio("Hello world."); + var b = new Bio("Hello world."); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var bio = new Bio("Hello world."); + string value = bio; + Assert.That(value, Is.EqualTo("Hello world.")); + } + + [Test] + public void ToString_MatchesImplicitOperator() + { + var bio = new Bio("Hello world."); + Assert.That(bio.ToString(), Is.EqualTo((string)bio)); + } + + [Test] + public void ToString_ReturnsValue() + { + var bio = new Bio("Hello world."); + Assert.That(bio.ToString(), Is.EqualTo("Hello world.")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/User/ValueObjects/ImageUrlTests.cs b/StackDuel.Domain.Tests/User/ValueObjects/ImageUrlTests.cs new file mode 100644 index 0000000..84b2278 --- /dev/null +++ b/StackDuel.Domain.Tests/User/ValueObjects/ImageUrlTests.cs @@ -0,0 +1,94 @@ +using StackDuel.Domain.Users.Exceptions; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Tests.User.ValueObjects; + +public class ImageUrlTests +{ + private const string ValidHttpUrl = "https://example.com/avatar.png"; + + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string path = new('a', ImageUrl.MaxLength - "https://x.co/".Length); + string atMax = $"https://x.co/{path}"; + Assert.DoesNotThrow(() => new ImageUrl(atMax)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidImageUrlException( + [Values("", " ", " ", null)] string? value + ) + { + Assert.Throws(() => new ImageUrl(value!)); + } + + [Test] + public void Constructor_ExceedsMaxLength_ThrowsInvalidImageUrlException() + { + string path = new('a', ImageUrl.MaxLength); + string tooLong = $"https://x.co/{path}"; + Assert.Throws(() => new ImageUrl(tooLong)); + } + + [Test] + public void Constructor_InvalidUrl_ThrowsInvalidImageUrlException( + [Values("not-a-url", "ftp://example.com/file.png", "example.com/avatar.png", "//example.com/avatar.png")] + string value + ) + { + Assert.Throws(() => new ImageUrl(value)); + } + + [Test] + public void Constructor_ValidUrl_SetsValue( + [Values( + "https://example.com/avatar.png", + "http://example.com/avatar.jpg", + "https://avatars.githubusercontent.com/u/12345" + )] + string value + ) + { + var imageUrl = new ImageUrl(value); + Assert.That(imageUrl.Value, Is.EqualTo(value)); + } + + [Test] + public void Equality_DifferentValue_AreNotEqual() + { + var a = new ImageUrl(ValidHttpUrl); + var b = new ImageUrl("https://example.com/other.png"); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new ImageUrl(ValidHttpUrl); + var b = new ImageUrl(ValidHttpUrl); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var imageUrl = new ImageUrl(ValidHttpUrl); + string value = imageUrl; + Assert.That(value, Is.EqualTo(ValidHttpUrl)); + } + + [Test] + public void ToString_MatchesImplicitOperator() + { + var imageUrl = new ImageUrl(ValidHttpUrl); + Assert.That(imageUrl.ToString(), Is.EqualTo((string)imageUrl)); + } + + [Test] + public void ToString_ReturnsValue() + { + var imageUrl = new ImageUrl(ValidHttpUrl); + Assert.That(imageUrl.ToString(), Is.EqualTo(ValidHttpUrl)); + } +} \ No newline at end of file diff --git a/StackDuel.Domain.Tests/User/ValueObjects/UsernameTests.cs b/StackDuel.Domain.Tests/User/ValueObjects/UsernameTests.cs new file mode 100644 index 0000000..5626003 --- /dev/null +++ b/StackDuel.Domain.Tests/User/ValueObjects/UsernameTests.cs @@ -0,0 +1,118 @@ +using StackDuel.Domain.Users.Exceptions; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Tests.User.ValueObjects; + +public class UsernameTests +{ + [Test] + public void Constructor_AtMaxLength_Succeeds() + { + string atMax = new('a', Username.MaxLength); + Assert.DoesNotThrow(() => new Username(atMax)); + } + + [Test] + public void Constructor_AtMinLength_Succeeds() + { + string atMin = new('a', Username.MinLength); + Assert.DoesNotThrow(() => new Username(atMin)); + } + + [Test] + public void Constructor_EmptyOrWhitespace_ThrowsInvalidUsernameException( + [Values("", " ", " ", null)] string? value + ) + { + Assert.Throws(() => new Username(value!)); + } + + [Test] + public void Constructor_FarExceedsMaxLength_ThrowsInvalidUsernameException() + { + string veryLong = new('a', 1000); + Assert.Throws(() => new Username(veryLong)); + } + + [Test] + public void Constructor_OneAboveMaxLength_ThrowsInvalidUsernameException() + { + string tooLong = new('a', Username.MaxLength + 1); + Assert.Throws(() => new Username(tooLong)); + } + + [Test] + public void Constructor_OneBelowMinLength_ThrowsInvalidUsernameException() + { + string tooShort = new('a', Username.MinLength - 1); + Assert.Throws(() => new Username(tooShort)); + } + + [Test] + public void Constructor_ValidCharacterVariations_Succeeds( + [Values("alice123", "ALICE", "Alice", "123", "a")] string value + ) + { + Assert.DoesNotThrow(() => new Username(value)); + } + + [Test] + public void Constructor_ValidValue_SetsValue() + { + var username = new Username("alice"); + Assert.That(username.Value, Is.EqualTo("alice")); + } + + [Test] + public void Equality_DifferentCasing_AreNotEqual() + { + var a = new Username("alice"); + var b = new Username("Alice"); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_DifferentValue_AreNotEqual() + { + var a = new Username("alice"); + var b = new Username("bob"); + Assert.That(a, Is.Not.EqualTo(b)); + } + + [Test] + public void Equality_SameReference_AreEqual() + { + var a = new Username("alice"); + Assert.That(a.Equals(a), Is.True); + } + + [Test] + public void Equality_SameValue_AreEqual() + { + var a = new Username("alice"); + var b = new Username("alice"); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void ImplicitOperator_ReturnsStringValue() + { + var username = new Username("alice"); + string value = username; + Assert.That(value, Is.EqualTo("alice")); + } + + [Test] + public void ToString_MatchesImplicitOperator() + { + var username = new Username("alice"); + Assert.That(username.ToString(), Is.EqualTo((string)username)); + } + + [Test] + public void ToString_ReturnsValue() + { + var username = new Username("alice"); + Assert.That(username.ToString(), Is.EqualTo("alice")); + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Entities/AchievementDefinition.cs b/StackDuel.Domain/Achievements/Entities/AchievementDefinition.cs new file mode 100644 index 0000000..767208c --- /dev/null +++ b/StackDuel.Domain/Achievements/Entities/AchievementDefinition.cs @@ -0,0 +1,178 @@ +using StackDuel.Domain.Achievements.Enums; +using StackDuel.Domain.Achievements.Exceptions; +using StackDuel.Domain.Achievements.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Achievements.Entities; + +/// +/// A badge in the catalog. Admin-managed metadata, not an aggregate with a lifecycle of its +/// own — earning one is represented separately by . +/// +public sealed class AchievementDefinition : Entity +{ + private AchievementDefinition( + AchievementCode code, + string name, + string description, + AchievementCategory category, + AchievementTier tier, + string iconKey, + bool isSecret, + AchievementCriteriaType criteriaType, + AchievementStat? criteriaStat, + int? criteriaThreshold, + string? customRuleKey + ) + { + Code = code; + Category = category; + Tier = tier; + IsSecret = isSecret; + IsActive = true; + CreatedAt = DateTime.UtcNow; + + SetDetails(name, description, iconKey); + SetCriteria(criteriaType, criteriaStat, criteriaThreshold, customRuleKey); + } + + private AchievementDefinition() { } + + public static AchievementDefinition CreateThreshold( + AchievementCode code, + string name, + string description, + AchievementCategory category, + AchievementTier tier, + string iconKey, + bool isSecret, + AchievementStat stat, + int threshold + ) + { + return new AchievementDefinition( + code, + name, + description, + category, + tier, + iconKey, + isSecret, + AchievementCriteriaType.Threshold, + stat, + threshold, + null + ); + } + + public static AchievementDefinition CreateCustom( + AchievementCode code, + string name, + string description, + AchievementCategory category, + AchievementTier tier, + string iconKey, + bool isSecret, + string customRuleKey + ) + { + return new AchievementDefinition( + code, + name, + description, + category, + tier, + iconKey, + isSecret, + AchievementCriteriaType.Custom, + null, + null, + customRuleKey + ); + } + + public void UpdateDetails(string name, string description, string iconKey) => + SetDetails(name, description, iconKey); + + public void UpdateThresholdCriteria(AchievementStat stat, int threshold) => + SetCriteria(AchievementCriteriaType.Threshold, stat, threshold, null); + + public void SetActive(bool isActive) => IsActive = isActive; + + public void SetSecret(bool isSecret) => IsSecret = isSecret; + + /// Whether the given value of this definition's satisfies it. + public bool IsSatisfiedByThreshold(int currentStatValue) => + CriteriaType == AchievementCriteriaType.Threshold + && CriteriaThreshold.HasValue + && currentStatValue >= CriteriaThreshold.Value; + + private void SetDetails(string name, string description, string iconKey) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name is required.", nameof(name)); + + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description is required.", nameof(description)); + + if (string.IsNullOrWhiteSpace(iconKey)) + throw new ArgumentException("Icon key is required.", nameof(iconKey)); + + Name = name; + Description = description; + IconKey = iconKey; + } + + private void SetCriteria( + AchievementCriteriaType criteriaType, + AchievementStat? stat, + int? threshold, + string? customRuleKey + ) + { + if (criteriaType == AchievementCriteriaType.Threshold) + { + if (stat is null) + throw new InvalidAchievementCriteriaException("A stat is required for threshold criteria."); + + if (threshold is not > 0) + throw new InvalidAchievementCriteriaException("Threshold must be greater than zero."); + } + else if (string.IsNullOrWhiteSpace(customRuleKey)) + { + throw new InvalidAchievementCriteriaException("A custom rule key is required for custom criteria."); + } + + CriteriaType = criteriaType; + CriteriaStat = stat; + CriteriaThreshold = threshold; + CustomRuleKey = customRuleKey; + } + + public AchievementCode Code { get; private set; } + + public string Name { get; private set; } = null!; + + public string Description { get; private set; } = null!; + + public AchievementCategory Category { get; private set; } + + public AchievementTier Tier { get; private set; } + + public string IconKey { get; private set; } = null!; + + /// Hidden from the catalog/profile until earned. + public bool IsSecret { get; private set; } + + public bool IsActive { get; private set; } + + public AchievementCriteriaType CriteriaType { get; private set; } + + public AchievementStat? CriteriaStat { get; private set; } + + public int? CriteriaThreshold { get; private set; } + + public string? CustomRuleKey { get; private set; } + + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Entities/UserAchievement.cs b/StackDuel.Domain/Achievements/Entities/UserAchievement.cs new file mode 100644 index 0000000..fa46ba6 --- /dev/null +++ b/StackDuel.Domain/Achievements/Entities/UserAchievement.cs @@ -0,0 +1,37 @@ +using StackDuel.Domain.Achievements.Events; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Achievements.Entities; + +/// +/// Records that a user has earned a specific . Awarding one +/// is the interesting domain event — created once per (user, definition) pair and never mutated. +/// +public sealed class UserAchievement : AggregateRoot +{ + public UserAchievement(Guid userId, Guid achievementDefinitionId) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + if (achievementDefinitionId == Guid.Empty) + throw new ArgumentException( + "Achievement definition id must not be empty.", + nameof(achievementDefinitionId) + ); + + UserId = userId; + AchievementDefinitionId = achievementDefinitionId; + EarnedAt = DateTime.UtcNow; + + AddDomainEvent(new AchievementUnlockedDomainEvent(UserId, AchievementDefinitionId, Id, EarnedAt)); + } + + private UserAchievement() { } + + public Guid UserId { get; private set; } + + public Guid AchievementDefinitionId { get; private set; } + + public DateTime EarnedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Entities/UserAchievementStats.cs b/StackDuel.Domain/Achievements/Entities/UserAchievementStats.cs new file mode 100644 index 0000000..1f3065b --- /dev/null +++ b/StackDuel.Domain/Achievements/Entities/UserAchievementStats.cs @@ -0,0 +1,146 @@ +using StackDuel.Domain.Achievements.Enums; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.SeedWork; +using System.Numerics; + +namespace StackDuel.Domain.Achievements.Entities; + +/// +/// Denormalized per-user counters that back achievement evaluation. Kept up to date incrementally +/// by domain event handlers reacting to submissions/games/feedback, so checking a threshold-type +/// is an O(1) comparison instead of re-aggregating history. +/// +public sealed class UserAchievementStats : Entity +{ + public UserAchievementStats(Guid userId) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + UserId = userId; + } + + private UserAchievementStats() { } + + public void RecordAcceptedSolve(DifficultyTier difficulty, DateOnly solvedOnUtc) + { + AcceptedSolveCount++; + + _solvedDifficultyMask |= 1 << ((int)difficulty - 1); + + UpdateSolveStreak(solvedOnUtc); + } + + public void RecordLanguageUsed(Guid languageId) + { + if (_languages.Any(l => l.LanguageId == languageId)) + return; + + _languages.Add(new UserAchievementStatLanguage(languageId)); + } + + public void RecordGameResult(GameOutcome outcome) + { + GamesPlayed++; + + switch (outcome) + { + case GameOutcome.Won: + GamesWon++; + CurrentWinStreak++; + LongestWinStreak = Math.Max(LongestWinStreak, CurrentWinStreak); + break; + case GameOutcome.Lost: + GamesLost++; + CurrentWinStreak = 0; + break; + case GameOutcome.Drawn: + GamesDrawn++; + break; + } + } + + public void RecordBugReport() => BugReportsSubmitted++; + + /// Reads the value of a named stat, for the generic threshold evaluator. + public int GetStatValue(AchievementStat stat) => + stat switch + { + AchievementStat.AcceptedSolveCount => AcceptedSolveCount, + AchievementStat.DistinctDifficultiesSolved => DistinctDifficultiesSolved, + AchievementStat.DistinctLanguagesUsed => DistinctLanguagesUsed, + AchievementStat.LongestSolveStreak => LongestSolveStreak, + AchievementStat.GamesPlayed => GamesPlayed, + AchievementStat.GamesWon => GamesWon, + AchievementStat.LongestWinStreak => LongestWinStreak, + AchievementStat.BugReportsSubmitted => BugReportsSubmitted, + _ => throw new ArgumentOutOfRangeException(nameof(stat), stat, null), + }; + + private void UpdateSolveStreak(DateOnly solvedOnUtc) + { + if (LastSolveDateUtc is { } last) + { + if (solvedOnUtc <= last) + return; + + CurrentSolveStreak = solvedOnUtc == last.AddDays(1) ? CurrentSolveStreak + 1 : 1; + } + else + { + CurrentSolveStreak = 1; + } + + LastSolveDateUtc = solvedOnUtc; + LongestSolveStreak = Math.Max(LongestSolveStreak, CurrentSolveStreak); + } + + public Guid UserId { get; private set; } + + public int AcceptedSolveCount { get; private set; } + + public int DistinctDifficultiesSolved => BitOperations.PopCount((uint)_solvedDifficultyMask); + + public int DistinctLanguagesUsed => _languages.Count; + + public int CurrentSolveStreak { get; private set; } + + public int LongestSolveStreak { get; private set; } + + public DateOnly? LastSolveDateUtc { get; private set; } + + public int GamesPlayed { get; private set; } + + public int GamesWon { get; private set; } + + public int GamesLost { get; private set; } + + public int GamesDrawn { get; private set; } + + public int CurrentWinStreak { get; private set; } + + public int LongestWinStreak { get; private set; } + + public int BugReportsSubmitted { get; private set; } + + public IReadOnlyCollection Languages => _languages.AsReadOnly(); + + private int _solvedDifficultyMask; + + private readonly List _languages = []; +} + +public sealed class UserAchievementStatLanguage : Entity +{ + public UserAchievementStatLanguage(Guid languageId) + { + if (languageId == Guid.Empty) + throw new ArgumentException("Language id must not be empty.", nameof(languageId)); + + LanguageId = languageId; + } + + private UserAchievementStatLanguage() { } + + public Guid LanguageId { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Enums/AchievementCategory.cs b/StackDuel.Domain/Achievements/Enums/AchievementCategory.cs new file mode 100644 index 0000000..75777e0 --- /dev/null +++ b/StackDuel.Domain/Achievements/Enums/AchievementCategory.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.Achievements.Enums; + +public enum AchievementCategory +{ + Solving, + Streak, + Dueling, + Feedback, +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Enums/AchievementCriteriaType.cs b/StackDuel.Domain/Achievements/Enums/AchievementCriteriaType.cs new file mode 100644 index 0000000..0474c28 --- /dev/null +++ b/StackDuel.Domain/Achievements/Enums/AchievementCriteriaType.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Domain.Achievements.Enums; + +public enum AchievementCriteriaType +{ + /// A single named reaching a threshold value. + Threshold, + + /// Evaluated by a code-registered rule (keyed by ) for conditions a single stat threshold can't express. + Custom, +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Enums/AchievementStat.cs b/StackDuel.Domain/Achievements/Enums/AchievementStat.cs new file mode 100644 index 0000000..d15cc7b --- /dev/null +++ b/StackDuel.Domain/Achievements/Enums/AchievementStat.cs @@ -0,0 +1,17 @@ +namespace StackDuel.Domain.Achievements.Enums; + +/// +/// Named counters on that a threshold-type +/// can be evaluated against. +/// +public enum AchievementStat +{ + AcceptedSolveCount, + DistinctDifficultiesSolved, + DistinctLanguagesUsed, + LongestSolveStreak, + GamesPlayed, + GamesWon, + LongestWinStreak, + BugReportsSubmitted, +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Enums/AchievementTier.cs b/StackDuel.Domain/Achievements/Enums/AchievementTier.cs new file mode 100644 index 0000000..0827596 --- /dev/null +++ b/StackDuel.Domain/Achievements/Enums/AchievementTier.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Domain.Achievements.Enums; + +public enum AchievementTier +{ + None, + Bronze, + Silver, + Gold, + Platinum, +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Enums/GameOutcome.cs b/StackDuel.Domain/Achievements/Enums/GameOutcome.cs new file mode 100644 index 0000000..84e48a1 --- /dev/null +++ b/StackDuel.Domain/Achievements/Enums/GameOutcome.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.Achievements.Enums; + +public enum GameOutcome +{ + Won, + Lost, + Drawn, +} \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Events/AchievementUnlockedDomainEvent.cs b/StackDuel.Domain/Achievements/Events/AchievementUnlockedDomainEvent.cs new file mode 100644 index 0000000..03ccf39 --- /dev/null +++ b/StackDuel.Domain/Achievements/Events/AchievementUnlockedDomainEvent.cs @@ -0,0 +1,10 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Achievements.Events; + +public sealed record AchievementUnlockedDomainEvent( + Guid UserId, + Guid AchievementDefinitionId, + Guid UserAchievementId, + DateTime EarnedAt +) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/Exceptions/InvalidAchievementCriteriaException.cs b/StackDuel.Domain/Achievements/Exceptions/InvalidAchievementCriteriaException.cs new file mode 100644 index 0000000..bbcaa63 --- /dev/null +++ b/StackDuel.Domain/Achievements/Exceptions/InvalidAchievementCriteriaException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Achievements.Exceptions; + +public sealed class InvalidAchievementCriteriaException(string reason) + : DomainException($"Achievement criteria is invalid: {reason}") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Achievements/ValueObjects/AchievementCode.cs b/StackDuel.Domain/Achievements/ValueObjects/AchievementCode.cs new file mode 100644 index 0000000..bffc17c --- /dev/null +++ b/StackDuel.Domain/Achievements/ValueObjects/AchievementCode.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace StackDuel.Domain.Achievements.ValueObjects; + +public readonly partial record struct AchievementCode +{ + public static readonly int MaxLength = 100; + + public AchievementCode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("AchievementCode required.", nameof(value)); + + if (value.Length > MaxLength) + throw new ArgumentException($"AchievementCode must be at most {MaxLength} characters.", nameof(value)); + + if (!KebabCaseRegex().IsMatch(value)) + throw new ArgumentException( + "AchievementCode must be lowercase kebab-case, e.g. 'first-blood'.", + nameof(value) + ); + + Value = value; + } + + public string Value { get; } + + [GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")] + private static partial Regex KebabCaseRegex(); +} \ No newline at end of file diff --git a/StackDuel.Domain/Audit/Entities/AuditLogEntry.cs b/StackDuel.Domain/Audit/Entities/AuditLogEntry.cs new file mode 100644 index 0000000..bd8b8df --- /dev/null +++ b/StackDuel.Domain/Audit/Entities/AuditLogEntry.cs @@ -0,0 +1,49 @@ +namespace StackDuel.Domain.Audit.Entities; + +public sealed class AuditLogEntry +{ + private AuditLogEntry() { } + + public static AuditLogEntry Create( + Guid? actorUserId, + string actorUsername, + string action, + string? targetType, + string? targetId, + string? detailsJson + ) + { + if (string.IsNullOrWhiteSpace(actorUsername)) + throw new ArgumentException("Actor username is required", nameof(actorUsername)); + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Action is required", nameof(action)); + + return new AuditLogEntry + { + Id = Guid.NewGuid(), + ActorUserId = actorUserId, + ActorUsername = actorUsername, + Action = action, + TargetType = targetType, + TargetId = targetId, + DetailsJson = detailsJson, + CreatedAt = DateTime.UtcNow, + }; + } + + public Guid Id { get; private set; } + + public Guid? ActorUserId { get; private set; } + + public string ActorUsername { get; private set; } = string.Empty; + + public string Action { get; private set; } = string.Empty; + + public string? TargetType { get; private set; } + + public string? TargetId { get; private set; } + + public string? DetailsJson { get; private set; } + + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/IAuthorizationReadRepository.cs b/StackDuel.Domain/Authorization/IAuthorizationReadRepository.cs new file mode 100644 index 0000000..4e222e9 --- /dev/null +++ b/StackDuel.Domain/Authorization/IAuthorizationReadRepository.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.Authorization; + +public interface IAuthorizationReadRepository +{ + Task> GetUserPermissionsAsync(Guid userId, CancellationToken cancellationToken); + + Task> GetUserRoleNamesAsync(Guid userId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/Entities/Group.cs b/StackDuel.Domain/Authorization/Rbac/Entities/Group.cs new file mode 100644 index 0000000..62bd7a5 --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/Entities/Group.cs @@ -0,0 +1,32 @@ +using StackDuel.Domain.Authorization.Rbac.ValueObjects; + +namespace StackDuel.Domain.Authorization.Rbac.Entities; + +public sealed class Group +{ + internal Group(GroupId id, Name name) + { + if (name is null) + throw new ArgumentException("Group name required", nameof(name)); + Id = id; + Name = name; + } + + public static Group Create(Name name) => new(new GroupId(Guid.NewGuid()), name); + + public void GrantRole(Role role) + { + ArgumentNullException.ThrowIfNull(role); + _roleGrants.Add(role); + } + + public void RevokeRole(Role role) => _roleGrants.Remove(role); + + public GroupId Id { get; } + + public Name Name { get; } + + public IReadOnlyCollection RoleGrants => _roleGrants.ToList().AsReadOnly(); + + private readonly HashSet _roleGrants = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/Entities/Role.cs b/StackDuel.Domain/Authorization/Rbac/Entities/Role.cs new file mode 100644 index 0000000..de0db07 --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/Entities/Role.cs @@ -0,0 +1,75 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; + +namespace StackDuel.Domain.Authorization.Rbac.Entities; + +public sealed class Role +{ + internal Role(RoleId id, Name name) + { + Id = id; + Name = name; + } + + public static Role Create(Name name) => new(new RoleId(Guid.NewGuid()), name); + + public void GrantPermission(PermissionId permissionId) => Upsert(permissionId, DecisionEffect.Allow); + + public void DenyPermission(PermissionId permissionId) => Upsert(permissionId, DecisionEffect.Deny); + + private void Upsert(PermissionId permissionId, DecisionEffect effect) + { + var existingPermission = _permissions.FirstOrDefault(p => p.PermissionId == permissionId); + if (existingPermission != null) + { + existingPermission.UpdateEffect(effect); + } + else + { + var newPermission = new RolePermission(permissionId, effect); + _permissions.Add(newPermission); + } + } + + public RoleId Id { get; } + + public Name Name { get; } + + public IReadOnlyCollection Permissions => _permissions.AsReadOnly(); + + private readonly List _permissions = []; +} + +public sealed class Permission +{ + internal Permission(PermissionId id, PermissionCode code, string description) + { + Id = id; + Code = code; + Description = description; + } + + public static Permission Create(PermissionCode code, string description) => + new(new PermissionId(Guid.NewGuid()), code, description); + + public PermissionId Id { get; } + + public PermissionCode Code { get; } + + public string Description { get; } +} + +public sealed class RolePermission +{ + public PermissionId PermissionId { get; } + + public DecisionEffect Effect { get; private set; } + + internal RolePermission(PermissionId permissionId, DecisionEffect effect) + { + PermissionId = permissionId; + Effect = effect; + } + + internal void UpdateEffect(DecisionEffect effect) => Effect = effect; +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/Enums/DecisionEffect.cs b/StackDuel.Domain/Authorization/Rbac/Enums/DecisionEffect.cs new file mode 100644 index 0000000..c98147c --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/Enums/DecisionEffect.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.Authorization.Rbac.Enums; + +public enum DecisionEffect +{ + Allow, + Deny, +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/ValueObjects/GroupId.cs b/StackDuel.Domain/Authorization/Rbac/ValueObjects/GroupId.cs new file mode 100644 index 0000000..fe9d550 --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/ValueObjects/GroupId.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Authorization.Rbac.ValueObjects; + +public readonly record struct GroupId(Guid Value); \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/ValueObjects/Name.cs b/StackDuel.Domain/Authorization/Rbac/ValueObjects/Name.cs new file mode 100644 index 0000000..42d675e --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/ValueObjects/Name.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace StackDuel.Domain.Authorization.Rbac.ValueObjects; + +public sealed record Name +{ + public Name(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Name required.", nameof(value)); + if (value.Length > MaxLength) + throw new ArgumentException($"Name must be at least {MaxLength} characters.", nameof(value)); + Value = value; + } + + public string Value { get; } + + public static readonly int MaxLength = 200; +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/ValueObjects/PermissionCode.cs b/StackDuel.Domain/Authorization/Rbac/ValueObjects/PermissionCode.cs new file mode 100644 index 0000000..81e631d --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/ValueObjects/PermissionCode.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Domain.Authorization.Rbac.ValueObjects; + +public readonly record struct PermissionCode +{ + public PermissionCode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("PermissionCode required.", nameof(value)); + Value = value; + } + + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/ValueObjects/PermissionId.cs b/StackDuel.Domain/Authorization/Rbac/ValueObjects/PermissionId.cs new file mode 100644 index 0000000..51a3817 --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/ValueObjects/PermissionId.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Authorization.Rbac.ValueObjects; + +public readonly record struct PermissionId(Guid Value); \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/ValueObjects/RoleId.cs b/StackDuel.Domain/Authorization/Rbac/ValueObjects/RoleId.cs new file mode 100644 index 0000000..9e86ac5 --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/ValueObjects/RoleId.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Authorization.Rbac.ValueObjects; + +public readonly record struct RoleId(Guid Value); \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/ValueObjects/UserId.cs b/StackDuel.Domain/Authorization/Rbac/ValueObjects/UserId.cs new file mode 100644 index 0000000..a433c2e --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/ValueObjects/UserId.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Authorization.Rbac.ValueObjects; + +public readonly record struct UserId(Guid Value); \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Rbac/WellKnownAuthorization.cs b/StackDuel.Domain/Authorization/Rbac/WellKnownAuthorization.cs new file mode 100644 index 0000000..8d8811a --- /dev/null +++ b/StackDuel.Domain/Authorization/Rbac/WellKnownAuthorization.cs @@ -0,0 +1,33 @@ +namespace StackDuel.Domain.Authorization.Rbac; + +public static class WellKnownAuthorization +{ + public const string CreateSubmissionPermission = "submission:create"; + public const string ViewSubmissionPermission = "submission:view"; + public const string PlaySoloRushPermission = "game:solo-rush:play"; + public const string PlayDuelPermission = "game:duel:play"; + public const string PlayFfaPermission = "game:ffa:play"; + public const string ReadAdminProblemsPermission = "problem:read:admin"; + public const string UpdateAdminProblemsPermission = "problem:update:admin"; + public const string ReadAdminUsersPermission = "user:read:admin"; + public const string UpdateAdminUserGroupsPermission = "user:groups:update:admin"; + public const string ReadAdminSubmissionsPermission = "submission:read:admin"; + public const string CreateFeedbackPermission = "feedback:create"; + public const string ReadAdminFeedbackPermission = "feedback:read:admin"; + public const string UpdateAdminFeedbackPermission = "feedback:update:admin"; + public const string ReadAdminDashboardPermission = "dashboard:read:admin"; + public const string ReadAdminGamesPermission = "games:read:admin"; + public const string ManageFeatureFlagsPermission = "feature-flag:manage:admin"; + public const string ReadAdminCampaignsPermission = "campaign:read:admin"; + public const string ManageAdminCampaignsPermission = "campaign:manage:admin"; + public const string ReadAdminAuditLogPermission = "audit-log:read:admin"; + public const string CreateAdminProblemsPermission = "problem:create:admin"; + public const string SubmitAdminProblemsPermission = "problem:submit:admin"; + public const string ManageRequiredProblemLanguagesPermission = "problem-required-language:manage:admin"; + + public const string DefaultUserRole = "default-user"; + public const string DefaultUserGroup = "default-user"; + + public const string AdminRole = "admin"; + public const string AdminGroup = "admin"; +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Security/Entities/SecurityRestriction.cs b/StackDuel.Domain/Authorization/Security/Entities/SecurityRestriction.cs new file mode 100644 index 0000000..883161e --- /dev/null +++ b/StackDuel.Domain/Authorization/Security/Entities/SecurityRestriction.cs @@ -0,0 +1,61 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.Authorization.Security.ValueObjects; + +namespace StackDuel.Domain.Authorization.Security.Entities; + +public sealed class SecurityRestriction +{ + internal SecurityRestriction( + Guid id, + UserId userId, + PermissionCode permissionCode, + DecisionEffect effect, + DateTimeOffset expiresAt, + Reason reason, + DetectionEventId detectionEventId + ) + { + if (userId.Value == Guid.Empty) + throw new ArgumentException("UserId must not be empty"); + if (string.IsNullOrWhiteSpace(permissionCode.Value)) + throw new ArgumentException("PermissionCode required.", nameof(permissionCode)); + if (expiresAt <= DateTimeOffset.UtcNow) + throw new ArgumentException("ExpiresAt must be in the future.", nameof(expiresAt)); + if (reason.Value is null) + throw new ArgumentException("Reason required.", nameof(reason)); + + Id = id; + UserId = userId; + PermissionCode = permissionCode; + Effect = effect; + ExpiresAt = expiresAt; + Reason = reason; + DetectionEventId = detectionEventId; + } + + public static SecurityRestriction CreateDenyTemporary( + Guid id, + UserId userId, + PermissionCode permissionCode, + DateTimeOffset expiresAt, + Reason reason, + DetectionEventId detectionEventId + ) => new(id, userId, permissionCode, DecisionEffect.Deny, expiresAt, reason, detectionEventId); + + public bool IsActiveAt(DateTimeOffset now) => now <= ExpiresAt; + + public Guid Id { get; } + + public UserId UserId { get; } + + public PermissionCode PermissionCode { get; } + + public DecisionEffect Effect { get; } + + public DateTimeOffset ExpiresAt { get; } + + public Reason Reason { get; } + + public DetectionEventId DetectionEventId { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Security/ValueObjects/DetectionEventId.cs b/StackDuel.Domain/Authorization/Security/ValueObjects/DetectionEventId.cs new file mode 100644 index 0000000..4334ee2 --- /dev/null +++ b/StackDuel.Domain/Authorization/Security/ValueObjects/DetectionEventId.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Authorization.Security.ValueObjects; + +public readonly record struct DetectionEventId(Guid Value); \ No newline at end of file diff --git a/StackDuel.Domain/Authorization/Security/ValueObjects/Reason.cs b/StackDuel.Domain/Authorization/Security/ValueObjects/Reason.cs new file mode 100644 index 0000000..0c88906 --- /dev/null +++ b/StackDuel.Domain/Authorization/Security/ValueObjects/Reason.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Domain.Authorization.Security.ValueObjects; + +public readonly record struct Reason +{ + public Reason(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Reason required.", nameof(value)); + Value = value; + } + + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/Campaign.cs b/StackDuel.Domain/Campaigns/Entities/Campaign.cs new file mode 100644 index 0000000..fdf6bca --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/Campaign.cs @@ -0,0 +1,93 @@ +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class Campaign : AggregateRoot +{ + public Campaign(string slug, string title, string description, CampaignDifficulty difficulty) + { + Slug = !string.IsNullOrWhiteSpace(slug) + ? slug.Trim().ToLowerInvariant() + : throw new ArgumentException("Campaign slug must not be empty.", nameof(slug)); + + SetDetails(title, description, difficulty, null); + + Status = CampaignStatus.Draft; + SortOrder = 0; + CreatedAt = DateTime.UtcNow; + } + + public void UpdateDetails(string title, string description, CampaignDifficulty difficulty, string? iconKey) => + SetDetails(title, description, difficulty, iconKey); + + public void Publish() + { + if (Status != CampaignStatus.Draft) + throw new InvalidOperationException("Only draft campaigns can be published."); + + Status = CampaignStatus.Published; + PublishedAt = DateTime.UtcNow; + } + + public void Archive() + { + if (Status == CampaignStatus.Archived) + throw new InvalidOperationException("Campaign is already archived."); + + Status = CampaignStatus.Archived; + } + + public void SetSortOrder(int sortOrder) => SortOrder = sortOrder; + + public CampaignModule AddModule(string title, string description) + { + CampaignModule module = new(Id, title, description, _modules.Count); + _modules.Add(module); + return module; + } + + public void SetPrerequisites(IEnumerable requiredCampaignIds) + { + Guid[] distinctIds = + requiredCampaignIds?.Where(id => id != Guid.Empty).Distinct().ToArray() + ?? throw new ArgumentNullException(nameof(requiredCampaignIds)); + + if (distinctIds.Contains(Id)) + throw new ArgumentException("A campaign cannot be its own prerequisite.", nameof(requiredCampaignIds)); + + _prerequisites.Clear(); + + foreach (Guid requiredCampaignId in distinctIds) + _prerequisites.Add(new CampaignPrerequisite(Id, requiredCampaignId)); + } + + private void SetDetails(string title, string description, CampaignDifficulty difficulty, string? iconKey) + { + Title = !string.IsNullOrWhiteSpace(title) + ? title.Trim() + : throw new ArgumentException("Campaign title must not be empty.", nameof(title)); + + Description = description ?? throw new ArgumentNullException(nameof(description)); + Difficulty = difficulty; + IconKey = string.IsNullOrWhiteSpace(iconKey) ? null : iconKey.Trim(); + } + + private Campaign() { } + + public string Slug { get; private set; } = null!; + public string Title { get; private set; } = null!; + public string Description { get; private set; } = null!; + public CampaignDifficulty Difficulty { get; private set; } + public CampaignStatus Status { get; private set; } + public string? IconKey { get; private set; } + public int SortOrder { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? PublishedAt { get; private set; } + + public IReadOnlyCollection Modules => _modules.AsReadOnly(); + public IReadOnlyCollection Prerequisites => _prerequisites.AsReadOnly(); + + private readonly List _modules = []; + private readonly List _prerequisites = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/CampaignEnrollment.cs b/StackDuel.Domain/Campaigns/Entities/CampaignEnrollment.cs new file mode 100644 index 0000000..255d87a --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/CampaignEnrollment.cs @@ -0,0 +1,38 @@ +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class CampaignEnrollment : AggregateRoot +{ + public CampaignEnrollment(Guid userId, Guid campaignId) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + if (campaignId == Guid.Empty) + throw new ArgumentException("Campaign id must not be empty.", nameof(campaignId)); + + UserId = userId; + CampaignId = campaignId; + Status = EnrollmentStatus.InProgress; + EnrolledAt = DateTime.UtcNow; + } + + public void Complete() + { + if (Status == EnrollmentStatus.Completed) + throw new InvalidOperationException("Enrollment is already completed."); + + Status = EnrollmentStatus.Completed; + CompletedAt = DateTime.UtcNow; + } + + private CampaignEnrollment() { } + + public Guid UserId { get; private set; } + public Guid CampaignId { get; private set; } + public EnrollmentStatus Status { get; private set; } + public DateTime EnrolledAt { get; private set; } + public DateTime? CompletedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/CampaignModule.cs b/StackDuel.Domain/Campaigns/Entities/CampaignModule.cs new file mode 100644 index 0000000..4e4ccd4 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/CampaignModule.cs @@ -0,0 +1,48 @@ +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class CampaignModule : Entity +{ + public CampaignModule(Guid campaignId, string title, string description, int sortOrder) + { + if (campaignId == Guid.Empty) + throw new ArgumentException("Campaign id must not be empty.", nameof(campaignId)); + + CampaignId = campaignId; + + SetDetails(title, description); + + SortOrder = sortOrder; + } + + public void UpdateDetails(string title, string description) => SetDetails(title, description); + + public CampaignUnit AddUnit(string title, string content, UnitType unitType, int estimatedMinutes) + { + CampaignUnit unit = new(Id, title, content, unitType, estimatedMinutes, _units.Count); + _units.Add(unit); + return unit; + } + + private void SetDetails(string title, string description) + { + Title = !string.IsNullOrWhiteSpace(title) + ? title.Trim() + : throw new ArgumentException("Module title must not be empty.", nameof(title)); + + Description = description ?? throw new ArgumentNullException(nameof(description)); + } + + private CampaignModule() { } + + public Guid CampaignId { get; private set; } + public string Title { get; private set; } = null!; + public string Description { get; private set; } = null!; + public int SortOrder { get; private set; } + + public IReadOnlyCollection Units => _units.AsReadOnly(); + + private readonly List _units = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/CampaignPrerequisite.cs b/StackDuel.Domain/Campaigns/Entities/CampaignPrerequisite.cs new file mode 100644 index 0000000..5d71e69 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/CampaignPrerequisite.cs @@ -0,0 +1,23 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class CampaignPrerequisite : Entity +{ + public CampaignPrerequisite(Guid campaignId, Guid requiredCampaignId) + { + if (campaignId == Guid.Empty) + throw new ArgumentException("Campaign id must not be empty.", nameof(campaignId)); + + if (requiredCampaignId == Guid.Empty) + throw new ArgumentException("Required campaign id must not be empty.", nameof(requiredCampaignId)); + + CampaignId = campaignId; + RequiredCampaignId = requiredCampaignId; + } + + private CampaignPrerequisite() { } + + public Guid CampaignId { get; private set; } + public Guid RequiredCampaignId { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/CampaignUnit.cs b/StackDuel.Domain/Campaigns/Entities/CampaignUnit.cs new file mode 100644 index 0000000..08d3f22 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/CampaignUnit.cs @@ -0,0 +1,73 @@ +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class CampaignUnit : Entity +{ + public CampaignUnit( + Guid campaignModuleId, + string title, + string content, + UnitType unitType, + int estimatedMinutes, + int sortOrder + ) + { + if (campaignModuleId == Guid.Empty) + throw new ArgumentException("Campaign module id must not be empty.", nameof(campaignModuleId)); + + CampaignModuleId = campaignModuleId; + + SetDetails(title, content, unitType, estimatedMinutes); + + SortOrder = sortOrder; + } + + public void UpdateDetails(string title, string content, UnitType unitType, int estimatedMinutes) => + SetDetails(title, content, unitType, estimatedMinutes); + + public void SetProblems(IEnumerable problemIds) + { + Guid[] distinctProblemIds = + problemIds?.Where(id => id != Guid.Empty).Distinct().ToArray() + ?? throw new ArgumentNullException(nameof(problemIds)); + + _problems.Clear(); + + int sortOrder = 0; + foreach (Guid problemId in distinctProblemIds) + { + _problems.Add(new UnitProblem(Id, problemId, sortOrder)); + sortOrder++; + } + } + + private void SetDetails(string title, string content, UnitType unitType, int estimatedMinutes) + { + Title = !string.IsNullOrWhiteSpace(title) + ? title.Trim() + : throw new ArgumentException("Unit title must not be empty.", nameof(title)); + + Content = content ?? throw new ArgumentNullException(nameof(content)); + + if (estimatedMinutes < 0) + throw new ArgumentException("Estimated minutes must not be negative.", nameof(estimatedMinutes)); + + UnitType = unitType; + EstimatedMinutes = estimatedMinutes; + } + + private CampaignUnit() { } + + public Guid CampaignModuleId { get; private set; } + public string Title { get; private set; } = null!; + public string Content { get; private set; } = null!; + public UnitType UnitType { get; private set; } + public int EstimatedMinutes { get; private set; } + public int SortOrder { get; private set; } + + public IReadOnlyCollection Problems => _problems.AsReadOnly(); + + private readonly List _problems = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/UnitCompletion.cs b/StackDuel.Domain/Campaigns/Entities/UnitCompletion.cs new file mode 100644 index 0000000..294641a --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/UnitCompletion.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class UnitCompletion : AggregateRoot +{ + public UnitCompletion(Guid userId, Guid campaignUnitId) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + if (campaignUnitId == Guid.Empty) + throw new ArgumentException("Campaign unit id must not be empty.", nameof(campaignUnitId)); + + UserId = userId; + CampaignUnitId = campaignUnitId; + CompletedAt = DateTime.UtcNow; + } + + private UnitCompletion() { } + + public Guid UserId { get; private set; } + public Guid CampaignUnitId { get; private set; } + public DateTime CompletedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Entities/UnitProblem.cs b/StackDuel.Domain/Campaigns/Entities/UnitProblem.cs new file mode 100644 index 0000000..e90fd04 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Entities/UnitProblem.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Campaigns.Entities; + +public sealed class UnitProblem : Entity +{ + public UnitProblem(Guid campaignUnitId, Guid problemId, int sortOrder) + { + if (campaignUnitId == Guid.Empty) + throw new ArgumentException("Campaign unit id must not be empty.", nameof(campaignUnitId)); + + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + CampaignUnitId = campaignUnitId; + ProblemId = problemId; + SortOrder = sortOrder; + } + + private UnitProblem() { } + + public Guid CampaignUnitId { get; private set; } + public Guid ProblemId { get; private set; } + public int SortOrder { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Enums/CampaignDifficulty.cs b/StackDuel.Domain/Campaigns/Enums/CampaignDifficulty.cs new file mode 100644 index 0000000..e4df7b8 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Enums/CampaignDifficulty.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.Campaigns.Enums; + +public enum CampaignDifficulty +{ + Beginner = 0, + Intermediate = 1, + Advanced = 2, + Expert = 3, +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Enums/CampaignStatus.cs b/StackDuel.Domain/Campaigns/Enums/CampaignStatus.cs new file mode 100644 index 0000000..efd830f --- /dev/null +++ b/StackDuel.Domain/Campaigns/Enums/CampaignStatus.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.Campaigns.Enums; + +public enum CampaignStatus +{ + Draft = 0, + Published = 1, + Archived = 2, +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Enums/EnrollmentStatus.cs b/StackDuel.Domain/Campaigns/Enums/EnrollmentStatus.cs new file mode 100644 index 0000000..c8a5e81 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Enums/EnrollmentStatus.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.Campaigns.Enums; + +public enum EnrollmentStatus +{ + InProgress = 0, + Completed = 1, +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/Enums/UnitType.cs b/StackDuel.Domain/Campaigns/Enums/UnitType.cs new file mode 100644 index 0000000..ebf8be5 --- /dev/null +++ b/StackDuel.Domain/Campaigns/Enums/UnitType.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.Campaigns.Enums; + +public enum UnitType +{ + Lesson = 0, + Challenge = 1, + Quiz = 2, +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/ICampaignEnrollmentWriteRepository.cs b/StackDuel.Domain/Campaigns/ICampaignEnrollmentWriteRepository.cs new file mode 100644 index 0000000..de9bf07 --- /dev/null +++ b/StackDuel.Domain/Campaigns/ICampaignEnrollmentWriteRepository.cs @@ -0,0 +1,10 @@ +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Domain.Campaigns; + +public interface ICampaignEnrollmentWriteRepository +{ + Task AddAsync(CampaignEnrollment entity, CancellationToken cancellationToken = default); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CampaignEnrollment entity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/ICampaignWriteRepository.cs b/StackDuel.Domain/Campaigns/ICampaignWriteRepository.cs new file mode 100644 index 0000000..5347e6a --- /dev/null +++ b/StackDuel.Domain/Campaigns/ICampaignWriteRepository.cs @@ -0,0 +1,10 @@ +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Domain.Campaigns; + +public interface ICampaignWriteRepository +{ + Task AddAsync(Campaign entity, CancellationToken cancellationToken = default); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task SaveChangesAsync(Campaign entity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Campaigns/IUnitCompletionWriteRepository.cs b/StackDuel.Domain/Campaigns/IUnitCompletionWriteRepository.cs new file mode 100644 index 0000000..0245ab7 --- /dev/null +++ b/StackDuel.Domain/Campaigns/IUnitCompletionWriteRepository.cs @@ -0,0 +1,8 @@ +using StackDuel.Domain.Campaigns.Entities; + +namespace StackDuel.Domain.Campaigns; + +public interface IUnitCompletionWriteRepository +{ + Task AddAsync(UnitCompletion entity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/DailyChallenges/Entities/DailyChallenge.cs b/StackDuel.Domain/DailyChallenges/Entities/DailyChallenge.cs new file mode 100644 index 0000000..42e9e32 --- /dev/null +++ b/StackDuel.Domain/DailyChallenges/Entities/DailyChallenge.cs @@ -0,0 +1,28 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.DailyChallenges.Entities; + +public sealed class DailyChallenge : Entity +{ + public DailyChallenge(DateOnly challengeDate, Guid problemId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + ChallengeDate = challengeDate; + ProblemId = problemId; + } + + private DailyChallenge() { } + + public void UpdateProblem(Guid problemId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + ProblemId = problemId; + } + + public DateOnly ChallengeDate { get; private set; } + public Guid ProblemId { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionAssets/Entities/AdditionalFileBundle.cs b/StackDuel.Domain/ExecutionAssets/Entities/AdditionalFileBundle.cs new file mode 100644 index 0000000..f61cd63 --- /dev/null +++ b/StackDuel.Domain/ExecutionAssets/Entities/AdditionalFileBundle.cs @@ -0,0 +1,31 @@ +using StackDuel.Domain.ExecutionAssets.Exceptions; +using StackDuel.Domain.ExecutionAssets.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ExecutionAssets.Entities; + +public sealed class AdditionalFileBundle : AggregateRoot +{ + public AdditionalFileBundle(AdditionalFileBundleName name, byte[] content) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Content = Validate(content); + CreatedAt = DateTime.UtcNow; + } + + public void UpdateContent(byte[] content) + { + Content = Validate(content); + } + + private static byte[] Validate(byte[] content) => + content is { Length: > 0 } + ? content + : throw new InvalidAdditionalFileBundleContentException("Content must not be empty."); + + private AdditionalFileBundle() { } + + public AdditionalFileBundleName Name { get; private set; } = null!; + public byte[] Content { get; private set; } = null!; + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionAssets/Exceptions/InvalidAdditionalFileBundleContentException.cs b/StackDuel.Domain/ExecutionAssets/Exceptions/InvalidAdditionalFileBundleContentException.cs new file mode 100644 index 0000000..c36296c --- /dev/null +++ b/StackDuel.Domain/ExecutionAssets/Exceptions/InvalidAdditionalFileBundleContentException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ExecutionAssets.Exceptions; + +public sealed class InvalidAdditionalFileBundleContentException : DomainException +{ + public InvalidAdditionalFileBundleContentException(string reason) + : base($"Additional file bundle content is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionAssets/Exceptions/InvalidAdditionalFileBundleNameException.cs b/StackDuel.Domain/ExecutionAssets/Exceptions/InvalidAdditionalFileBundleNameException.cs new file mode 100644 index 0000000..2129e59 --- /dev/null +++ b/StackDuel.Domain/ExecutionAssets/Exceptions/InvalidAdditionalFileBundleNameException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ExecutionAssets.Exceptions; + +public sealed class InvalidAdditionalFileBundleNameException : DomainException +{ + public InvalidAdditionalFileBundleNameException(string reason) + : base($"Additional file bundle name is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionAssets/ValueObjects/AdditionalFileBundleName.cs b/StackDuel.Domain/ExecutionAssets/ValueObjects/AdditionalFileBundleName.cs new file mode 100644 index 0000000..a01a46d --- /dev/null +++ b/StackDuel.Domain/ExecutionAssets/ValueObjects/AdditionalFileBundleName.cs @@ -0,0 +1,27 @@ +using StackDuel.Domain.ExecutionAssets.Exceptions; + +namespace StackDuel.Domain.ExecutionAssets.ValueObjects; + +public sealed record AdditionalFileBundleName +{ + public AdditionalFileBundleName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidAdditionalFileBundleNameException("Name cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidAdditionalFileBundleNameException( + $"Name must be between {MinLength} and {MaxLength} characters." + ); + + Value = value; + } + + public static implicit operator string(AdditionalFileBundleName name) => name.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 100; + public static readonly int MinLength = 1; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionPipelines/Entities/ExecutionPipelineStep.cs b/StackDuel.Domain/ExecutionPipelines/Entities/ExecutionPipelineStep.cs new file mode 100644 index 0000000..87c02f1 --- /dev/null +++ b/StackDuel.Domain/ExecutionPipelines/Entities/ExecutionPipelineStep.cs @@ -0,0 +1,41 @@ +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ExecutionPipelines.Entities; + +public sealed class ExecutionPipelineStep : Entity +{ + internal ExecutionPipelineStep( + ExecutionPipelineStepType stepType, + int stepOrder, + int maxAttempts, + int timeoutSeconds, + bool isPolling, + string name + ) + { + StepType = stepType; + StepOrder = stepOrder; + MaxAttempts = + maxAttempts > 0 + ? maxAttempts + : throw new ArgumentException("MaxAttempts must be positive.", nameof(maxAttempts)); + TimeoutSeconds = + timeoutSeconds > 0 + ? timeoutSeconds + : throw new ArgumentException("TimeoutSeconds must be positive.", nameof(timeoutSeconds)); + IsPolling = isPolling; + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + } + + private ExecutionPipelineStep() { } + + public string Name { get; private set; } = null!; + public ExecutionPipelineStepType StepType { get; private set; } + public int StepOrder { get; private set; } + public int MaxAttempts { get; private set; } + public int TimeoutSeconds { get; private set; } + public bool IsPolling { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionPipelines/Enums/ExecutionPipelineStepType.cs b/StackDuel.Domain/ExecutionPipelines/Enums/ExecutionPipelineStepType.cs new file mode 100644 index 0000000..3fd87a7 --- /dev/null +++ b/StackDuel.Domain/ExecutionPipelines/Enums/ExecutionPipelineStepType.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.ExecutionPipelines.Enums; + +public enum ExecutionPipelineStepType +{ + Judge0Execute = 1, + Judge0Poll = 2, + Evaluate = 3, + ParseOutput = 4, +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionPipelines/ExecutionPipeline.cs b/StackDuel.Domain/ExecutionPipelines/ExecutionPipeline.cs new file mode 100644 index 0000000..b41f727 --- /dev/null +++ b/StackDuel.Domain/ExecutionPipelines/ExecutionPipeline.cs @@ -0,0 +1,44 @@ +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ExecutionPipelines; + +public sealed class ExecutionPipeline : AggregateRoot +{ + public ExecutionPipeline(string name, string? description = null) + { + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + Description = description; + } + + public ExecutionPipelineStep AddStep( + ExecutionPipelineStepType stepType, + int stepOrder, + int maxAttempts, + int timeoutSeconds, + bool isPolling, + string name + ) + { + var step = new ExecutionPipelineStep(stepType, stepOrder, maxAttempts, timeoutSeconds, isPolling, name); + _steps.Add(step); + return step; + } + + public ExecutionPipelineStep? FirstStep() => _steps.OrderBy(s => s.StepOrder).FirstOrDefault(); + + public ExecutionPipelineStep? NextStep(int currentStepOrder) => + _steps.Where(s => s.StepOrder > currentStepOrder).OrderBy(s => s.StepOrder).FirstOrDefault(); + + private ExecutionPipeline() { } + + public string Name { get; private set; } = null!; + public string? Description { get; private set; } + + public IReadOnlyCollection Steps => _steps.AsReadOnly(); + + private readonly List _steps = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionPipelines/IExecutionPipelineRepository.cs b/StackDuel.Domain/ExecutionPipelines/IExecutionPipelineRepository.cs new file mode 100644 index 0000000..8f06e5a --- /dev/null +++ b/StackDuel.Domain/ExecutionPipelines/IExecutionPipelineRepository.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ExecutionPipelines; + +public interface IExecutionPipelineRepository : IRepository +{ + Task FindByIdWithStepsAsync(Guid id, CancellationToken cancellationToken = default); + Task FindIdByNameAsync(string name, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/ExecutionPipelines/WellKnownExecutionPipelines.cs b/StackDuel.Domain/ExecutionPipelines/WellKnownExecutionPipelines.cs new file mode 100644 index 0000000..d53b576 --- /dev/null +++ b/StackDuel.Domain/ExecutionPipelines/WellKnownExecutionPipelines.cs @@ -0,0 +1,6 @@ +namespace StackDuel.Domain.ExecutionPipelines; + +public static class WellKnownExecutionPipelines +{ + public const string DefaultName = "Default Judge0 Pipeline"; +} \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/Entities/FeatureFlag.cs b/StackDuel.Domain/FeatureFlags/Entities/FeatureFlag.cs new file mode 100644 index 0000000..f48f284 --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/Entities/FeatureFlag.cs @@ -0,0 +1,170 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Domain.FeatureFlags.Entities; + +public sealed class FeatureFlag +{ + internal FeatureFlag( + FeatureFlagId id, + FeatureFlagKey key, + string name, + string description, + bool defaultEnabled, + RolloutPercentage rolloutPercentage, + DateTimeOffset createdAt, + DateTimeOffset updatedAt + ) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name required.", nameof(name)); + if (description is null) + throw new ArgumentException("Description required.", nameof(description)); + + Id = id; + Key = key; + Name = name; + Description = description; + DefaultEnabled = defaultEnabled; + RolloutPercentage = rolloutPercentage; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + } + + public static FeatureFlag Create(FeatureFlagKey key, string name, string description, bool defaultEnabled) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + return new FeatureFlag( + new FeatureFlagId(Guid.NewGuid()), + key, + name, + description, + defaultEnabled, + RolloutPercentage.Zero, + now, + now + ); + } + + public void UpdateDetails(string name, string description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name required.", nameof(name)); + if (description is null) + throw new ArgumentException("Description required.", nameof(description)); + + Name = name; + Description = description; + Touch(); + } + + public void SetDefaultEnabled(bool enabled) + { + DefaultEnabled = enabled; + Touch(); + } + + public void SetRolloutPercentage(RolloutPercentage percentage) + { + RolloutPercentage = percentage; + Touch(); + } + + public void SetUserOverride(UserId userId, DecisionEffect effect) + { + FeatureFlagUserOverride? existing = _userOverrides.FirstOrDefault(o => o.UserId == userId); + if (existing is not null) + { + existing.UpdateEffect(effect); + } + else + { + _userOverrides.Add(new FeatureFlagUserOverride(userId, effect)); + } + + Touch(); + } + + public void RemoveUserOverride(UserId userId) + { + _userOverrides.RemoveWhere(o => o.UserId == userId); + Touch(); + } + + public void SetGroupOverride(GroupId groupId, DecisionEffect effect) + { + FeatureFlagGroupOverride? existing = _groupOverrides.FirstOrDefault(o => o.GroupId == groupId); + if (existing is not null) + { + existing.UpdateEffect(effect); + } + else + { + _groupOverrides.Add(new FeatureFlagGroupOverride(groupId, effect)); + } + + Touch(); + } + + public void RemoveGroupOverride(GroupId groupId) + { + _groupOverrides.RemoveWhere(o => o.GroupId == groupId); + Touch(); + } + + private void Touch() => UpdatedAt = DateTimeOffset.UtcNow; + + public FeatureFlagId Id { get; } + + public FeatureFlagKey Key { get; } + + public string Name { get; private set; } + + public string Description { get; private set; } + + public bool DefaultEnabled { get; private set; } + + public RolloutPercentage RolloutPercentage { get; private set; } + + public DateTimeOffset CreatedAt { get; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public IReadOnlyCollection UserOverrides => _userOverrides.ToList().AsReadOnly(); + + public IReadOnlyCollection GroupOverrides => _groupOverrides.ToList().AsReadOnly(); + + private readonly HashSet _userOverrides = []; + private readonly HashSet _groupOverrides = []; +} + +public sealed class FeatureFlagUserOverride +{ + internal FeatureFlagUserOverride(UserId userId, DecisionEffect effect) + { + UserId = userId; + Effect = effect; + } + + public UserId UserId { get; } + + public DecisionEffect Effect { get; private set; } + + internal void UpdateEffect(DecisionEffect effect) => Effect = effect; +} + +public sealed class FeatureFlagGroupOverride +{ + internal FeatureFlagGroupOverride(GroupId groupId, DecisionEffect effect) + { + GroupId = groupId; + Effect = effect; + } + + public GroupId GroupId { get; } + + public DecisionEffect Effect { get; private set; } + + internal void UpdateEffect(DecisionEffect effect) => Effect = effect; +} \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/IFeatureFlagEvaluationRepository.cs b/StackDuel.Domain/FeatureFlags/IFeatureFlagEvaluationRepository.cs new file mode 100644 index 0000000..a82c34a --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/IFeatureFlagEvaluationRepository.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Domain.FeatureFlags; + +public interface IFeatureFlagEvaluationRepository +{ + Task GetEvaluationDataAsync( + string flagKey, + Guid? userId, + CancellationToken cancellationToken + ); + + Task> GetAllEvaluationDataAsync( + Guid? userId, + CancellationToken cancellationToken + ); +} + +public sealed record FeatureFlagEvaluationData( + string Key, + bool DefaultEnabled, + int RolloutPercentage, + DecisionEffect? UserOverrideEffect, + DecisionEffect? GroupOverrideEffect +); \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/IFeatureFlagWriteRepository.cs b/StackDuel.Domain/FeatureFlags/IFeatureFlagWriteRepository.cs new file mode 100644 index 0000000..42edf70 --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/IFeatureFlagWriteRepository.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.FeatureFlags.Entities; + +namespace StackDuel.Domain.FeatureFlags; + +public interface IFeatureFlagWriteRepository +{ + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); + + Task FindByKeyAsync(string key, CancellationToken cancellationToken); + + Task AddAsync(FeatureFlag flag, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/ValueObjects/FeatureFlagId.cs b/StackDuel.Domain/FeatureFlags/ValueObjects/FeatureFlagId.cs new file mode 100644 index 0000000..1e304d6 --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/ValueObjects/FeatureFlagId.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.FeatureFlags.ValueObjects; + +public readonly record struct FeatureFlagId(Guid Value); \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/ValueObjects/FeatureFlagKey.cs b/StackDuel.Domain/FeatureFlags/ValueObjects/FeatureFlagKey.cs new file mode 100644 index 0000000..dd5d95f --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/ValueObjects/FeatureFlagKey.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace StackDuel.Domain.FeatureFlags.ValueObjects; + +public readonly partial record struct FeatureFlagKey +{ + public static readonly int MaxLength = 100; + + public FeatureFlagKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("FeatureFlagKey required.", nameof(value)); + + if (value.Length > MaxLength) + throw new ArgumentException($"FeatureFlagKey must be at most {MaxLength} characters.", nameof(value)); + + if (!KebabCaseRegex().IsMatch(value)) + throw new ArgumentException( + "FeatureFlagKey must be lowercase kebab-case, e.g. 'leaderboards'.", + nameof(value) + ); + + Value = value; + } + + public string Value { get; } + + [GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")] + private static partial Regex KebabCaseRegex(); +} \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/ValueObjects/RolloutPercentage.cs b/StackDuel.Domain/FeatureFlags/ValueObjects/RolloutPercentage.cs new file mode 100644 index 0000000..11cdb1c --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/ValueObjects/RolloutPercentage.cs @@ -0,0 +1,16 @@ +namespace StackDuel.Domain.FeatureFlags.ValueObjects; + +public readonly record struct RolloutPercentage +{ + public static readonly RolloutPercentage Zero = new(0); + + public RolloutPercentage(int value) + { + if (value is < 0 or > 100) + throw new ArgumentException("RolloutPercentage must be between 0 and 100.", nameof(value)); + + Value = value; + } + + public int Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/FeatureFlags/WellKnownFeatureFlags.cs b/StackDuel.Domain/FeatureFlags/WellKnownFeatureFlags.cs new file mode 100644 index 0000000..88a893c --- /dev/null +++ b/StackDuel.Domain/FeatureFlags/WellKnownFeatureFlags.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.FeatureFlags; + +public static class WellKnownFeatureFlags +{ + public const string Leaderboards = "leaderboards"; + public const string Campaigns = "campaigns"; + public const string Ratings = "ratings"; +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Entities/FeedbackSubmission.cs b/StackDuel.Domain/Feedback/Entities/FeedbackSubmission.cs new file mode 100644 index 0000000..f911b46 --- /dev/null +++ b/StackDuel.Domain/Feedback/Entities/FeedbackSubmission.cs @@ -0,0 +1,56 @@ +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.Feedback.Events; +using StackDuel.Domain.Feedback.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Feedback.Entities; + +public sealed class FeedbackSubmission : AggregateRoot +{ + public FeedbackSubmission( + Guid userId, + FeedbackType type, + FeedbackMessage message, + FeedbackRating? rating, + FeedbackContextType contextType, + Guid? contextEntityId, + string? pageUrl, + string? userAgent + ) + { + UserId = userId; + Type = type; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Rating = rating; + ContextType = contextType; + ContextEntityId = contextEntityId; + PageUrl = pageUrl; + UserAgent = userAgent; + Status = FeedbackStatus.New; + CreatedAt = DateTime.UtcNow; + + AddDomainEvent(new FeedbackSubmittedDomainEvent(Id, UserId, Type)); + } + + public void UpdateStatus(FeedbackStatus status, string? adminNote) + { + Status = status; + AdminNote = adminNote; + UpdatedAt = DateTime.UtcNow; + } + + private FeedbackSubmission() { } + + public Guid UserId { get; private set; } + public FeedbackType Type { get; private set; } + public FeedbackMessage Message { get; private set; } = null!; + public FeedbackRating? Rating { get; private set; } + public FeedbackContextType ContextType { get; private set; } + public Guid? ContextEntityId { get; private set; } + public FeedbackStatus Status { get; private set; } + public string? AdminNote { get; private set; } + public string? PageUrl { get; private set; } + public string? UserAgent { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? UpdatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Enums/FeedbackContextType.cs b/StackDuel.Domain/Feedback/Enums/FeedbackContextType.cs new file mode 100644 index 0000000..7afa136 --- /dev/null +++ b/StackDuel.Domain/Feedback/Enums/FeedbackContextType.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.Feedback.Enums; + +public enum FeedbackContextType +{ + None, + Problem, + Game, + Submission, +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Enums/FeedbackStatus.cs b/StackDuel.Domain/Feedback/Enums/FeedbackStatus.cs new file mode 100644 index 0000000..c429b15 --- /dev/null +++ b/StackDuel.Domain/Feedback/Enums/FeedbackStatus.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Domain.Feedback.Enums; + +public enum FeedbackStatus +{ + New, + Triaged, + InProgress, + Resolved, + WontFix, +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Enums/FeedbackType.cs b/StackDuel.Domain/Feedback/Enums/FeedbackType.cs new file mode 100644 index 0000000..eec445b --- /dev/null +++ b/StackDuel.Domain/Feedback/Enums/FeedbackType.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.Feedback.Enums; + +public enum FeedbackType +{ + Bug, + FeatureRequest, + Question, + General, +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Events/FeedbackSubmittedDomainEvent.cs b/StackDuel.Domain/Feedback/Events/FeedbackSubmittedDomainEvent.cs new file mode 100644 index 0000000..7d88921 --- /dev/null +++ b/StackDuel.Domain/Feedback/Events/FeedbackSubmittedDomainEvent.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Feedback.Events; + +public sealed record FeedbackSubmittedDomainEvent(Guid FeedbackSubmissionId, Guid UserId, FeedbackType Type) + : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Exceptions/InvalidFeedbackMessageException.cs b/StackDuel.Domain/Feedback/Exceptions/InvalidFeedbackMessageException.cs new file mode 100644 index 0000000..08566c5 --- /dev/null +++ b/StackDuel.Domain/Feedback/Exceptions/InvalidFeedbackMessageException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Feedback.Exceptions; + +public sealed class InvalidFeedbackMessageException : DomainException +{ + public InvalidFeedbackMessageException(string reason) + : base($"Feedback message is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Exceptions/InvalidFeedbackRatingException.cs b/StackDuel.Domain/Feedback/Exceptions/InvalidFeedbackRatingException.cs new file mode 100644 index 0000000..b030b09 --- /dev/null +++ b/StackDuel.Domain/Feedback/Exceptions/InvalidFeedbackRatingException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Feedback.Exceptions; + +public sealed class InvalidFeedbackRatingException : DomainException +{ + public InvalidFeedbackRatingException(string reason) + : base($"Feedback rating is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/Factories/FeedbackSubmissionFactory.cs b/StackDuel.Domain/Feedback/Factories/FeedbackSubmissionFactory.cs new file mode 100644 index 0000000..3e2991f --- /dev/null +++ b/StackDuel.Domain/Feedback/Factories/FeedbackSubmissionFactory.cs @@ -0,0 +1,34 @@ +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Domain.Feedback.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Feedback.Factories; + +public sealed record CreateFeedbackSubmissionParams( + Guid UserId, + FeedbackType Type, + FeedbackMessage Message, + FeedbackRating? Rating, + FeedbackContextType ContextType, + Guid? ContextEntityId, + string? PageUrl, + string? UserAgent +); + +public sealed class FeedbackSubmissionFactory : IAggregateFactory +{ + public FeedbackSubmission Create(CreateFeedbackSubmissionParams parameters) + { + return new FeedbackSubmission( + parameters.UserId, + parameters.Type, + parameters.Message, + parameters.Rating, + parameters.ContextType, + parameters.ContextEntityId, + parameters.PageUrl, + parameters.UserAgent + ); + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/IFeedbackWriteRepository.cs b/StackDuel.Domain/Feedback/IFeedbackWriteRepository.cs new file mode 100644 index 0000000..8c79fda --- /dev/null +++ b/StackDuel.Domain/Feedback/IFeedbackWriteRepository.cs @@ -0,0 +1,6 @@ +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Feedback; + +public interface IFeedbackWriteRepository : IRepository { } \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/ValueObjects/FeedbackMessage.cs b/StackDuel.Domain/Feedback/ValueObjects/FeedbackMessage.cs new file mode 100644 index 0000000..940b98d --- /dev/null +++ b/StackDuel.Domain/Feedback/ValueObjects/FeedbackMessage.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Feedback.Exceptions; + +namespace StackDuel.Domain.Feedback.ValueObjects; + +public sealed record FeedbackMessage +{ + public FeedbackMessage(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidFeedbackMessageException("Message cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidFeedbackMessageException($"Message cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(FeedbackMessage message) => message.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 2000; + + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Feedback/ValueObjects/FeedbackRating.cs b/StackDuel.Domain/Feedback/ValueObjects/FeedbackRating.cs new file mode 100644 index 0000000..a8dcc35 --- /dev/null +++ b/StackDuel.Domain/Feedback/ValueObjects/FeedbackRating.cs @@ -0,0 +1,23 @@ +using StackDuel.Domain.Feedback.Exceptions; + +namespace StackDuel.Domain.Feedback.ValueObjects; + +public sealed record FeedbackRating +{ + public FeedbackRating(int value) + { + if (value < MinValue || value > MaxValue) + throw new InvalidFeedbackRatingException($"Rating must be between {MinValue} and {MaxValue}."); + + Value = value; + } + + public static implicit operator int(FeedbackRating rating) => rating.Value; + + public override string ToString() => Value.ToString(); + + public static readonly int MinValue = 1; + public static readonly int MaxValue = 5; + + public int Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/Game.cs b/StackDuel.Domain/Games/Entities/Game.cs new file mode 100644 index 0000000..219ebbd --- /dev/null +++ b/StackDuel.Domain/Games/Entities/Game.cs @@ -0,0 +1,395 @@ +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Games.Events; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class Game : AggregateRoot +{ + public Game( + Guid gameModeId, + Guid poolId, + IEnumerable trackIds, + IEnumerable playerUserIds, + int timeLimitInSeconds, + Guid? lobbyId = null, + IReadOnlyDictionary>? trackLanguageIds = null, + bool skipsEnabled = true + ) + { + if (gameModeId == Guid.Empty) + throw new ArgumentException("Game mode id must not be empty.", nameof(gameModeId)); + + if (poolId == Guid.Empty) + throw new ArgumentException("Pool id must not be empty.", nameof(poolId)); + + Guid[] trackIdArray = + trackIds?.Where(id => id != Guid.Empty).Distinct().ToArray() + ?? throw new ArgumentNullException(nameof(trackIds)); + + if (trackIdArray.Length == 0) + throw new ArgumentException("At least one track is required.", nameof(trackIds)); + + if (timeLimitInSeconds <= 0) + throw new ArgumentException("Time limit must be greater than zero.", nameof(timeLimitInSeconds)); + + Guid[] participantIds = + playerUserIds?.Where(id => id != Guid.Empty).Distinct().ToArray() + ?? throw new ArgumentNullException(nameof(playerUserIds)); + + if (participantIds.Length == 0) + throw new ArgumentException("At least one player is required.", nameof(playerUserIds)); + + GameModeId = gameModeId; + PoolId = poolId; + LobbyId = lobbyId; + TimeLimitInSeconds = timeLimitInSeconds; + SkipsEnabled = skipsEnabled; + CreatedAt = DateTime.UtcNow; + Status = GameStatus.Pending; + JoinCode = GameJoinCode.Generate(); + + foreach (Guid trackId in trackIdArray) + { + IReadOnlyList languageIds = + trackLanguageIds is not null && trackLanguageIds.TryGetValue(trackId, out IReadOnlyList? ids) + ? ids + : []; + + _tracks.Add(new GameTrack(trackId, languageIds)); + } + + int seatNo = 1; + foreach (Guid userId in participantIds) + { + _participants.Add(new GameParticipant(userId, seatNo)); + seatNo++; + } + } + + public void Join(Guid userId, int maxPlayers) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + if (Status != GameStatus.Pending) + throw new InvalidOperationException("Only pending games can be joined."); + + if (_participants.Any(p => p.UserId == userId)) + throw new InvalidOperationException("User is already a participant in this game."); + + if (_participants.Count >= maxPlayers) + throw new InvalidOperationException("Game is already full."); + + // One past the highest seat currently in use, not _participants.Count + 1 — a seat + // freed by Leave must never be reissued while a lower-numbered seat is still taken, + // or the new participant would collide with it on the (game_id, seat_no) unique index. + int nextSeatNo = _participants.Count == 0 ? 1 : _participants.Max(p => p.SeatNo) + 1; + _participants.Add(new GameParticipant(userId, nextSeatNo)); + + AddDomainEvent(new GameLobbyUpdatedDomainEvent(Id)); + } + + /// + /// Removes a participant from a still-Pending lobby. If they were the last one left, the + /// lobby is cancelled outright — there's no one left to host it. Deliberately never + /// renumbers the remaining seats: is always whoever currently holds + /// the lowest seat number, so a non-host leaving just shrinks the roster, and a host leaving + /// hands the role to the next-lowest seat automatically without touching anyone else's row. + /// + public void Leave(Guid userId) + { + if (Status != GameStatus.Pending) + throw new InvalidOperationException("Only pending games can be left."); + + GameParticipant participant = + _participants.FirstOrDefault(p => p.UserId == userId) + ?? throw new InvalidOperationException("User is not a participant in this game."); + + _participants.Remove(participant); + + if (_participants.Count == 0) + { + Cancel(); + } + else + { + AddDomainEvent(new GameLobbyUpdatedDomainEvent(Id)); + } + } + + /// + /// Closes the lobby outright — cancels it regardless of how many participants remain, unlike + /// (which only removes the caller and keeps the lobby going for whoever's + /// left, if anyone). Only valid while still Pending — once the game has started there's no + /// "close", only Forfeit or letting the clock run out. Authorization (host-only) is the + /// caller's responsibility, matching Start/Forfeit/etc. + /// + public void CloseLobby() + { + if (Status != GameStatus.Pending) + throw new InvalidOperationException("Only a pending lobby can be closed."); + + Cancel(); + + AddDomainEvent(new GameLobbyUpdatedDomainEvent(Id)); + } + + /// + /// The current host: whoever holds the lowest seat number among current participants. Not + /// necessarily seat 1 — Leave never renumbers, so if the original seat-1 host leaves, the + /// role passes to whoever has the next-lowest seat instead. Null only for an empty + /// (Cancelled) game, since every Game always has at least one participant otherwise. + /// + public Guid? HostUserId => _participants.OrderBy(p => p.SeatNo).FirstOrDefault()?.UserId; + + public void Start(int countdownSeconds = 0) + { + if (Status != GameStatus.Pending) + throw new InvalidOperationException("Only pending games can be started."); + + Status = GameStatus.Running; + StartedAt = DateTime.UtcNow.AddSeconds(countdownSeconds); + + AddDomainEvent(new GameStartedDomainEvent(Id, StartedAt.Value, TimeLimitInSeconds)); + AddDomainEvent(new GameLobbyUpdatedDomainEvent(Id)); + } + + public void Complete() + { + if (Status != GameStatus.Running) + throw new InvalidOperationException("Only running games can be completed."); + + Status = GameStatus.Completed; + EndedAt = DateTime.UtcNow; + + AddDomainEvent(new GameCompletedDomainEvent(Id, Status, EndedAt.Value)); + } + + /// + /// True when a Running game's time limit has elapsed. Used as a defense-in-depth check — + /// independent of whether the scheduled GameTimeExpiredMessage/sweep job has finalized the + /// game yet — anywhere the game is read or acted on, so a slow/lost message can never let + /// the game outlive its clock. + /// + public bool HasExpired(DateTime nowUtc) => + Status == GameStatus.Running + && StartedAt is not null + && nowUtc >= StartedAt.Value.AddSeconds(TimeLimitInSeconds); + + /// + /// Completes the game if it has expired, returning whether it did. Callers that mutate + /// state on a Running game (submit, complete-problem) or read it (get game state) should + /// call this first so an expired game is always finalized before anything else happens, + /// regardless of whether the message-bus expiry path has caught up yet. + /// + public bool CompleteIfExpired(DateTime nowUtc) + { + if (!HasExpired(nowUtc)) + return false; + + Complete(); + return true; + } + + public void Cancel() + { + if (Status == GameStatus.Completed || Status == GameStatus.Cancelled) + throw new InvalidOperationException("Completed or cancelled games cannot be cancelled again."); + + Status = GameStatus.Cancelled; + EndedAt = DateTime.UtcNow; + } + + /// + /// Forfeits one participant. The game itself only ends once every participant has stopped + /// playing (see ) — in a multiplayer mode, one player + /// quitting shouldn't cut the others' run short; they keep playing (and can still improve + /// their score) until the clock runs out or everyone else has also stopped, at which point + /// the highest score among all participants wins. + /// + public void Forfeit(Guid userId) + { + if (Status != GameStatus.Running) + throw new InvalidOperationException("Only running games can be forfeited."); + + GameParticipant participant = + _participants.FirstOrDefault(p => p.UserId == userId) + ?? throw new InvalidOperationException("User is not a participant in this game."); + + participant.Forfeit(); + + CompleteIfEveryoneStopped(); + } + + /// + /// Everything CompleteProblemHandler and SkipProblemHandler need to know before touching a + /// participant's current problem, in one call: is there such a participant, did the game just + /// auto-complete because its clock ran out (checked here, before the running-check, exactly + /// like the two handlers used to do it independently), and — if it's still running — is this + /// participant actually eligible to attempt against + /// . Forfeit isn't covered here: it doesn't target a problem, so it + /// only ever needed . + /// + public GameProblemAttempt EvaluateProblemAttempt( + Guid userId, + Guid problemId, + ParticipantAction action, + DateTime nowUtc + ) + { + GameParticipant? participant = _participants.FirstOrDefault(p => p.UserId == userId); + if (participant is null) + return new GameProblemAttempt(null, GameJustExpired: false, ProblemAttemptEligibility.ParticipantNotFound); + + bool justExpired = CompleteIfExpired(nowUtc); + + if (Status != GameStatus.Running) + return new GameProblemAttempt(participant, justExpired, ProblemAttemptEligibility.GameNotRunning); + + return new GameProblemAttempt(participant, justExpired, participant.CanAttemptProblem(action, problemId)); + } + + /// + /// Records a solved problem for a participant — bumps their score and raises + /// GameProgressUpdatedDomainEvent so opponents watching the Score tab find out promptly + /// instead of seeing stale numbers until something else happens to trigger a refetch. + /// + public void RecordProblemSolved(Guid userId) + { + if (Status != GameStatus.Running) + throw new InvalidOperationException("Only running games can record progress."); + + GameParticipant participant = + _participants.FirstOrDefault(p => p.UserId == userId) + ?? throw new InvalidOperationException("User is not a participant in this game."); + + participant.IncrementScore(); + + AddDomainEvent(new GameProgressUpdatedDomainEvent(Id)); + } + + /// + /// Marks a participant as having exhausted the problem pool. Same "doesn't end the game alone" + /// rule as — they're just done for a different reason (nothing left to + /// solve, rather than quitting). + /// + public void FinishProblemsFor(Guid userId) + { + if (Status != GameStatus.Running) + throw new InvalidOperationException("Only running games can be updated."); + + GameParticipant participant = + _participants.FirstOrDefault(p => p.UserId == userId) + ?? throw new InvalidOperationException("User is not a participant in this game."); + + participant.FinishProblems(); + + CompleteIfEveryoneStopped(); + } + + /// + /// Completes the game if every participant has stopped playing (forfeited or run out of + /// problems — any mix), returning whether it did. Called after Forfeit/FinishProblemsFor, and + /// also safe to call from a read path (e.g. GetGameState) as a self-healing check: two + /// participants stopping via concurrent requests can each read the game before the other's + /// write lands, so neither sees "everyone's done" and the game is left Running with nothing + /// left to happen in it until the expiry sweep eventually catches it. Calling this on the next + /// read closes that window immediately instead of waiting for the clock. + /// + public bool CompleteIfEveryoneStopped() + { + if (Status != GameStatus.Running) + return false; + + if (!_participants.All(p => p.HasStoppedPlaying)) + return false; + + Complete(); + return true; + } + + /// + /// The problem id every participant who has reached was (or will + /// be) given, if anyone has reached it yet. Null means no participant has gotten there — + /// the caller is the first, and must generate one and record it via + /// so every later arrival reuses the exact same problem instead + /// of each participant rolling independently. + /// + public Guid? ProblemIdAtPosition(int position) => + _problemSequence.FirstOrDefault(p => p.Position == position)?.ProblemId; + + /// + /// Extends the shared problem sequence by one slot, at whatever the next position is. Only + /// ever called after came back null for that position — this + /// is what makes the sequence a single shared list instead of independent per-participant + /// selection: the first participant to reach a new position generates it, and it's persisted + /// here so everyone after them lands on the identical problem. + /// + public void AppendProblem(Guid problemId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + int nextPosition = _problemSequence.Count; + _problemSequence.Add(new GameProblem(nextPosition, problemId)); + } + + /// + /// Records the transport handle (e.g. Azure Service Bus sequence number) for the scheduled + /// GameTimeExpiredMessage tied to this run of the game, so it can be cancelled later if the + /// game ends early (forfeit) or gets rescheduled. + /// + public void AttachScheduledExpiry(long sequenceNumber) + { + ScheduledExpirySequenceNumber = sequenceNumber; + } + + /// + /// Clears the recorded scheduled-expiry handle once it's no longer relevant — e.g. after a + /// cancellation attempt (successful or not) on early forfeit. Safe to call regardless of + /// whether the underlying transport could actually honor the cancellation; the + /// GameTimeExpiredMessage consumer re-verifies game state independently either way. + /// + public void ClearScheduledExpiry() + { + ScheduledExpirySequenceNumber = null; + } + + private Game() { } + + public Guid? LobbyId { get; private set; } + public Guid GameModeId { get; private set; } + public string JoinCode { get; private set; } + public int TimeLimitInSeconds { get; private set; } + public bool SkipsEnabled { get; private set; } + public GameStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? StartedAt { get; private set; } + public DateTime? EndedAt { get; private set; } + + /// + /// Azure Service Bus sequence number for the scheduled expiry message, when the active + /// transport supports cancellation by handle. Null on RabbitMQ (no such handle exists) or + /// before the domain event handler has attached it. + /// + public long? ScheduledExpirySequenceNumber { get; private set; } + + public Guid PoolId { get; private set; } + + public IReadOnlyCollection Participants => _participants.AsReadOnly(); + + public IReadOnlyCollection Tracks => _tracks.AsReadOnly(); + + /// + /// The shared problem sequence for this game, in position order. Every participant advances + /// through this same list rather than each getting an independently-selected run of + /// problems — see and . + /// + public IReadOnlyCollection ProblemSequence => _problemSequence.AsReadOnly(); + + private readonly List _participants = []; + private readonly List _tracks = []; + private readonly List _problemSequence = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameMode.cs b/StackDuel.Domain/Games/Entities/GameMode.cs new file mode 100644 index 0000000..d3865a1 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameMode.cs @@ -0,0 +1,87 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class GameMode : AggregateRoot +{ + public GameMode( + string key, + string name, + string description, + bool isBuiltIn, + int minPlayers, + int maxPlayers, + Guid defaultPoolId + ) + { + Key = !string.IsNullOrWhiteSpace(key) + ? key.Trim().ToLowerInvariant() + : throw new ArgumentException("Mode key must not be empty.", nameof(key)); + + Name = !string.IsNullOrWhiteSpace(name) + ? name.Trim() + : throw new ArgumentException("Mode name must not be empty.", nameof(name)); + + Description = !string.IsNullOrWhiteSpace(description) + ? description.Trim() + : throw new ArgumentException("Mode description must not be empty.", nameof(description)); + + if (minPlayers <= 0) + throw new ArgumentException("Min players must be greater than zero.", nameof(minPlayers)); + + if (maxPlayers < minPlayers) + throw new ArgumentException( + "Max players must be greater than or equal to min players.", + nameof(maxPlayers) + ); + + IsBuiltIn = isBuiltIn; + IsActive = true; + MinPlayers = minPlayers; + MaxPlayers = maxPlayers; + CreatedAt = DateTime.UtcNow; + DefaultPoolId = defaultPoolId; + } + + public GameModeTimeOption AddTimeOption(int durationSeconds, bool isDefault = false) + { + if (_timeOptions.Any(option => option.DurationSeconds == durationSeconds)) + throw new InvalidOperationException($"Time option {durationSeconds} seconds already exists for this mode."); + + if (isDefault) + { + foreach (GameModeTimeOption option in _timeOptions) + option.MarkAsDefault(false); + } + + GameModeTimeOption timeOption = new(durationSeconds, isDefault); + _timeOptions.Add(timeOption); + return timeOption; + } + + public void Deactivate() => IsActive = false; + + private GameMode() { } + + public string Key { get; private set; } = null!; + + public string Name { get; private set; } = null!; + + public string Description { get; private set; } = null!; + + public bool IsBuiltIn { get; private set; } + + public bool IsActive { get; private set; } + + public int MinPlayers { get; private set; } + + public int MaxPlayers { get; private set; } + + public DateTime CreatedAt { get; private set; } + + public Guid DefaultPoolId { get; private set; } + + public IReadOnlyCollection TimeOptions => _timeOptions.AsReadOnly(); + + private readonly List _timeOptions = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameModeTimeOption.cs b/StackDuel.Domain/Games/Entities/GameModeTimeOption.cs new file mode 100644 index 0000000..1823e89 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameModeTimeOption.cs @@ -0,0 +1,26 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class GameModeTimeOption : Entity +{ + internal GameModeTimeOption(int durationSeconds, bool isDefault) + { + if (durationSeconds <= 0) + throw new ArgumentException("Duration must be greater than zero.", nameof(durationSeconds)); + + DurationSeconds = durationSeconds; + IsDefault = isDefault; + } + + internal void MarkAsDefault(bool isDefault) + { + IsDefault = isDefault; + } + + private GameModeTimeOption() { } + + public int DurationSeconds { get; private set; } + + public bool IsDefault { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameParticipant.cs b/StackDuel.Domain/Games/Entities/GameParticipant.cs new file mode 100644 index 0000000..59660f3 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameParticipant.cs @@ -0,0 +1,220 @@ +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.Games.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class GameParticipant : Entity +{ + public const int TotalSkips = 3; + + public GameParticipant(Guid userId, int seatNo) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + if (seatNo <= 0) + throw new ArgumentException("Seat number must be greater than zero.", nameof(seatNo)); + + UserId = userId; + SeatNo = seatNo; + JoinedAt = DateTime.UtcNow; + Score = 0; + SkipsRemaining = TotalSkips; + ProblemSession = null; + } + + private GameParticipant() { } + + public Guid UserId { get; private set; } + + public int SeatNo { get; private set; } + + public DateTime JoinedAt { get; private set; } + + public int Score { get; private set; } + + public int SkipsRemaining { get; private set; } + + public GameProblemSession? ProblemSession { get; private set; } + + public DateTime? ForfeitedAt { get; private set; } + + public bool HasForfeited => ForfeitedAt is not null; + + public DateTime? FinishedAt { get; private set; } + + public bool HasFinishedProblems => FinishedAt is not null; + + /// + /// True once this participant has stopped playing for any reason — quit () + /// or ran out of problems to solve (). Game completion (see + /// Game.Forfeit/Game.FinishProblemsFor) is driven by every participant reaching this, not by + /// either reason alone. + /// + public bool HasStoppedPlaying => HasForfeited || HasFinishedProblems; + + /// + /// Single source of truth for participant lifecycle, derived from and + /// rather than stored directly, so the two can never disagree. Backs + /// — see that for why callers should gate actions through this instead + /// of checking / individually. + /// + public ParticipantPlayState PlayState + { + get + { + if (HasForfeited) + return ParticipantPlayState.Forfeited; + + return HasFinishedProblems ? ParticipantPlayState.Finished : ParticipantPlayState.InProgress; + } + } + + private static readonly Dictionary AllowedActions = new() + { + [ParticipantPlayState.InProgress] = + [ + ParticipantAction.CompleteProblem, + ParticipantAction.SkipProblem, + ParticipantAction.Forfeit, + ], + [ParticipantPlayState.Finished] = [], + [ParticipantPlayState.Forfeited] = [], + }; + + /// + /// Whether this participant may currently attempt . The single table + /// CompleteProblemHandler, SkipProblemHandler, and ForfeitGameHandler all check against, instead + /// of each re-deriving "has this participant stopped playing" independently. + /// + public bool CanPerform(ParticipantAction action) => AllowedActions[PlayState].Contains(action); + + /// + /// Whether this participant may attempt against + /// right now: still playing, has an initialized session, and that session's current problem is + /// the one being acted on. Backs — see that for the + /// game-level checks (participant exists, game running) layered around this. + /// + public ProblemAttemptEligibility CanAttemptProblem(ParticipantAction action, Guid problemId) + { + if (!CanPerform(action)) + return ProblemAttemptEligibility.ParticipantStopped; + + if (ProblemSession is null) + return ProblemAttemptEligibility.ProblemSessionNotInitialized; + + if (ProblemSession.CurrentProblemId != problemId) + return ProblemAttemptEligibility.ProblemMismatch; + + return ProblemAttemptEligibility.Eligible; + } + + public void Forfeit() + { + if (HasStoppedPlaying) + throw new InvalidOperationException("Participant has already stopped playing."); + + ForfeitedAt = DateTime.UtcNow; + } + + /// + /// Marks this participant as having exhausted the problem pool — there was nothing left to + /// serve them (see DifficultyRampProblemSelectionStrategy returning null). Distinct from + /// forfeiting: they didn't quit, they ran out of content. + /// + public void FinishProblems() + { + if (HasStoppedPlaying) + throw new InvalidOperationException("Participant has already stopped playing."); + + FinishedAt = DateTime.UtcNow; + } + + public void InitializeProblemSession(Guid initialProblemId) + { + if (ProblemSession is not null) + throw new InvalidOperationException("Problem session is already initialized."); + + ProblemSession = new GameProblemSession(initialProblemId); + } + + public void IncrementScore() + { + Score++; + } + + public void SetActiveSubmission(Guid submissionId) + { + if (ProblemSession is null) + throw new InvalidOperationException("Problem session is not initialized."); + + // Replace reference so EF Core detects the change (jsonb value conversion snapshot issue). + var next = new GameProblemSession(ProblemSession.CurrentProblemId); + foreach (var solvedId in ProblemSession.SolvedProblemIds) + next.AddSolvedProblem(solvedId); + foreach (var skippedId in ProblemSession.SkippedProblemIds) + next.AddSkippedProblem(skippedId); + foreach (var (problemId, recordedSubmissionId) in ProblemSession.SolvedProblemSubmissionIds) + next.RecordSolvedSubmission(problemId, recordedSubmissionId); + next.SetActiveSubmission(submissionId); + ProblemSession = next; + } + + public void AdvanceProblem(Guid nextProblemId) + { + if (ProblemSession is null) + throw new InvalidOperationException("Problem session is not initialized."); + + // Build a new GameProblemSession instance rather than mutating in place. + // ProblemSession is stored as a jsonb column with a value conversion; + // EF Core's snapshot points to the same object reference, so in-place + // mutations are invisible to change detection. Replacing the reference + // guarantees the property is detected as changed and saved. + var next = new GameProblemSession(nextProblemId); + foreach (var solvedId in ProblemSession.SolvedProblemIds) + next.AddSolvedProblem(solvedId); + next.AddSolvedProblem(ProblemSession.CurrentProblemId); + foreach (var skippedId in ProblemSession.SkippedProblemIds) + next.AddSkippedProblem(skippedId); + + foreach (var (problemId, recordedSubmissionId) in ProblemSession.SolvedProblemSubmissionIds) + next.RecordSolvedSubmission(problemId, recordedSubmissionId); + if (ProblemSession.ActiveSubmissionId is { } activeSubmissionId) + next.RecordSolvedSubmission(ProblemSession.CurrentProblemId, activeSubmissionId); + + ProblemSession = next; + } + + public void SkipToProblem(Guid nextProblemId) + { + if (ProblemSession is null) + throw new InvalidOperationException("Problem session is not initialized."); + + UseSkip(); + + // Same rebuild-rather-than-mutate approach as AdvanceProblem (jsonb value + // conversion snapshot issue). The skipped problem goes to SkippedProblemIds + // instead of SolvedProblemIds, so it's excluded from future selection without + // counting toward the participant's score. + var next = new GameProblemSession(nextProblemId); + foreach (var solvedId in ProblemSession.SolvedProblemIds) + next.AddSolvedProblem(solvedId); + foreach (var skippedId in ProblemSession.SkippedProblemIds) + next.AddSkippedProblem(skippedId); + next.AddSkippedProblem(ProblemSession.CurrentProblemId); + + foreach (var (problemId, recordedSubmissionId) in ProblemSession.SolvedProblemSubmissionIds) + next.RecordSolvedSubmission(problemId, recordedSubmissionId); + + ProblemSession = next; + } + + public void UseSkip() + { + if (SkipsRemaining <= 0) + throw new InvalidOperationException("No skips remaining."); + + SkipsRemaining--; + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameProblem.cs b/StackDuel.Domain/Games/Entities/GameProblem.cs new file mode 100644 index 0000000..532f2c7 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameProblem.cs @@ -0,0 +1,31 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +/// +/// One slot in a game's shared problem sequence — every participant who reaches this +/// is given this same . The sequence is built +/// lazily (see ): a position only exists once some participant +/// has actually reached it, so a fast solo player doesn't force problems to be generated for +/// rounds no one else will ever play. +/// +public sealed class GameProblem : Entity +{ + public GameProblem(int position, Guid problemId) + { + if (position < 0) + throw new ArgumentException("Position must not be negative.", nameof(position)); + + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + Position = position; + ProblemId = problemId; + } + + private GameProblem() { } + + public int Position { get; private set; } + + public Guid ProblemId { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameProblemAttempt.cs b/StackDuel.Domain/Games/Entities/GameProblemAttempt.cs new file mode 100644 index 0000000..0cc7b53 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameProblemAttempt.cs @@ -0,0 +1,19 @@ +using StackDuel.Domain.Games.Enums; + +namespace StackDuel.Domain.Games.Entities; + +/// +/// Outcome of — everything CompleteProblemHandler and +/// SkipProblemHandler need to decide what to do next, in one value instead of re-derived +/// independently in each: whether the game happened to auto-complete from expiry as a side effect +/// of the check (the caller must persist that regardless of ), and whether +/// the participant may actually attempt the action. +/// +public readonly record struct GameProblemAttempt( + GameParticipant? Participant, + bool GameJustExpired, + ProblemAttemptEligibility Eligibility +) +{ + public bool IsEligible => Eligibility == ProblemAttemptEligibility.Eligible; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameTrack.cs b/StackDuel.Domain/Games/Entities/GameTrack.cs new file mode 100644 index 0000000..368005a --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameTrack.cs @@ -0,0 +1,31 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class GameTrack : Entity +{ + public GameTrack(Guid trackId, IEnumerable languageIds) + { + if (trackId == Guid.Empty) + throw new ArgumentException("Track id must not be empty.", nameof(trackId)); + + TrackId = trackId; + + Guid[] languageIdArray = + languageIds?.Where(id => id != Guid.Empty).Distinct().ToArray() + ?? throw new ArgumentNullException(nameof(languageIds)); + + foreach (Guid languageId in languageIdArray) + { + _languages.Add(new GameTrackLanguage(languageId)); + } + } + + private GameTrack() { } + + public Guid TrackId { get; private set; } + + public IReadOnlyCollection Languages => _languages.AsReadOnly(); + + private readonly List _languages = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/GameTrackLanguage.cs b/StackDuel.Domain/Games/Entities/GameTrackLanguage.cs new file mode 100644 index 0000000..3005bab --- /dev/null +++ b/StackDuel.Domain/Games/Entities/GameTrackLanguage.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class GameTrackLanguage : Entity +{ + public GameTrackLanguage(Guid languageId) + { + if (languageId == Guid.Empty) + throw new ArgumentException("Language id must not be empty.", nameof(languageId)); + + LanguageId = languageId; + } + + private GameTrackLanguage() { } + + public Guid LanguageId { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/Leaderboard.cs b/StackDuel.Domain/Games/Entities/Leaderboard.cs new file mode 100644 index 0000000..7328303 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/Leaderboard.cs @@ -0,0 +1,49 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class Leaderboard : AggregateRoot +{ + public Leaderboard(Guid gameModeId, int timeLimitInSeconds) + { + if (gameModeId == Guid.Empty) + throw new ArgumentException("Game mode id must not be empty.", nameof(gameModeId)); + if (timeLimitInSeconds <= 0) + throw new ArgumentException("Time limit must be greater than zero.", nameof(timeLimitInSeconds)); + GameModeId = gameModeId; + TimeLimitInSeconds = timeLimitInSeconds; + } + + private Leaderboard() { } + + /// + /// Records a completed run's score against this leaderboard, updating the participant's + /// high score only if it improves on their existing one (or adding them as a new entry if + /// they've never appeared here before). + /// + public void RecordScore(Guid userId, int score) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + LeaderboardParticipant? participant = _participants.FirstOrDefault(p => p.UserId == userId); + if (participant is null) + { + _participants.Add(new LeaderboardParticipant(userId, score)); + return; + } + + participant.UpdateHighScoreIfGreater(score); + } + + public Guid GameModeId { get; private set; } + + public int TimeLimitInSeconds { get; private set; } + + public IReadOnlyCollection Participants => _participants.AsReadOnly(); + + public IReadOnlyCollection Rankings => + [.. _participants.OrderByDescending(p => p.HighScore)]; + + private readonly List _participants = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Entities/LeaderboardParticipant.cs b/StackDuel.Domain/Games/Entities/LeaderboardParticipant.cs new file mode 100644 index 0000000..6b4e3b8 --- /dev/null +++ b/StackDuel.Domain/Games/Entities/LeaderboardParticipant.cs @@ -0,0 +1,31 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Entities; + +public sealed class LeaderboardParticipant : Entity +{ + public LeaderboardParticipant(Guid userId, int highScore) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + if (highScore < 0) + throw new ArgumentException("High score must not be negative.", nameof(highScore)); + UserId = userId; + HighScore = highScore; + } + + private LeaderboardParticipant() { } + + public Guid UserId { get; private set; } + + public int HighScore { get; private set; } + + internal void UpdateHighScoreIfGreater(int score) + { + if (score < 0) + throw new ArgumentException("Score must not be negative.", nameof(score)); + + if (score > HighScore) + HighScore = score; + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Enums/GameStatus.cs b/StackDuel.Domain/Games/Enums/GameStatus.cs new file mode 100644 index 0000000..b24943a --- /dev/null +++ b/StackDuel.Domain/Games/Enums/GameStatus.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.Games.Enums; + +public enum GameStatus +{ + Pending = 0, + Running = 1, + Completed = 2, + Cancelled = 3, +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Enums/ParticipantAction.cs b/StackDuel.Domain/Games/Enums/ParticipantAction.cs new file mode 100644 index 0000000..0c928d9 --- /dev/null +++ b/StackDuel.Domain/Games/Enums/ParticipantAction.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.Games.Enums; + +public enum ParticipantAction +{ + CompleteProblem = 0, + SkipProblem = 1, + Forfeit = 2, +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Enums/ParticipantPlayState.cs b/StackDuel.Domain/Games/Enums/ParticipantPlayState.cs new file mode 100644 index 0000000..019dfad --- /dev/null +++ b/StackDuel.Domain/Games/Enums/ParticipantPlayState.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Domain.Games.Enums; + +public enum ParticipantPlayState +{ + InProgress = 0, + Finished = 1, + Forfeited = 2, +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Enums/ProblemAttemptEligibility.cs b/StackDuel.Domain/Games/Enums/ProblemAttemptEligibility.cs new file mode 100644 index 0000000..37a2544 --- /dev/null +++ b/StackDuel.Domain/Games/Enums/ProblemAttemptEligibility.cs @@ -0,0 +1,11 @@ +namespace StackDuel.Domain.Games.Enums; + +public enum ProblemAttemptEligibility +{ + Eligible = 0, + ParticipantNotFound = 1, + GameNotRunning = 2, + ParticipantStopped = 3, + ProblemSessionNotInitialized = 4, + ProblemMismatch = 5, +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/Events/GameCompletedDomainEvent.cs b/StackDuel.Domain/Games/Events/GameCompletedDomainEvent.cs new file mode 100644 index 0000000..d18f056 --- /dev/null +++ b/StackDuel.Domain/Games/Events/GameCompletedDomainEvent.cs @@ -0,0 +1,12 @@ +using StackDuel.Domain.Games.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Events; + +/// +/// Raised whenever a game reaches a terminal Completed state — whether via the normal +/// GameTimeExpiredMessage path, the sweep-job backstop, or an early player forfeit. Consumers +/// (e.g. the SignalR notification handler) use this as the single trigger point for telling +/// clients a game is over, regardless of which path finalized it. +/// +public sealed record GameCompletedDomainEvent(Guid GameId, GameStatus Status, DateTime EndedAt) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Games/Events/GameLobbyUpdatedDomainEvent.cs b/StackDuel.Domain/Games/Events/GameLobbyUpdatedDomainEvent.cs new file mode 100644 index 0000000..4f140d5 --- /dev/null +++ b/StackDuel.Domain/Games/Events/GameLobbyUpdatedDomainEvent.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Events; + +public sealed record GameLobbyUpdatedDomainEvent(Guid GameId) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Games/Events/GameProgressUpdatedDomainEvent.cs b/StackDuel.Domain/Games/Events/GameProgressUpdatedDomainEvent.cs new file mode 100644 index 0000000..e561232 --- /dev/null +++ b/StackDuel.Domain/Games/Events/GameProgressUpdatedDomainEvent.cs @@ -0,0 +1,11 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Events; + +/// +/// Raised whenever a participant's progress changes in a Running game — currently, solving a +/// problem (see Game.RecordProblemSolved). Consumers (e.g. the SignalR notification handler) +/// use this to tell everyone else in the game to refresh, so opponents' scores on the Score tab +/// update promptly instead of sitting stale until something else happens to trigger a refetch. +/// +public sealed record GameProgressUpdatedDomainEvent(Guid GameId) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Games/Events/GameStartedDomainEvent.cs b/StackDuel.Domain/Games/Events/GameStartedDomainEvent.cs new file mode 100644 index 0000000..3127443 --- /dev/null +++ b/StackDuel.Domain/Games/Events/GameStartedDomainEvent.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Games.Events; + +public sealed record GameStartedDomainEvent(Guid GameId, DateTime StartedAt, int TimeLimitInSeconds) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Games/GameJoinCode.cs b/StackDuel.Domain/Games/GameJoinCode.cs new file mode 100644 index 0000000..e30c370 --- /dev/null +++ b/StackDuel.Domain/Games/GameJoinCode.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; + +namespace StackDuel.Domain.Games; + +public static class GameJoinCode +{ + private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + private const int Length = 7; + + public static string Generate() + { + Span code = stackalloc char[Length]; + + for (int i = 0; i < Length; i++) + { + code[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + } + + return new string(code); + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/GameProblemPositionConflictException.cs b/StackDuel.Domain/Games/GameProblemPositionConflictException.cs new file mode 100644 index 0000000..151d3af --- /dev/null +++ b/StackDuel.Domain/Games/GameProblemPositionConflictException.cs @@ -0,0 +1,18 @@ +namespace StackDuel.Domain.Games; + +/// +/// Thrown by when two participants raced to be +/// the first to reach the same new position in a game's shared problem sequence: both built a +/// GameProblem for that position in memory, but only one save can win the +/// (game_id, position) unique index. The loser should re-fetch the game (now picking up the +/// winner's already-persisted problem for that position via Game.ProblemIdAtPosition) and retry +/// once rather than treating this as a hard failure. +/// +public sealed class GameProblemPositionConflictException(Guid gameId, Exception innerException) + : Exception( + $"Game '{gameId}' already has a problem persisted at the position this save was about to create.", + innerException + ) +{ + public Guid GameId { get; } = gameId; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/GameReactionEmojis.cs b/StackDuel.Domain/Games/GameReactionEmojis.cs new file mode 100644 index 0000000..204431d --- /dev/null +++ b/StackDuel.Domain/Games/GameReactionEmojis.cs @@ -0,0 +1,22 @@ +namespace StackDuel.Domain.Games; + +/// +/// Allow-list of emoji a player can send as a quick reaction during a Running game. Single +/// source of truth for server-side validation — the frontend keeps its own matching display list +/// (web/src/domains/game/ramp/quick-reactions.ts) since there's no shared package between the two +/// stacks; growing this set is just appending an entry to both. +/// +public static class GameReactionEmojis +{ + public static readonly IReadOnlySet Allowed = new HashSet + { + "👍", + "🔥", + "😂", + "😮", + "💪", + "🎉", + "😅", + "🤝", + }; +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/IGameWriteRepository.cs b/StackDuel.Domain/Games/IGameWriteRepository.cs new file mode 100644 index 0000000..aaaa1ff --- /dev/null +++ b/StackDuel.Domain/Games/IGameWriteRepository.cs @@ -0,0 +1,10 @@ +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Domain.Games; + +public interface IGameWriteRepository +{ + Task AddAsync(Game entity, CancellationToken cancellationToken = default); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task SaveChangesAsync(Game entity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/ILeaderboardWriteRepository.cs b/StackDuel.Domain/Games/ILeaderboardWriteRepository.cs new file mode 100644 index 0000000..6dedac6 --- /dev/null +++ b/StackDuel.Domain/Games/ILeaderboardWriteRepository.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Domain.Games; + +public interface ILeaderboardWriteRepository +{ + Task AddAsync(Leaderboard entity, CancellationToken cancellationToken = default); + Task FindByGameModeAndTimeLimitAsync( + Guid gameModeId, + int timeLimitInSeconds, + CancellationToken cancellationToken = default + ); + Task SaveChangesAsync(Leaderboard entity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Games/ValueObjects/GameProblemSession.cs b/StackDuel.Domain/Games/ValueObjects/GameProblemSession.cs new file mode 100644 index 0000000..64decd1 --- /dev/null +++ b/StackDuel.Domain/Games/ValueObjects/GameProblemSession.cs @@ -0,0 +1,98 @@ +namespace StackDuel.Domain.Games.ValueObjects; + +public sealed class GameProblemSession +{ + public GameProblemSession(Guid currentProblemId) + { + if (currentProblemId == Guid.Empty) + throw new ArgumentException("Current problem id must not be empty.", nameof(currentProblemId)); + + CurrentProblemId = currentProblemId; + _solvedProblemIds = []; + _skippedProblemIds = []; + } + + public Guid CurrentProblemId { get; private set; } + public Guid? ActiveSubmissionId { get; private set; } + + public IReadOnlyList SolvedProblemIds => _solvedProblemIds.AsReadOnly(); + public IReadOnlyList SkippedProblemIds => _skippedProblemIds.AsReadOnly(); + public IReadOnlyDictionary SolvedProblemSubmissionIds => _solvedProblemSubmissionIds; + + /// + /// Every problem this participant should never be offered again: solved, skipped, or the one + /// they're currently on. The single source of truth for "exclude" lists passed to + /// IProblemSelectionStrategy — CompleteProblemHandler and SkipProblemHandler both read this + /// instead of each re-deriving it, so a skipped problem can't quietly drop out of one path's + /// exclusions while staying excluded in the other's. + /// + public IReadOnlyList ExcludedProblemIds + { + get + { + List excluded = [.. _solvedProblemIds, .. _skippedProblemIds]; + if (!excluded.Contains(CurrentProblemId)) + excluded.Add(CurrentProblemId); + return excluded; + } + } + + private readonly List _solvedProblemIds; + private readonly List _skippedProblemIds; + private readonly Dictionary _solvedProblemSubmissionIds = new(); + + public void AddSolvedProblem(Guid problemId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + if (!_solvedProblemIds.Contains(problemId)) + _solvedProblemIds.Add(problemId); + } + + public void AddSkippedProblem(Guid problemId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + if (!_skippedProblemIds.Contains(problemId)) + _skippedProblemIds.Add(problemId); + } + + public void RecordSolvedSubmission(Guid problemId, Guid submissionId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + if (submissionId == Guid.Empty) + throw new ArgumentException("Submission id must not be empty.", nameof(submissionId)); + + _solvedProblemSubmissionIds[problemId] = submissionId; + } + + public void SetActiveSubmission(Guid submissionId) + { + if (submissionId == Guid.Empty) + throw new ArgumentException("Submission id must not be empty.", nameof(submissionId)); + ActiveSubmissionId = submissionId; + } + + /// + /// Whether is the one this session's current problem was + /// actually submitted through — guards against completing a problem with a submission that + /// wasn't created via the game's own submit endpoint. + /// + public bool HasActiveSubmission(Guid submissionId) => ActiveSubmissionId == submissionId; + + public void AdvanceProblem(Guid newProblemId) + { + if (newProblemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(newProblemId)); + + // Add current problem to solved before advancing + if (!_solvedProblemIds.Contains(CurrentProblemId)) + _solvedProblemIds.Add(CurrentProblemId); + + CurrentProblemId = newProblemId; + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Entities/Language.cs b/StackDuel.Domain/Languages/Entities/Language.cs new file mode 100644 index 0000000..ea40684 --- /dev/null +++ b/StackDuel.Domain/Languages/Entities/Language.cs @@ -0,0 +1,56 @@ +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Languages.Exceptions; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Entities; + +public sealed class Language : AggregateRoot +{ + public Language(LanguageName name, LanguageSlug slug, Guid trackId) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Slug = slug ?? throw new ArgumentNullException(nameof(slug)); + TrackId = + trackId != Guid.Empty + ? trackId + : throw new ArgumentException("Track id must not be empty.", nameof(trackId)); + Status = LanguageStatus.Active; + } + + public void Activate() + { + Status = LanguageStatus.Active; + } + + public LanguageVersionEntry AddVersion(LanguageVersion version, Judge0Id judge0Id) + { + var entry = new LanguageVersionEntry(version, judge0Id); + _versions.Add(entry); + return entry; + } + + public void Deactivate() + { + Status = LanguageStatus.Inactive; + } + + public void DeprecateVersion(Guid versionId) + { + var version = + _versions.FirstOrDefault(v => v.Id == versionId) ?? throw new LanguageVersionNotFoundException(versionId); + + version.Deprecate(); + } + + private Language() { } + + public bool IsActive => Status == LanguageStatus.Active; + public LanguageName Name { get; private set; } = null!; + public LanguageSlug Slug { get; private set; } = null!; + public Guid TrackId { get; private set; } + public LanguageStatus Status { get; private set; } + public IReadOnlyCollection Versions => _versions.AsReadOnly(); + + private readonly List _versions = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Entities/LanguageVersionEntry.cs b/StackDuel.Domain/Languages/Entities/LanguageVersionEntry.cs new file mode 100644 index 0000000..49ae282 --- /dev/null +++ b/StackDuel.Domain/Languages/Entities/LanguageVersionEntry.cs @@ -0,0 +1,47 @@ +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Entities; + +public sealed class LanguageVersionEntry : Entity +{ + internal LanguageVersionEntry(LanguageVersion version, Judge0Id judge0Id) + { + Version = version ?? throw new ArgumentNullException(nameof(version)); + Judge0Id = judge0Id ?? throw new ArgumentNullException(nameof(judge0Id)); + Status = LanguageVersionStatus.Active; + ExecutionMode = TestExecutionMode.PerCase; + } + + public void Activate() + { + Status = LanguageVersionStatus.Active; + } + + public void Deprecate() + { + Status = LanguageVersionStatus.Deprecated; + } + + /// + /// Opts this specific language version into running test cases through its native test + /// framework in one batched Judge0 submission instead of one submission per case. The + /// Judge0 image behind must actually have that test framework + /// installed — this is a per-version choice, not a per-language one, so a plain + /// "JavaScript" entry can keep running PerCase while a "JavaScript (Vitest)" entry + /// pointing at a different Judge0 image opts into batching. + /// + public void SetExecutionMode(TestExecutionMode mode) + { + ExecutionMode = mode; + } + + private LanguageVersionEntry() { } + + public bool IsActive => Status == LanguageVersionStatus.Active; + public Judge0Id Judge0Id { get; private set; } = null!; + public LanguageVersionStatus Status { get; private set; } + public LanguageVersion Version { get; private set; } = null!; + public TestExecutionMode ExecutionMode { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Enums/LanguageStatus.cs b/StackDuel.Domain/Languages/Enums/LanguageStatus.cs new file mode 100644 index 0000000..4d5ad5c --- /dev/null +++ b/StackDuel.Domain/Languages/Enums/LanguageStatus.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.Languages.Enums; + +public enum LanguageStatus +{ + Active, + Inactive, +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Enums/LanguageVersionStatus.cs b/StackDuel.Domain/Languages/Enums/LanguageVersionStatus.cs new file mode 100644 index 0000000..9cf3156 --- /dev/null +++ b/StackDuel.Domain/Languages/Enums/LanguageVersionStatus.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.Languages.Enums; + +public enum LanguageVersionStatus +{ + Active, + Deprecated, +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Enums/TestExecutionMode.cs b/StackDuel.Domain/Languages/Enums/TestExecutionMode.cs new file mode 100644 index 0000000..6d80d63 --- /dev/null +++ b/StackDuel.Domain/Languages/Enums/TestExecutionMode.cs @@ -0,0 +1,16 @@ +namespace StackDuel.Domain.Languages.Enums; + +/// +/// How a language version executes a submission's test cases against Judge0. +/// is today's only mode: one Judge0 submission per test case, each +/// isolated in its own sandboxed process with Judge0's own wall-clock/memory limits. +/// runs every test case in a single Judge0 submission via +/// the language's native test runner (e.g. Vitest for JavaScript, NUnit for C#) — far fewer +/// Judge0 calls, at the cost of losing Judge0's per-case process isolation; the harness itself +/// has to provide whatever per-case isolation it still needs. +/// +public enum TestExecutionMode +{ + PerCase = 0, + BatchedTestFramework = 1, +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Exceptions/InvalidJudge0IdException.cs b/StackDuel.Domain/Languages/Exceptions/InvalidJudge0IdException.cs new file mode 100644 index 0000000..aacf97d --- /dev/null +++ b/StackDuel.Domain/Languages/Exceptions/InvalidJudge0IdException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Exceptions; + +public sealed class InvalidJudge0IdException(string message) : DomainException(message) { } \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Exceptions/InvalidLanguageNameException.cs b/StackDuel.Domain/Languages/Exceptions/InvalidLanguageNameException.cs new file mode 100644 index 0000000..d1b191b --- /dev/null +++ b/StackDuel.Domain/Languages/Exceptions/InvalidLanguageNameException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Exceptions; + +public sealed class InvalidLanguageNameException : DomainException +{ + public InvalidLanguageNameException(string reason) + : base($"Language name is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Exceptions/InvalidLanguageSlugException.cs b/StackDuel.Domain/Languages/Exceptions/InvalidLanguageSlugException.cs new file mode 100644 index 0000000..c51dd6f --- /dev/null +++ b/StackDuel.Domain/Languages/Exceptions/InvalidLanguageSlugException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Exceptions; + +public sealed class InvalidLanguageSlugException(string reason) + : DomainException($"Language slug is invalid: {reason}") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Exceptions/InvalidLanguageVersionException.cs b/StackDuel.Domain/Languages/Exceptions/InvalidLanguageVersionException.cs new file mode 100644 index 0000000..7002961 --- /dev/null +++ b/StackDuel.Domain/Languages/Exceptions/InvalidLanguageVersionException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Exceptions; + +public sealed class InvalidLanguageVersionException(string reason) + : DomainException($"Language version is invalid: {reason}") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Languages/Exceptions/LanguageVersionNotFoundException.cs b/StackDuel.Domain/Languages/Exceptions/LanguageVersionNotFoundException.cs new file mode 100644 index 0000000..c682c3a --- /dev/null +++ b/StackDuel.Domain/Languages/Exceptions/LanguageVersionNotFoundException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages.Exceptions; + +public sealed class LanguageVersionNotFoundException(Guid versionId) + : DomainException($"Language version with ID '{versionId}' was not found.") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Languages/ILanguageRepository.cs b/StackDuel.Domain/Languages/ILanguageRepository.cs new file mode 100644 index 0000000..6a6d8cb --- /dev/null +++ b/StackDuel.Domain/Languages/ILanguageRepository.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Languages; + +public interface ILanguageRepository : IRepository +{ + Task FindBySlugAsync(LanguageSlug slug, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/ValueObjects/Judge0Id.cs b/StackDuel.Domain/Languages/ValueObjects/Judge0Id.cs new file mode 100644 index 0000000..fedb53f --- /dev/null +++ b/StackDuel.Domain/Languages/ValueObjects/Judge0Id.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.Languages.Exceptions; + +namespace StackDuel.Domain.Languages.ValueObjects; + +public sealed record Judge0Id +{ + public Judge0Id(int value) + { + if (value <= 0) + throw new InvalidJudge0IdException($"Judge0 id must be a positive integer, got {value}."); + + Value = value; + } + + public override string ToString() => Value.ToString(); + + public int Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/ValueObjects/LanguageName.cs b/StackDuel.Domain/Languages/ValueObjects/LanguageName.cs new file mode 100644 index 0000000..110421c --- /dev/null +++ b/StackDuel.Domain/Languages/ValueObjects/LanguageName.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Languages.Exceptions; + +namespace StackDuel.Domain.Languages.ValueObjects; + +public sealed record LanguageName +{ + public LanguageName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidLanguageNameException("Name cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidLanguageNameException($"Name must be between {MinLength} and {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(LanguageName name) => name.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 100; + public static readonly int MinLength = 1; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/ValueObjects/LanguageSlug.cs b/StackDuel.Domain/Languages/ValueObjects/LanguageSlug.cs new file mode 100644 index 0000000..1ddb1c6 --- /dev/null +++ b/StackDuel.Domain/Languages/ValueObjects/LanguageSlug.cs @@ -0,0 +1,47 @@ +using StackDuel.Domain.Languages.Exceptions; +using System.Text.RegularExpressions; + +namespace StackDuel.Domain.Languages.ValueObjects; + +public sealed record LanguageSlug +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex ValidSlugPattern = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled, RegexTimeout); + private static readonly Regex InvalidSlugCharactersPattern = new(@"[^a-z0-9\s-]", RegexOptions.Compiled, RegexTimeout); + private static readonly Regex MultipleWhitespacePattern = new(@"\s+", RegexOptions.Compiled, RegexTimeout); + private static readonly Regex MultipleHyphensPattern = new(@"-+", RegexOptions.Compiled, RegexTimeout); + + public LanguageSlug(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidLanguageSlugException("Slug cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidLanguageSlugException($"Slug must be between {MinLength} and {MaxLength} characters."); + + if (!ValidSlugPattern.IsMatch(value)) + throw new InvalidLanguageSlugException( + "Slug must be lowercase, contain only letters, numbers, and hyphens, and cannot start or end with a hyphen." + ); + + Value = value; + } + + public static LanguageSlug FromName(LanguageName name) + { + string slug = name.Value.ToLowerInvariant(); + slug = InvalidSlugCharactersPattern.Replace(slug, string.Empty); + slug = MultipleWhitespacePattern.Replace(slug, "-"); + slug = MultipleHyphensPattern.Replace(slug, "-"); + slug = slug.Trim('-'); + return new LanguageSlug(slug); + } + + public static implicit operator string(LanguageSlug slug) => slug.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 100; + public static readonly int MinLength = 1; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Languages/ValueObjects/LanguageVersion.cs b/StackDuel.Domain/Languages/ValueObjects/LanguageVersion.cs new file mode 100644 index 0000000..dee9148 --- /dev/null +++ b/StackDuel.Domain/Languages/ValueObjects/LanguageVersion.cs @@ -0,0 +1,24 @@ +using StackDuel.Domain.Languages.Exceptions; + +namespace StackDuel.Domain.Languages.ValueObjects; + +public sealed record LanguageVersion +{ + public LanguageVersion(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidLanguageVersionException("Version cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidLanguageVersionException($"Version cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(LanguageVersion version) => version.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 50; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/ProblemValidation/Entities/ProblemValidationJob.cs b/StackDuel.Domain/ProblemValidation/Entities/ProblemValidationJob.cs new file mode 100644 index 0000000..c25e1f5 --- /dev/null +++ b/StackDuel.Domain/ProblemValidation/Entities/ProblemValidationJob.cs @@ -0,0 +1,62 @@ +using StackDuel.Domain.ProblemValidation.Enums; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ProblemValidation.Entities; + +/// +/// A queued request to validate a newly-authored problem's reference solutions and, on success, +/// generate its hidden test cases before publishing it. Runs in two passes, tracked by +/// : reference-solution validation, then waiting on the +/// TestCaseGenerationJobs it enqueues. See +/// for the equivalent single-pass job this one chains into. +/// +public sealed class ProblemValidationJob : AggregateRoot +{ + public ProblemValidationJob(Guid problemId) + { + ProblemId = + problemId != Guid.Empty + ? problemId + : throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + Status = ProblemValidationJobStatus.Pending; + CreatedAt = DateTime.UtcNow; + } + + private ProblemValidationJob() { } + + public void MoveToAwaitingGeneration(IReadOnlyList testCaseGenerationJobIds) + { + TestCaseGenerationJobIds = testCaseGenerationJobIds; + Status = ProblemValidationJobStatus.AwaitingGeneration; + } + + public void Complete(string summary) + { + Status = ProblemValidationJobStatus.Completed; + ResultSummary = summary; + CompletedAt = DateTime.UtcNow; + } + + public void Fail(string reason) + { + Status = ProblemValidationJobStatus.Failed; + FailureReason = reason; + CompletedAt = DateTime.UtcNow; + } + + public Guid ProblemId { get; private set; } + public ProblemValidationJobStatus Status { get; private set; } + public IReadOnlyList TestCaseGenerationJobIds { get; private set; } = []; + public string? ResultSummary { get; private set; } + public string? FailureReason { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + + /// + /// Lease expiry for the worker currently processing this job — same purpose as + /// . + /// Set/cleared atomically via ExecuteUpdateAsync, not on the tracked entity. + /// + public DateTime? LockedUntil { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/ProblemValidation/Enums/ProblemValidationJobStatus.cs b/StackDuel.Domain/ProblemValidation/Enums/ProblemValidationJobStatus.cs new file mode 100644 index 0000000..c7bfea3 --- /dev/null +++ b/StackDuel.Domain/ProblemValidation/Enums/ProblemValidationJobStatus.cs @@ -0,0 +1,15 @@ +namespace StackDuel.Domain.ProblemValidation.Enums; + +public enum ProblemValidationJobStatus +{ + /// Not yet validated. + Pending = 0, + + /// Reference solutions validated; waiting on the enqueued TestCaseGenerationJobs. + AwaitingGeneration = 1, + + /// All setups generated successfully — the problem has been published. + Completed = 2, + + Failed = 3, +} \ No newline at end of file diff --git a/StackDuel.Domain/ProblemValidation/IProblemValidationJobRepository.cs b/StackDuel.Domain/ProblemValidation/IProblemValidationJobRepository.cs new file mode 100644 index 0000000..cab3557 --- /dev/null +++ b/StackDuel.Domain/ProblemValidation/IProblemValidationJobRepository.cs @@ -0,0 +1,27 @@ +using StackDuel.Domain.ProblemValidation.Entities; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.ProblemValidation; + +public interface IProblemValidationJobRepository : IRepository +{ + Task> FindPendingAsync( + int batchSize, + CancellationToken cancellationToken = default + ); + Task> FindAwaitingGenerationAsync( + int batchSize, + CancellationToken cancellationToken = default + ); + + /// + /// Atomically claims the job for exclusive processing by extending its lock lease, but + /// only if it isn't already locked by another worker. Works against a job in either + /// or + /// . + /// + Task TryClaimAsync(Guid jobId, TimeSpan leaseDuration, CancellationToken cancellationToken = default); + + /// Releases the processing lock so the job can be picked up again if still unfinished. + Task ReleaseLockAsync(Guid jobId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/Problem.cs b/StackDuel.Domain/Problems/Entities/Problem.cs new file mode 100644 index 0000000..d9acf8b --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/Problem.cs @@ -0,0 +1,204 @@ +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class Problem : AggregateRoot +{ + public Problem( + Slug slug, + Title title, + Question question, + Difficulty difficulty, + TimeLimit timeLimit, + MemoryLimit memoryLimit + ) + { + Slug = slug ?? throw new ArgumentNullException(nameof(slug)); + Title = title ?? throw new ArgumentNullException(nameof(title)); + Question = question ?? throw new ArgumentNullException(nameof(question)); + Difficulty = difficulty ?? throw new ArgumentNullException(nameof(difficulty)); + TimeLimit = timeLimit ?? throw new ArgumentNullException(nameof(timeLimit)); + MemoryLimit = memoryLimit ?? throw new ArgumentNullException(nameof(memoryLimit)); + Status = ProblemStatus.Draft; + CreatedAt = DateTime.UtcNow; + } + + public void Archive() + { + Status = ProblemStatus.Archived; + } + + public void Publish() + { + Status = ProblemStatus.Published; + } + + public void SubmitForValidation() + { + Status = ProblemStatus.Pending; + ValidationFailureReason = null; + } + + public void FailValidation(string reason) + { + ValidationFailureReason = !string.IsNullOrWhiteSpace(reason) + ? reason + : throw new ArgumentException("Reason must not be empty.", nameof(reason)); + Status = ProblemStatus.Failed; + } + + public void CompleteValidation() + { + Status = ProblemStatus.Published; + ValidationFailureReason = null; + } + + public void SetTrack(Guid trackId) + { + TrackId = + trackId != Guid.Empty + ? trackId + : throw new ArgumentException("Track id must not be empty.", nameof(trackId)); + } + + public void SetGenerationParameters( + IReadOnlyList parameters, + string outputValueType, + int targetCaseCount, + int seed + ) + { + GenerationSpec = new ProblemGenerationSpec(parameters, outputValueType, targetCaseCount, seed); + } + + public void UpdateContent( + Title title, + Question question, + Difficulty difficulty, + TimeLimit timeLimit, + MemoryLimit memoryLimit + ) + { + Title = title ?? throw new ArgumentNullException(nameof(title)); + Question = question ?? throw new ArgumentNullException(nameof(question)); + Difficulty = difficulty ?? throw new ArgumentNullException(nameof(difficulty)); + TimeLimit = timeLimit ?? throw new ArgumentNullException(nameof(timeLimit)); + MemoryLimit = memoryLimit ?? throw new ArgumentNullException(nameof(memoryLimit)); + + _history.Add(new ProblemHistory(Title, Question, Difficulty, TimeLimit, MemoryLimit)); + } + + public void UpdateSlug(Slug slug) + { + Slug = slug ?? throw new ArgumentNullException(nameof(slug)); + } + + public ProblemSetup AddSetup(Guid languageVersionId, string initialCode, string? functionName, Guid pipelineId) + { + var setup = new ProblemSetup(languageVersionId, initialCode, functionName, pipelineId); + _setups.Add(setup); + return setup; + } + + public void SetReferenceSolution(Guid setupId, string code) + { + ProblemSetup setup = + _setups.FirstOrDefault(s => s.Id == setupId) + ?? throw new InvalidOperationException($"Setup {setupId} not found on problem."); + setup.SetReferenceSolution(code); + } + + public void SetGenerationSpecId(Guid setupId, Guid specId) + { + ProblemSetup setup = + _setups.FirstOrDefault(s => s.Id == setupId) + ?? throw new InvalidOperationException($"Setup {setupId} not found on problem."); + setup.SetGenerationSpecId(specId); + } + + public void SetAdditionalFileBundle(Guid setupId, Guid bundleId) + { + ProblemSetup setup = + _setups.FirstOrDefault(s => s.Id == setupId) + ?? throw new InvalidOperationException($"Setup {setupId} not found on problem."); + setup.SetAdditionalFileBundle(bundleId); + } + + public void UpdateSetupInitialCode(Guid setupId, string initialCode) + { + ProblemSetup setup = + _setups.FirstOrDefault(s => s.Id == setupId) + ?? throw new InvalidOperationException($"Setup {setupId} not found on problem."); + setup.UpdateInitialCode(initialCode); + } + + public void UpdateSetupFunctionName(Guid setupId, string? functionName) + { + ProblemSetup setup = + _setups.FirstOrDefault(s => s.Id == setupId) + ?? throw new InvalidOperationException($"Setup {setupId} not found on problem."); + setup.UpdateFunctionName(functionName); + } + + public void AddTag(ProblemTag tag) + { + ArgumentNullException.ThrowIfNull(tag); + if (!_tags.Contains(tag)) + _tags.Add(tag); + } + + public void RemoveTag(ProblemTag tag) + { + ArgumentNullException.ThrowIfNull(tag); + _tags.Remove(tag); + } + + public void SetCreatedBy(Guid userId) + { + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + CreatedById = userId; + } + + private Problem() + { + Slug = null!; + Title = null!; + Question = null!; + Difficulty = null!; + TimeLimit = null!; + MemoryLimit = null!; + } + + public IEnumerable AvailableLanguageVersionIds() => _setups.Select(setup => setup.LanguageVersionId); + + public ProblemSetup? FindSetupByLanguageVersionId(Guid languageVersionId) => + _setups.SingleOrDefault(setup => setup.LanguageVersionId == languageVersionId); + + public Slug Slug { get; private set; } + public Title Title { get; private set; } + public Question Question { get; private set; } + public Difficulty Difficulty { get; private set; } + public TimeLimit TimeLimit { get; private set; } + public MemoryLimit MemoryLimit { get; private set; } + public ProblemStatus Status { get; private set; } + public string? ValidationFailureReason { get; private set; } + public Guid? TrackId { get; private set; } + public DateTime CreatedAt { get; private set; } + public Guid? CreatedById { get; private set; } + public User? CreatedBy { get; private set; } + + public ProblemGenerationSpec? GenerationSpec { get; private set; } + + public IReadOnlyCollection History => _history.AsReadOnly(); + public IReadOnlyCollection Setups => _setups.AsReadOnly(); + public IReadOnlyCollection Tags => _tags.AsReadOnly(); + + private readonly List _history = []; + private readonly List _setups = []; + private readonly List _tags = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemHistory.cs b/StackDuel.Domain/Problems/Entities/ProblemHistory.cs new file mode 100644 index 0000000..0e2a3b2 --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemHistory.cs @@ -0,0 +1,32 @@ +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemHistory : Entity +{ + internal ProblemHistory( + Title title, + Question question, + Difficulty difficulty, + TimeLimit timeLimit, + MemoryLimit memoryLimit + ) + { + Title = title ?? throw new ArgumentNullException(nameof(title)); + Question = question ?? throw new ArgumentNullException(nameof(question)); + Difficulty = difficulty ?? throw new ArgumentNullException(nameof(difficulty)); + TimeLimit = timeLimit ?? throw new ArgumentNullException(nameof(timeLimit)); + MemoryLimit = memoryLimit ?? throw new ArgumentNullException(nameof(memoryLimit)); + CreatedAt = DateTime.UtcNow; + } + + private ProblemHistory() { } + + public Title Title { get; private set; } = null!; + public Question Question { get; private set; } = null!; + public Difficulty Difficulty { get; private set; } = null!; + public TimeLimit TimeLimit { get; private set; } = null!; + public MemoryLimit MemoryLimit { get; private set; } = null!; + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemPool.cs b/StackDuel.Domain/Problems/Entities/ProblemPool.cs new file mode 100644 index 0000000..055b719 --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemPool.cs @@ -0,0 +1,119 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemPool : Entity +{ + public ProblemPool(string key, string name, string? description = null) + { + Key = ValidateKey(key); + Name = ValidateName(name); + Description = NormalizeDescription(description); + CreatedAt = DateTime.UtcNow; + } + + private ProblemPool() + { + Key = null!; + Name = null!; + } + + public void Rename(string name) + { + Name = ValidateName(name); + } + + public void UpdateDescription(string? description) + { + Description = NormalizeDescription(description); + } + + public void AddProblem(Guid problemId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + if (!_items.Any(item => item.ProblemId == problemId)) + { + int nextPosition = _items.Count == 0 ? 0 : _items.Max(item => item.Position) + 1; + _items.Add(new ProblemPoolItem(problemId, nextPosition)); + } + } + + public void RemoveProblem(Guid problemId) + { + ProblemPoolItem? item = _items.FirstOrDefault(item => item.ProblemId == problemId); + if (item is not null) + _items.Remove(item); + } + + /// + /// Reassigns each member's position to match . The given list + /// must contain exactly the pool's current members (same count, same ids) — it's a reordering of + /// the existing set, not a way to add or remove members. + /// + public void Reorder(IReadOnlyList orderedProblemIds) + { + if (orderedProblemIds is null) + throw new ArgumentNullException(nameof(orderedProblemIds)); + + if (orderedProblemIds.Count != _items.Count || orderedProblemIds.Distinct().Count() != _items.Count) + throw new ArgumentException( + "Reorder must include every current pool member exactly once.", + nameof(orderedProblemIds) + ); + + Dictionary itemsByProblemId = _items.ToDictionary(item => item.ProblemId); + + for (int position = 0; position < orderedProblemIds.Count; position++) + { + if (!itemsByProblemId.TryGetValue(orderedProblemIds[position], out ProblemPoolItem? item)) + throw new ArgumentException( + "Reorder must include every current pool member exactly once.", + nameof(orderedProblemIds) + ); + + item.SetPosition(position); + } + } + + private static string ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Pool key must not be empty.", nameof(key)); + + string normalized = key.Trim().ToLowerInvariant(); + + if (normalized.Length > MaxKeyLength) + throw new ArgumentException($"Pool key must be {MaxKeyLength} characters or fewer.", nameof(key)); + + return normalized; + } + + private static string ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Pool name is required.", nameof(name)); + + if (name.Length > MaxNameLength) + throw new ArgumentException($"Pool name must be {MaxNameLength} characters or fewer.", nameof(name)); + + return name.Trim(); + } + + private static string? NormalizeDescription(string? description) => + string.IsNullOrWhiteSpace(description) ? null : description.Trim(); + + public static readonly int MaxKeyLength = 50; + public static readonly int MaxNameLength = 100; + + public string Key { get; private set; } + public string Name { get; private set; } + public string? Description { get; private set; } + public DateTime CreatedAt { get; private set; } + + public IReadOnlyCollection ProblemIds => + _items.OrderBy(item => item.Position).Select(item => item.ProblemId).ToList().AsReadOnly(); + + private readonly List _items = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemPoolItem.cs b/StackDuel.Domain/Problems/Entities/ProblemPoolItem.cs new file mode 100644 index 0000000..4efc562 --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemPoolItem.cs @@ -0,0 +1,29 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemPoolItem : Entity +{ + public ProblemPoolItem(Guid problemId, int position) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + ProblemId = problemId; + Position = position; + AddedAt = DateTime.UtcNow; + } + + private ProblemPoolItem() { } + + internal void SetPosition(int position) + { + Position = position; + } + + public Guid ProblemId { get; private set; } + + public int Position { get; private set; } + + public DateTime AddedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemReaction.cs b/StackDuel.Domain/Problems/Entities/ProblemReaction.cs new file mode 100644 index 0000000..1175466 --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemReaction.cs @@ -0,0 +1,56 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemReaction : AggregateRoot +{ + public ProblemReaction(Guid problemId, Guid userId, Guid reactionTypeId) + { + if (problemId == Guid.Empty) + throw new ArgumentException("Problem id must not be empty.", nameof(problemId)); + + if (userId == Guid.Empty) + throw new ArgumentException("User id must not be empty.", nameof(userId)); + + if (reactionTypeId == Guid.Empty) + throw new ArgumentException("Reaction type id must not be empty.", nameof(reactionTypeId)); + + ProblemId = problemId; + UserId = userId; + ReactionTypeId = reactionTypeId; + IsActive = true; + CreatedAt = DateTime.UtcNow; + UpdatedAt = CreatedAt; + } + + private ProblemReaction() { } + + /// + /// Flips this permanent row back on. Reacting again after unreacting reuses the same row + /// rather than inserting a new one, so a future notification hook here fires once per + /// genuine "turned on" transition, not once per click. + /// + public void Activate() + { + IsActive = true; + UpdatedAt = DateTime.UtcNow; + } + + public void Deactivate() + { + IsActive = false; + UpdatedAt = DateTime.UtcNow; + } + + public Guid ProblemId { get; private set; } + + public Guid UserId { get; private set; } + + public Guid ReactionTypeId { get; private set; } + + public bool IsActive { get; private set; } + + public DateTime CreatedAt { get; private set; } + + public DateTime UpdatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemReactionType.cs b/StackDuel.Domain/Problems/Entities/ProblemReactionType.cs new file mode 100644 index 0000000..4748110 --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemReactionType.cs @@ -0,0 +1,38 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemReactionType : AggregateRoot +{ + public ProblemReactionType(string key, string name, string? emoji, int sortOrder) + { + Key = !string.IsNullOrWhiteSpace(key) + ? key.Trim().ToLowerInvariant() + : throw new ArgumentException("Reaction type key must not be empty.", nameof(key)); + + Name = !string.IsNullOrWhiteSpace(name) + ? name.Trim() + : throw new ArgumentException("Reaction type name must not be empty.", nameof(name)); + + Emoji = string.IsNullOrWhiteSpace(emoji) ? null : emoji.Trim(); + SortOrder = sortOrder; + IsEnabled = true; + CreatedAt = DateTime.UtcNow; + } + + private ProblemReactionType() { } + + public void Disable() => IsEnabled = false; + + public string Key { get; private set; } = null!; + + public string Name { get; private set; } = null!; + + public string? Emoji { get; private set; } + + public int SortOrder { get; private set; } + + public bool IsEnabled { get; private set; } + + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemSetup.cs b/StackDuel.Domain/Problems/Entities/ProblemSetup.cs new file mode 100644 index 0000000..f69f6a8 --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemSetup.cs @@ -0,0 +1,102 @@ +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemSetup : Entity +{ + internal ProblemSetup(Guid languageVersionId, string initialCode, string? functionName, Guid pipelineId) + { + LanguageVersionId = + languageVersionId != Guid.Empty + ? languageVersionId + : throw new ArgumentException("Language version id must not be empty.", nameof(languageVersionId)); + + InitialCode = !string.IsNullOrWhiteSpace(initialCode) + ? initialCode + : throw new ArgumentException("Initial code must not be empty.", nameof(initialCode)); + + if (functionName is not null && string.IsNullOrWhiteSpace(functionName)) + throw new ArgumentException("Function name must not be blank when provided.", nameof(functionName)); + + FunctionName = functionName; + + PipelineId = + pipelineId != Guid.Empty + ? pipelineId + : throw new ArgumentException("Pipeline id must not be empty.", nameof(pipelineId)); + } + + internal void SetAdditionalFileBundle(Guid bundleId) + { + AdditionalFileBundleId = + bundleId != Guid.Empty + ? bundleId + : throw new ArgumentException("Additional file bundle id must not be empty.", nameof(bundleId)); + } + + internal void ClearAdditionalFileBundle() + { + AdditionalFileBundleId = null; + } + + private ProblemSetup() { } + + public IEnumerable PublicTestSuites() => + [.. _testSuites.Where(testSuite => testSuite.Type == TestSuiteType.Sample)]; + + internal void SetReferenceSolution(string code) + { + ReferenceSolutionCode = !string.IsNullOrWhiteSpace(code) + ? code + : throw new ArgumentException("Reference solution code must not be empty.", nameof(code)); + } + + public void UpdateInitialCode(string initialCode) + { + InitialCode = !string.IsNullOrWhiteSpace(initialCode) + ? initialCode + : throw new ArgumentException("Initial code must not be empty.", nameof(initialCode)); + } + + public void UpdateFunctionName(string? functionName) + { + if (functionName is not null && string.IsNullOrWhiteSpace(functionName)) + throw new ArgumentException("Function name must not be blank when provided.", nameof(functionName)); + + FunctionName = functionName; + } + + internal void SetGenerationSpecId(Guid specId) + { + GenerationSpecId = + specId != Guid.Empty + ? specId + : throw new ArgumentException("Generation spec id must not be empty.", nameof(specId)); + } + + /// + /// Sets the setup's additional starter files (e.g. helper components alongside the main + /// solution file) — editable in the workspace like the main file, unlike + /// which is an opaque, non-editable static asset. + /// + public void SetAdditionalFiles(IEnumerable files) + { + AdditionalFiles = [.. files]; + } + + public Guid LanguageVersionId { get; private set; } + public Guid PipelineId { get; private set; } + public string InitialCode { get; private set; } = null!; + public string? FunctionName { get; private set; } + public string? ReferenceSolutionCode { get; private set; } + public Guid? GenerationSpecId { get; private set; } + public Guid? AdditionalFileBundleId { get; private set; } + public IReadOnlyList AdditionalFiles { get; private set; } = []; + + public IReadOnlyCollection TestSuites => _testSuites.AsReadOnly(); + + private readonly List _testSuites = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Entities/ProblemTag.cs b/StackDuel.Domain/Problems/Entities/ProblemTag.cs new file mode 100644 index 0000000..4b2eedf --- /dev/null +++ b/StackDuel.Domain/Problems/Entities/ProblemTag.cs @@ -0,0 +1,23 @@ +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Entities; + +public sealed class ProblemTag : Entity +{ + public ProblemTag(Tag name) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + private ProblemTag() + { + Name = null!; + } + + public Tag Name { get; private set; } + + public IReadOnlyCollection Problems => _problems.AsReadOnly(); + + private readonly List _problems = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Enums/DifficultyTier.cs b/StackDuel.Domain/Problems/Enums/DifficultyTier.cs new file mode 100644 index 0000000..dba1156 --- /dev/null +++ b/StackDuel.Domain/Problems/Enums/DifficultyTier.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Domain.Problems.Enums; + +public enum DifficultyTier +{ + Beginner = 1, + Easy = 2, + Intermediate = 3, + Advanced = 4, + Expert = 5, +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Enums/ProblemStatus.cs b/StackDuel.Domain/Problems/Enums/ProblemStatus.cs new file mode 100644 index 0000000..3c7e448 --- /dev/null +++ b/StackDuel.Domain/Problems/Enums/ProblemStatus.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Domain.Problems.Enums; + +public enum ProblemStatus +{ + Draft = 1, + Published = 2, + Archived = 3, + Pending = 4, + Failed = 5, +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidDifficultyException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidDifficultyException.cs new file mode 100644 index 0000000..cd6f22e --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidDifficultyException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidDifficultyException(string reason) : DomainException($"Difficulty is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidMemoryLimitException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidMemoryLimitException.cs new file mode 100644 index 0000000..e4d979b --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidMemoryLimitException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidMemoryLimitException(string reason) + : DomainException($"Memory limit is invalid: {reason}") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidQuestionException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidQuestionException.cs new file mode 100644 index 0000000..8b07ce7 --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidQuestionException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidQuestionException(string reason) : DomainException($"Question is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidSlugException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidSlugException.cs new file mode 100644 index 0000000..25c706f --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidSlugException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidSlugException : DomainException +{ + public InvalidSlugException(string reason) + : base($"Slug is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidTagException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidTagException.cs new file mode 100644 index 0000000..2b5a6ef --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidTagException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidTagException(string reason) : DomainException($"Tag is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidTimeLimitException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidTimeLimitException.cs new file mode 100644 index 0000000..c017e6b --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidTimeLimitException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidTimeLimitException(string reason) : DomainException($"Time limit is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/InvalidTitleException.cs b/StackDuel.Domain/Problems/Exceptions/InvalidTitleException.cs new file mode 100644 index 0000000..dab4eaf --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/InvalidTitleException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class InvalidTitleException(string reason) : DomainException($"Title is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/Exceptions/ProblemSetupNotFoundException.cs b/StackDuel.Domain/Problems/Exceptions/ProblemSetupNotFoundException.cs new file mode 100644 index 0000000..0c20451 --- /dev/null +++ b/StackDuel.Domain/Problems/Exceptions/ProblemSetupNotFoundException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.Exceptions; + +public sealed class ProblemSetupNotFoundException(Guid setupId) + : DomainException($"Problem setup '{setupId}' was not found.") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Problems/IProblemReactionWriteRepository.cs b/StackDuel.Domain/Problems/IProblemReactionWriteRepository.cs new file mode 100644 index 0000000..33b451f --- /dev/null +++ b/StackDuel.Domain/Problems/IProblemReactionWriteRepository.cs @@ -0,0 +1,28 @@ +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Domain.Problems; + +public interface IProblemReactionWriteRepository +{ + Task FindByProblemUserAndTypeAsync( + Guid problemId, + Guid userId, + Guid reactionTypeId, + CancellationToken cancellationToken = default + ); + + /// + /// Returns the user's currently active reaction on this problem, if any. At most one can + /// exist at a time — enforced by a partial unique index on (problem_id, user_id) where + /// is_active — so this never has more than one row to return. + /// + Task FindActiveByProblemAndUserAsync( + Guid problemId, + Guid userId, + CancellationToken cancellationToken = default + ); + + Task AddAsync(ProblemReaction entity, CancellationToken cancellationToken = default); + + Task UpdateAsync(ProblemReaction entity, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/IProblemRepository.cs b/StackDuel.Domain/Problems/IProblemRepository.cs new file mode 100644 index 0000000..08791d3 --- /dev/null +++ b/StackDuel.Domain/Problems/IProblemRepository.cs @@ -0,0 +1,17 @@ +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems; + +public interface IProblemRepository : IRepository +{ + Task FindBySetupIdAsync(Guid setupId, CancellationToken cancellationToken = default); + + Task FindBySlugAsync(Slug slug, CancellationToken cancellationToken = default); + + Task> FindOrCreateTagsAsync( + IReadOnlyCollection tagNames, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/RequiredLanguages/Entities/RequiredProblemLanguage.cs b/StackDuel.Domain/Problems/RequiredLanguages/Entities/RequiredProblemLanguage.cs new file mode 100644 index 0000000..5b3826c --- /dev/null +++ b/StackDuel.Domain/Problems/RequiredLanguages/Entities/RequiredProblemLanguage.cs @@ -0,0 +1,32 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.RequiredLanguages.Entities; + +/// +/// A language version every new problem must ship a reference solution for before it can be +/// submitted for validation. Admin-managed, global — not scoped to a particular problem. +/// +public sealed class RequiredProblemLanguage : AggregateRoot +{ + private RequiredProblemLanguage(Guid languageVersionId, int sortOrder) + { + LanguageVersionId = + languageVersionId != Guid.Empty + ? languageVersionId + : throw new ArgumentException("Language version id must not be empty.", nameof(languageVersionId)); + + SortOrder = sortOrder; + CreatedAt = DateTime.UtcNow; + } + + private RequiredProblemLanguage() { } + + public static RequiredProblemLanguage Create(Guid languageVersionId, int sortOrder) => + new(languageVersionId, sortOrder); + + public void UpdateSortOrder(int sortOrder) => SortOrder = sortOrder; + + public Guid LanguageVersionId { get; private set; } + public int SortOrder { get; private set; } + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/RequiredLanguages/IRequiredProblemLanguageRepository.cs b/StackDuel.Domain/Problems/RequiredLanguages/IRequiredProblemLanguageRepository.cs new file mode 100644 index 0000000..78f3198 --- /dev/null +++ b/StackDuel.Domain/Problems/RequiredLanguages/IRequiredProblemLanguageRepository.cs @@ -0,0 +1,18 @@ +using StackDuel.Domain.Problems.RequiredLanguages.Entities; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Problems.RequiredLanguages; + +public interface IRequiredProblemLanguageRepository : IRepository +{ + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); + Task> GetAllOrderedForTrackAsync( + Guid trackId, + CancellationToken cancellationToken = default + ); + Task FindByLanguageVersionIdAsync( + Guid languageVersionId, + CancellationToken cancellationToken = default + ); + Task RemoveAsync(Guid id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/Difficulty.cs b/StackDuel.Domain/Problems/ValueObjects/Difficulty.cs new file mode 100644 index 0000000..66fcc3b --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/Difficulty.cs @@ -0,0 +1,40 @@ +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.Exceptions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record Difficulty +{ + public Difficulty(int value) + { + if (value < MinValue) + throw new InvalidDifficultyException($"Difficulty cannot be less than {MinValue}."); + + Value = value; + } + + public override string ToString() => $"{Value} ({Tier})"; + + public static readonly int BeginnerMin = 0; + public static readonly int BeginnerMax = 200; + public static readonly int EasyMin = 201; + public static readonly int EasyMax = 500; + public static readonly int IntermediateMin = 501; + public static readonly int IntermediateMax = 1000; + public static readonly int AdvancedMin = 1001; + public static readonly int AdvancedMax = 2000; + public static readonly int ExpertMin = 2001; + public static readonly int MinValue = 0; + + public DifficultyTier Tier => + Value switch + { + <= 200 => DifficultyTier.Beginner, + <= 500 => DifficultyTier.Easy, + <= 1000 => DifficultyTier.Intermediate, + <= 2000 => DifficultyTier.Advanced, + _ => DifficultyTier.Expert, + }; + + public int Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/MemoryLimit.cs b/StackDuel.Domain/Problems/ValueObjects/MemoryLimit.cs new file mode 100644 index 0000000..e22aa39 --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/MemoryLimit.cs @@ -0,0 +1,22 @@ +using StackDuel.Domain.Problems.Exceptions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record MemoryLimit +{ + public MemoryLimit(int megabytes) + { + if (megabytes < MinMegabytes || megabytes > MaxMegabytes) + throw new InvalidMemoryLimitException( + $"Memory limit must be between {MinMegabytes}MB and {MaxMegabytes}MB." + ); + + Megabytes = megabytes; + } + + public override string ToString() => $"{Megabytes}MB"; + + public static readonly int MaxMegabytes = 512; + public static readonly int MinMegabytes = 16; + public int Megabytes { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/ProblemGenerationSpec.cs b/StackDuel.Domain/Problems/ValueObjects/ProblemGenerationSpec.cs new file mode 100644 index 0000000..34d9c0f --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/ProblemGenerationSpec.cs @@ -0,0 +1,26 @@ +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record ProblemGenerationSpec( + IReadOnlyList Parameters, + string OutputValueType, + int TargetCaseCount, + int Seed +) +{ + public IReadOnlyList Parameters { get; } = + Parameters is { Count: > 0 } + ? Parameters + : throw new ArgumentException("At least one parameter is required.", nameof(Parameters)); + + public string OutputValueType { get; } = + !string.IsNullOrWhiteSpace(OutputValueType) + ? OutputValueType + : throw new ArgumentException("Output value type must not be empty.", nameof(OutputValueType)); + + public int TargetCaseCount { get; } = + TargetCaseCount > 0 + ? TargetCaseCount + : throw new ArgumentException("Target case count must be greater than zero.", nameof(TargetCaseCount)); +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/ProblemSetupFile.cs b/StackDuel.Domain/Problems/ValueObjects/ProblemSetupFile.cs new file mode 100644 index 0000000..8a86ea3 --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/ProblemSetupFile.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record ProblemSetupFile(string Path, string Content); \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/Question.cs b/StackDuel.Domain/Problems/ValueObjects/Question.cs new file mode 100644 index 0000000..8715642 --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/Question.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Problems.Exceptions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record Question +{ + public Question(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidQuestionException("Question cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidQuestionException($"Question must be between {MinLength} and {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(Question question) => question.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 10000; + public static readonly int MinLength = 50; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/Slug.cs b/StackDuel.Domain/Problems/ValueObjects/Slug.cs new file mode 100644 index 0000000..1a6c15b --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/Slug.cs @@ -0,0 +1,47 @@ +using StackDuel.Domain.Problems.Exceptions; +using System.Text.RegularExpressions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record Slug +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex ValidSlugPattern = new(@"^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.Compiled, RegexTimeout); + private static readonly Regex InvalidSlugCharactersPattern = new(@"[^a-z0-9\s-]", RegexOptions.Compiled, RegexTimeout); + private static readonly Regex MultipleWhitespacePattern = new(@"\s+", RegexOptions.Compiled, RegexTimeout); + private static readonly Regex MultipleHyphensPattern = new(@"-+", RegexOptions.Compiled, RegexTimeout); + + public Slug(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidSlugException("Slug cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidSlugException($"Slug must be between {MinLength} and {MaxLength} characters."); + + if (!ValidSlugPattern.IsMatch(value)) + throw new InvalidSlugException( + "Slug must be lowercase, contain only letters, numbers, and hyphens, and cannot start or end with a hyphen." + ); + + Value = value; + } + + public static Slug FromTitle(Title title) + { + string slug = title.Value.ToLowerInvariant(); + slug = InvalidSlugCharactersPattern.Replace(slug, string.Empty); + slug = MultipleWhitespacePattern.Replace(slug, "-"); + slug = MultipleHyphensPattern.Replace(slug, "-"); + slug = slug.Trim('-'); + return new Slug(slug); + } + + public static implicit operator string(Slug slug) => slug.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 200; + public static readonly int MinLength = 3; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/Tag.cs b/StackDuel.Domain/Problems/ValueObjects/Tag.cs new file mode 100644 index 0000000..ed3856b --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/Tag.cs @@ -0,0 +1,40 @@ +using StackDuel.Domain.Problems.Exceptions; +using System.Text.RegularExpressions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record Tag +{ + private static readonly Regex ValidTagPattern = new( + @"^[a-z0-9]+(?:-[a-z0-9]+)*$", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1) + ); + + public Tag(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidTagException("Tag cannot be empty."); + + if (value.Contains(' ')) + throw new InvalidTagException("Tag cannot contain spaces."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidTagException($"Tag must be between {MinLength} and {MaxLength} characters."); + + if (!ValidTagPattern.IsMatch(value)) + throw new InvalidTagException( + "Tag must be lowercase, contain only letters, numbers, and hyphens, and cannot start or end with a hyphen." + ); + + Value = value; + } + + public static implicit operator string(Tag tag) => tag.Value; + + public override string ToString() => Value; + + public static readonly int MinLength = 1; + public static readonly int MaxLength = 50; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/TimeLimit.cs b/StackDuel.Domain/Problems/ValueObjects/TimeLimit.cs new file mode 100644 index 0000000..3193e33 --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/TimeLimit.cs @@ -0,0 +1,22 @@ +using StackDuel.Domain.Problems.Exceptions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record TimeLimit +{ + public TimeLimit(int milliseconds) + { + if (milliseconds < MinMilliseconds || milliseconds > MaxMilliseconds) + throw new InvalidTimeLimitException( + $"Time limit must be between {MinMilliseconds}ms and {MaxMilliseconds}ms." + ); + + Milliseconds = milliseconds; + } + + public override string ToString() => $"{Milliseconds}ms"; + + public static readonly int MaxMilliseconds = 10000; + public static readonly int MinMilliseconds = 100; + public int Milliseconds { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Problems/ValueObjects/Title.cs b/StackDuel.Domain/Problems/ValueObjects/Title.cs new file mode 100644 index 0000000..f653050 --- /dev/null +++ b/StackDuel.Domain/Problems/ValueObjects/Title.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Problems.Exceptions; + +namespace StackDuel.Domain.Problems.ValueObjects; + +public sealed record Title +{ + public Title(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidTitleException("Title cannot be empty."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidTitleException($"Title must be between {MinLength} and {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(Title title) => title.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 200; + public static readonly int MinLength = 3; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/SeedWork/AggregateRoot.cs b/StackDuel.Domain/SeedWork/AggregateRoot.cs index 8b2e14e..549c780 100644 --- a/StackDuel.Domain/SeedWork/AggregateRoot.cs +++ b/StackDuel.Domain/SeedWork/AggregateRoot.cs @@ -1,9 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Text; +namespace StackDuel.Domain.SeedWork; -namespace StackDuel.Domain.SeedWork; - -internal class AggregateRoot +public abstract class AggregateRoot : Entity { + private readonly List _domainEvents = []; + + public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); + + protected void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); + + public IReadOnlyList PopDomainEvents() + { + var events = _domainEvents.ToList(); + _domainEvents.Clear(); + return events; + } } \ No newline at end of file diff --git a/StackDuel.Domain/SeedWork/DomainException.cs b/StackDuel.Domain/SeedWork/DomainException.cs index cebffd3..22a344c 100644 --- a/StackDuel.Domain/SeedWork/DomainException.cs +++ b/StackDuel.Domain/SeedWork/DomainException.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Text; +namespace StackDuel.Domain.SeedWork; -namespace StackDuel.Domain.SeedWork; - -internal class DomainException -{ -} \ No newline at end of file +public abstract class DomainException(string message) : Exception(message) { } \ No newline at end of file diff --git a/StackDuel.Domain/SeedWork/Entity.cs b/StackDuel.Domain/SeedWork/Entity.cs index 2f612f7..e4a20f6 100644 --- a/StackDuel.Domain/SeedWork/Entity.cs +++ b/StackDuel.Domain/SeedWork/Entity.cs @@ -1,9 +1,36 @@ -using System; -using System.Collections.Generic; -using System.Text; +namespace StackDuel.Domain.SeedWork; -namespace StackDuel.Domain.SeedWork; - -internal class Entity +public abstract class Entity { + public Guid Id { get; protected set; } + + protected Entity() + { + Id = Guid.NewGuid(); + } + + public override bool Equals(object? obj) + { + if (obj is not Entity other) + return false; + if (ReferenceEquals(this, other)) + return true; + if (GetType() != other.GetType()) + return false; + return Id == other.Id; + } + + public override int GetHashCode() => Id.GetHashCode(); + + public static bool operator ==(Entity? left, Entity? right) + { + if (left is null) + return right is null; + return left.Equals(right); + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } } \ No newline at end of file diff --git a/StackDuel.Domain/SeedWork/IAggregateFactory.cs b/StackDuel.Domain/SeedWork/IAggregateFactory.cs index 1a8e3f5..4008eec 100644 --- a/StackDuel.Domain/SeedWork/IAggregateFactory.cs +++ b/StackDuel.Domain/SeedWork/IAggregateFactory.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace StackDuel.Domain.SeedWork; -internal interface IAggregateFactory +public interface IAggregateFactory + where TAggregate : AggregateRoot { + TAggregate Create(TParams parameters); } \ No newline at end of file diff --git a/StackDuel.Domain/SeedWork/IDomainEvent.cs b/StackDuel.Domain/SeedWork/IDomainEvent.cs index 22da429..b62e1c6 100644 --- a/StackDuel.Domain/SeedWork/IDomainEvent.cs +++ b/StackDuel.Domain/SeedWork/IDomainEvent.cs @@ -1,9 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace StackDuel.Domain.SeedWork; -internal interface IDomainEvent -{ -} \ No newline at end of file +public interface IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/SeedWork/IRepository.cs b/StackDuel.Domain/SeedWork/IRepository.cs index 17cb242..3471789 100644 --- a/StackDuel.Domain/SeedWork/IRepository.cs +++ b/StackDuel.Domain/SeedWork/IRepository.cs @@ -1,9 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace StackDuel.Domain.SeedWork; -internal interface IRepository +public interface IRepository + where T : AggregateRoot { + Task AddAsync(T entity, CancellationToken cancellationToken = default); + Task UpdateAsync(T entity, CancellationToken cancellationToken = default); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/StackDuel.Domain/StackDuel.Domain.csproj b/StackDuel.Domain/StackDuel.Domain.csproj index 8c8ce06..1659c68 100644 --- a/StackDuel.Domain/StackDuel.Domain.csproj +++ b/StackDuel.Domain/StackDuel.Domain.csproj @@ -1,5 +1,4 @@  - net10.0 enable @@ -7,7 +6,6 @@ - + - diff --git a/StackDuel.Domain/SubmissionJobs/Entities/SubmissionJobAttempt.cs b/StackDuel.Domain/SubmissionJobs/Entities/SubmissionJobAttempt.cs new file mode 100644 index 0000000..bd09aaf --- /dev/null +++ b/StackDuel.Domain/SubmissionJobs/Entities/SubmissionJobAttempt.cs @@ -0,0 +1,53 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.SubmissionJobs.Enums; + +namespace StackDuel.Domain.SubmissionJobs.Entities; + +public sealed class SubmissionJobAttempt : Entity +{ + internal SubmissionJobAttempt(Guid pipelineStepId, int attemptNumber) + { + PipelineStepId = pipelineStepId; + AttemptNumber = attemptNumber; + Status = SubmissionJobAttemptStatus.Processing; + StartedAt = DateTime.UtcNow; + } + + public void Succeed(string? requestPayload, string? responsePayload) + { + Status = SubmissionJobAttemptStatus.Succeeded; + RequestPayload = requestPayload; + ResponsePayload = responsePayload; + CompletedAt = DateTime.UtcNow; + DurationMs = (int)(CompletedAt.Value - StartedAt).TotalMilliseconds; + } + + public void Fail(string error, string? requestPayload = null, string? responsePayload = null) + { + Status = SubmissionJobAttemptStatus.Failed; + Error = error; + RequestPayload = requestPayload; + ResponsePayload = responsePayload; + CompletedAt = DateTime.UtcNow; + DurationMs = (int)(CompletedAt.Value - StartedAt).TotalMilliseconds; + } + + public void Abandon() + { + Status = SubmissionJobAttemptStatus.Abandoned; + CompletedAt = DateTime.UtcNow; + DurationMs = (int)(CompletedAt.Value - StartedAt).TotalMilliseconds; + } + + private SubmissionJobAttempt() { } + + public Guid PipelineStepId { get; private set; } + public int AttemptNumber { get; private set; } + public SubmissionJobAttemptStatus Status { get; private set; } + public string? RequestPayload { get; private set; } + public string? ResponsePayload { get; private set; } + public string? Error { get; private set; } + public DateTime StartedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + public int? DurationMs { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/SubmissionJobs/Enums/SubmissionJobAttemptStatus.cs b/StackDuel.Domain/SubmissionJobs/Enums/SubmissionJobAttemptStatus.cs new file mode 100644 index 0000000..0e91562 --- /dev/null +++ b/StackDuel.Domain/SubmissionJobs/Enums/SubmissionJobAttemptStatus.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.SubmissionJobs.Enums; + +public enum SubmissionJobAttemptStatus +{ + Processing = 1, + Succeeded = 2, + Failed = 3, + Abandoned = 4, +} \ No newline at end of file diff --git a/StackDuel.Domain/SubmissionJobs/Enums/SubmissionJobStatus.cs b/StackDuel.Domain/SubmissionJobs/Enums/SubmissionJobStatus.cs new file mode 100644 index 0000000..581f80a --- /dev/null +++ b/StackDuel.Domain/SubmissionJobs/Enums/SubmissionJobStatus.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.SubmissionJobs.Enums; + +public enum SubmissionJobStatus +{ + Pending = 1, + Running = 2, + Completed = 3, + Failed = 4, +} \ No newline at end of file diff --git a/StackDuel.Domain/SubmissionJobs/Exceptions/SubmissionJobException.cs b/StackDuel.Domain/SubmissionJobs/Exceptions/SubmissionJobException.cs new file mode 100644 index 0000000..4b0a137 --- /dev/null +++ b/StackDuel.Domain/SubmissionJobs/Exceptions/SubmissionJobException.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.SubmissionJobs.Exceptions; + +public sealed class SubmissionJobException(string message) : Exception(message); \ No newline at end of file diff --git a/StackDuel.Domain/SubmissionJobs/ISubmissionJobRepository.cs b/StackDuel.Domain/SubmissionJobs/ISubmissionJobRepository.cs new file mode 100644 index 0000000..16dd544 --- /dev/null +++ b/StackDuel.Domain/SubmissionJobs/ISubmissionJobRepository.cs @@ -0,0 +1,37 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.SubmissionJobs.Entities; + +namespace StackDuel.Domain.SubmissionJobs; + +public interface ISubmissionJobRepository : IRepository +{ + Task> FindPendingAsync(int batchSize, CancellationToken cancellationToken = default); + + Task FindBySubmissionIdAsync(Guid submissionId, CancellationToken cancellationToken = default); + + /// + /// Atomically claims the job for exclusive processing by extending its lock lease, + /// but only if it isn't already locked by another worker. Prevents the Quartz sweep + /// and the RabbitMQ-driven continuation flow from double-processing the same job. + /// Returns false if the job is currently locked, completed, or failed. + /// + Task TryClaimAsync(Guid jobId, TimeSpan leaseDuration, CancellationToken cancellationToken = default); + + /// + /// Releases the processing lock so the job can be picked up again (e.g. by the + /// next continuation message or the sweep, if it's still pending/running). + /// + Task ReleaseLockAsync(Guid jobId, CancellationToken cancellationToken = default); + + /// + /// Immediately inserts a newly created attempt into the database. + /// Call this right after so the row + /// exists before the step handler runs, making the final + /// a pure UPDATE with no INSERT required. + /// + Task PersistAttemptAsync( + SubmissionJob job, + SubmissionJobAttempt attempt, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Domain/SubmissionJobs/SubmissionJob.cs b/StackDuel.Domain/SubmissionJobs/SubmissionJob.cs new file mode 100644 index 0000000..213981d --- /dev/null +++ b/StackDuel.Domain/SubmissionJobs/SubmissionJob.cs @@ -0,0 +1,82 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.SubmissionJobs.Entities; +using StackDuel.Domain.SubmissionJobs.Enums; +using StackDuel.Domain.SubmissionJobs.Exceptions; + +namespace StackDuel.Domain.SubmissionJobs; + +public sealed class SubmissionJob : AggregateRoot +{ + public SubmissionJob(Guid submissionId, Guid pipelineId, Guid firstStepId) + { + SubmissionId = submissionId; + PipelineId = pipelineId; + CurrentStepId = firstStepId; + Status = SubmissionJobStatus.Pending; + CreatedAt = DateTime.UtcNow; + } + + public SubmissionJobAttempt StartAttempt() + { + if (CurrentStepId is null) + throw new SubmissionJobException("Cannot start attempt: no current step."); + + Status = SubmissionJobStatus.Running; + + int attemptNumber = _attempts.Count(a => a.PipelineStepId == CurrentStepId.Value) + 1; + + var attempt = new SubmissionJobAttempt(CurrentStepId.Value, attemptNumber); + _attempts.Add(attempt); + return attempt; + } + + public void AdvanceTo(Guid nextStepId) + { + CurrentStepId = nextStepId; + Status = SubmissionJobStatus.Pending; + } + + public void Complete() + { + Status = SubmissionJobStatus.Completed; + CurrentStepId = null; + CompletedAt = DateTime.UtcNow; + } + + public void Fail(string reason) + { + Status = SubmissionJobStatus.Failed; + FailureReason = reason; + CompletedAt = DateTime.UtcNow; + } + + public void ResetToPending() + { + Status = SubmissionJobStatus.Pending; + } + + public int AttemptCountForCurrentStep() => + CurrentStepId is null ? 0 : _attempts.Count(a => a.PipelineStepId == CurrentStepId.Value); + + private SubmissionJob() { } + + public Guid SubmissionId { get; private set; } + public Guid PipelineId { get; private set; } + public Guid? CurrentStepId { get; private set; } + public SubmissionJobStatus Status { get; private set; } + public string? FailureReason { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + + /// + /// Lease expiry for the worker currently processing this job. Prevents the + /// Quartz sweep and the RabbitMQ-driven continuation flow from processing + /// the same job concurrently. Set/cleared atomically by the repository via + /// ExecuteUpdateAsync — not mutated directly on the tracked entity. + /// + public DateTime? LockedUntil { get; private set; } + + public IReadOnlyCollection Attempts => _attempts.AsReadOnly(); + + private readonly List _attempts = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Entities/Submission.cs b/StackDuel.Domain/Submissions/Entities/Submission.cs new file mode 100644 index 0000000..5c0f500 --- /dev/null +++ b/StackDuel.Domain/Submissions/Entities/Submission.cs @@ -0,0 +1,107 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.Events; +using StackDuel.Domain.Submissions.Exceptions; +using StackDuel.Domain.Submissions.ValueObjects; + +namespace StackDuel.Domain.Submissions.Entities; + +public sealed class Submission : AggregateRoot +{ + public Submission( + Guid userId, + Guid problemSetupId, + SubmissionType type, + SourceCode sourceCode, + IEnumerable testCaseIds, + IEnumerable? additionalFiles = null, + Guid? gameId = null + ) + { + UserId = userId; + ProblemSetupId = problemSetupId; + GameId = gameId; + Type = type; + SourceCode = sourceCode ?? throw new ArgumentNullException(nameof(sourceCode)); + Status = SubmissionStatus.Queued; + CreatedAt = DateTime.UtcNow; + AdditionalFiles = additionalFiles is null ? [] : [.. additionalFiles]; + + foreach (Guid testCaseId in testCaseIds) + _results.Add(new SubmissionResult(testCaseId)); + + AddDomainEvent(new SubmissionCreatedDomainEvent(Id)); + } + + public void Complete() + { + if (_results.Any(r => !r.IsTerminal)) + throw new SubmissionNotCompleteException(); + + Status = _results.All(r => r.Status == SubmissionResultStatus.Accepted) + ? SubmissionStatus.Accepted + : SubmissionStatus.WrongAnswer; + + AddDomainEvent(new SubmissionCompletedDomainEvent(UserId, Id, Status, GameId)); + } + + /// + /// Marks all pending/processing results as + /// and transitions the submission to . + /// Called when the pipeline job fails before can run. + /// + public void Fail() + { + foreach (var result in _results.Where(r => !r.IsTerminal)) + result.Update(SubmissionResultStatus.RuntimeError); + + Status = SubmissionStatus.WrongAnswer; + + AddDomainEvent(new SubmissionCompletedDomainEvent(UserId, Id, Status, GameId)); + } + + public void StartRunning() + { + if (Status != SubmissionStatus.Queued) + throw new InvalidSubmissionStateException("Only a queued submission can be started."); + + Status = SubmissionStatus.Running; + } + + public void UpdateResult( + Guid testCaseId, + SubmissionResultStatus status, + int? runtime = null, + int? memoryUsed = null, + string? actualOutput = null, + string? standardOutput = null, + string? standardError = null, + string? compileOutput = null + ) + { + SubmissionResult result = + _results.FirstOrDefault(r => r.TestCaseId == testCaseId) + ?? throw new SubmissionResultNotFoundException(testCaseId); + + result.Update(status, runtime, memoryUsed, actualOutput, standardOutput, standardError, compileOutput); + } + + private Submission() { } + + public DateTime CreatedAt { get; private set; } + + public int? ExecutionTime => Results.Max(r => r.Runtime); + + public int? MemoryUsage => Results.Max(r => r.MemoryUsed); + + public Guid ProblemSetupId { get; private set; } + public Guid? GameId { get; private set; } + public IReadOnlyCollection Results => _results.AsReadOnly(); + public SourceCode SourceCode { get; private set; } = null!; + public SubmissionStatus Status { get; private set; } + public SubmissionType Type { get; private set; } + public Guid UserId { get; private set; } + public IReadOnlyList AdditionalFiles { get; private set; } = []; + + private readonly List _results = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Entities/SubmissionResult.cs b/StackDuel.Domain/Submissions/Entities/SubmissionResult.cs new file mode 100644 index 0000000..39283d1 --- /dev/null +++ b/StackDuel.Domain/Submissions/Entities/SubmissionResult.cs @@ -0,0 +1,45 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Domain.Submissions.Entities; + +public sealed class SubmissionResult : Entity +{ + internal SubmissionResult(Guid testCaseId) + { + TestCaseId = testCaseId; + Status = SubmissionResultStatus.Pending; + } + + public void Update( + SubmissionResultStatus status, + int? runtime = null, + int? memoryUsed = null, + string? actualOutput = null, + string? standardOutput = null, + string? standardError = null, + string? compileOutput = null + ) + { + Status = status; + Runtime = runtime; + MemoryUsed = memoryUsed; + ActualOutput = actualOutput; + StandardOutput = standardOutput; + StandardError = standardError; + CompileOutput = compileOutput; + } + + public bool IsTerminal => Status is not SubmissionResultStatus.Pending and not SubmissionResultStatus.Processing; + + private SubmissionResult() { } + + public string? ActualOutput { get; private set; } + public string? CompileOutput { get; private set; } + public int? MemoryUsed { get; private set; } + public int? Runtime { get; private set; } + public string? StandardError { get; private set; } + public string? StandardOutput { get; private set; } + public SubmissionResultStatus Status { get; private set; } + public Guid TestCaseId { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Enums/SubmissionResultStatus.cs b/StackDuel.Domain/Submissions/Enums/SubmissionResultStatus.cs new file mode 100644 index 0000000..0a87576 --- /dev/null +++ b/StackDuel.Domain/Submissions/Enums/SubmissionResultStatus.cs @@ -0,0 +1,13 @@ +namespace StackDuel.Domain.Submissions.Enums; + +public enum SubmissionResultStatus +{ + Pending, + Processing, + Accepted, + WrongAnswer, + TimeLimitExceeded, + MemoryLimitExceeded, + RuntimeError, + CompileError, +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Enums/SubmissionStatus.cs b/StackDuel.Domain/Submissions/Enums/SubmissionStatus.cs new file mode 100644 index 0000000..9c49751 --- /dev/null +++ b/StackDuel.Domain/Submissions/Enums/SubmissionStatus.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.Submissions.Enums; + +public enum SubmissionStatus +{ + Queued, + Running, + Accepted, + WrongAnswer, +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Enums/SubmissionType.cs b/StackDuel.Domain/Submissions/Enums/SubmissionType.cs new file mode 100644 index 0000000..3b56354 --- /dev/null +++ b/StackDuel.Domain/Submissions/Enums/SubmissionType.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.Submissions.Enums; + +public enum SubmissionType +{ + Run, + Submit, +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Events/SubmissionCompletedDomainEvent.cs b/StackDuel.Domain/Submissions/Events/SubmissionCompletedDomainEvent.cs new file mode 100644 index 0000000..ef3241e --- /dev/null +++ b/StackDuel.Domain/Submissions/Events/SubmissionCompletedDomainEvent.cs @@ -0,0 +1,11 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Submissions.Enums; + +namespace StackDuel.Domain.Submissions.Events; + +public sealed record SubmissionCompletedDomainEvent( + Guid UserId, + Guid SubmissionId, + SubmissionStatus Status, + Guid? GameId +) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Events/SubmissionCreatedDomainEvent.cs b/StackDuel.Domain/Submissions/Events/SubmissionCreatedDomainEvent.cs new file mode 100644 index 0000000..94d65f8 --- /dev/null +++ b/StackDuel.Domain/Submissions/Events/SubmissionCreatedDomainEvent.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Submissions.Events; + +public sealed record SubmissionCreatedDomainEvent(Guid SubmissionId) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Exceptions/InvalidSourceCodeException.cs b/StackDuel.Domain/Submissions/Exceptions/InvalidSourceCodeException.cs new file mode 100644 index 0000000..25e84dd --- /dev/null +++ b/StackDuel.Domain/Submissions/Exceptions/InvalidSourceCodeException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Submissions.Exceptions; + +public sealed class InvalidSourceCodeException : DomainException +{ + public InvalidSourceCodeException(string reason) + : base($"Source code is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Exceptions/InvalidSubmissionStateException.cs b/StackDuel.Domain/Submissions/Exceptions/InvalidSubmissionStateException.cs new file mode 100644 index 0000000..adef8f4 --- /dev/null +++ b/StackDuel.Domain/Submissions/Exceptions/InvalidSubmissionStateException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Submissions.Exceptions; + +public sealed class InvalidSubmissionStateException : DomainException +{ + public InvalidSubmissionStateException(string reason) + : base($"Submission state transition is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Exceptions/SubmissionNotCompleteException.cs b/StackDuel.Domain/Submissions/Exceptions/SubmissionNotCompleteException.cs new file mode 100644 index 0000000..af2bfb3 --- /dev/null +++ b/StackDuel.Domain/Submissions/Exceptions/SubmissionNotCompleteException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Submissions.Exceptions; + +public sealed class SubmissionNotCompleteException : DomainException +{ + public SubmissionNotCompleteException() + : base("Submission cannot be completed because one or more results are still pending or processing.") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Exceptions/SubmissionResultNotFoundException.cs b/StackDuel.Domain/Submissions/Exceptions/SubmissionResultNotFoundException.cs new file mode 100644 index 0000000..e5f90e0 --- /dev/null +++ b/StackDuel.Domain/Submissions/Exceptions/SubmissionResultNotFoundException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Submissions.Exceptions; + +public sealed class SubmissionResultNotFoundException : DomainException +{ + public SubmissionResultNotFoundException(Guid testCaseId) + : base($"Submission result for test case '{testCaseId}' was not found.") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/Factories/SubmissionFactory.cs b/StackDuel.Domain/Submissions/Factories/SubmissionFactory.cs new file mode 100644 index 0000000..1758d7a --- /dev/null +++ b/StackDuel.Domain/Submissions/Factories/SubmissionFactory.cs @@ -0,0 +1,32 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.ValueObjects; + +namespace StackDuel.Domain.Submissions.Factories; + +public sealed record CreateSubmissionParams( + Guid UserId, + Guid ProblemSetupId, + SubmissionType Type, + SourceCode SourceCode, + IEnumerable TestCaseIds, + IEnumerable? AdditionalFiles = null, + Guid? GameId = null +); + +public sealed class SubmissionFactory : IAggregateFactory +{ + public Submission Create(CreateSubmissionParams parameters) + { + return new Submission( + parameters.UserId, + parameters.ProblemSetupId, + parameters.Type, + parameters.SourceCode, + parameters.TestCaseIds, + parameters.AdditionalFiles, + parameters.GameId + ); + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/ISubmissionWriteRepository.cs b/StackDuel.Domain/Submissions/ISubmissionWriteRepository.cs new file mode 100644 index 0000000..a8566ca --- /dev/null +++ b/StackDuel.Domain/Submissions/ISubmissionWriteRepository.cs @@ -0,0 +1,6 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Submissions.Entities; + +namespace StackDuel.Domain.Submissions; + +public interface ISubmissionWriteRepository : IRepository { } \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/ValueObjects/SourceCode.cs b/StackDuel.Domain/Submissions/ValueObjects/SourceCode.cs new file mode 100644 index 0000000..88800dd --- /dev/null +++ b/StackDuel.Domain/Submissions/ValueObjects/SourceCode.cs @@ -0,0 +1,24 @@ +using StackDuel.Domain.Submissions.Exceptions; + +namespace StackDuel.Domain.Submissions.ValueObjects; + +public sealed record SourceCode +{ + public SourceCode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidSourceCodeException("Source code cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidSourceCodeException($"Source code cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(SourceCode code) => code.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 65536; + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Submissions/ValueObjects/SubmissionSourceFile.cs b/StackDuel.Domain/Submissions/ValueObjects/SubmissionSourceFile.cs new file mode 100644 index 0000000..47cbf84 --- /dev/null +++ b/StackDuel.Domain/Submissions/ValueObjects/SubmissionSourceFile.cs @@ -0,0 +1,3 @@ +namespace StackDuel.Domain.Submissions.ValueObjects; + +public sealed record SubmissionSourceFile(string Path, string Content); \ No newline at end of file diff --git a/StackDuel.Domain/TestCaseGeneration/Entities/TestCaseGenerationJob.cs b/StackDuel.Domain/TestCaseGeneration/Entities/TestCaseGenerationJob.cs new file mode 100644 index 0000000..2c80745 --- /dev/null +++ b/StackDuel.Domain/TestCaseGeneration/Entities/TestCaseGenerationJob.cs @@ -0,0 +1,86 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestCaseGeneration.Enums; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Domain.TestCaseGeneration.Entities; + +/// +/// A queued request to (re)generate a problem's random hidden test case pool. Decouples +/// requesting generation (a seeder today, an admin API eventually) from actually running it — +/// the request is just persisted here, and a background processor works through pending jobs +/// on its own schedule, so enqueuing never blocks the caller and many problems can be queued +/// at once without needing Judge0 available at request time. +/// +public sealed class TestCaseGenerationJob : AggregateRoot +{ + public TestCaseGenerationJob( + Guid problemSetupId, + string referenceSolutionCode, + IReadOnlyList parameters, + string outputValueType, + int targetCaseCount, + int seed + ) + { + ProblemSetupId = + problemSetupId != Guid.Empty + ? problemSetupId + : throw new ArgumentException("Problem setup id must not be empty.", nameof(problemSetupId)); + + ReferenceSolutionCode = !string.IsNullOrWhiteSpace(referenceSolutionCode) + ? referenceSolutionCode + : throw new ArgumentException("Reference solution code must not be empty.", nameof(referenceSolutionCode)); + + Parameters = parameters is { Count: > 0 } + ? parameters + : throw new ArgumentException("At least one parameter is required.", nameof(parameters)); + + OutputValueType = !string.IsNullOrWhiteSpace(outputValueType) + ? outputValueType + : throw new ArgumentException("Output value type must not be empty.", nameof(outputValueType)); + + TargetCaseCount = + targetCaseCount > 0 + ? targetCaseCount + : throw new ArgumentException("Target case count must be greater than zero.", nameof(targetCaseCount)); + + Seed = seed; + Status = TestCaseGenerationJobStatus.Pending; + CreatedAt = DateTime.UtcNow; + } + + private TestCaseGenerationJob() { } + + public void Complete(string resultSummary) + { + Status = TestCaseGenerationJobStatus.Completed; + ResultSummary = resultSummary; + CompletedAt = DateTime.UtcNow; + } + + public void Fail(string reason) + { + Status = TestCaseGenerationJobStatus.Failed; + FailureReason = reason; + CompletedAt = DateTime.UtcNow; + } + + public Guid ProblemSetupId { get; private set; } + public string ReferenceSolutionCode { get; private set; } = null!; + public IReadOnlyList Parameters { get; private set; } = []; + public string OutputValueType { get; private set; } = null!; + public int TargetCaseCount { get; private set; } + public int Seed { get; private set; } + public TestCaseGenerationJobStatus Status { get; private set; } + public string? ResultSummary { get; private set; } + public string? FailureReason { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + + /// + /// Lease expiry for the worker currently processing this job — prevents two overlapping + /// Quartz sweeps (a run that takes longer than the trigger interval) from double-processing + /// the same job. Set/cleared atomically via ExecuteUpdateAsync, not on the tracked entity. + /// + public DateTime? LockedUntil { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/TestCaseGeneration/Entities/TestCaseGenerationSpec.cs b/StackDuel.Domain/TestCaseGeneration/Entities/TestCaseGenerationSpec.cs new file mode 100644 index 0000000..4ad0331 --- /dev/null +++ b/StackDuel.Domain/TestCaseGeneration/Entities/TestCaseGenerationSpec.cs @@ -0,0 +1,60 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Domain.TestCaseGeneration.Entities; + +/// +/// The recipe used to produce a problem's generated hidden test cases: the declarative +/// input constraints, the expected output's value type, how many cases to generate, and the +/// seed that makes generation reproducible. Never mutated after creation — editing a +/// problem's constraints creates a new spec (with a new and incremented +/// ) so already-generated s +/// stay reproducible against the exact spec they were generated from. +/// +public sealed class TestCaseGenerationSpec : AggregateRoot +{ + public TestCaseGenerationSpec( + Guid problemSetupId, + IReadOnlyList parameters, + string outputValueType, + int targetCaseCount, + int seed, + int version + ) + { + ProblemSetupId = + problemSetupId != Guid.Empty + ? problemSetupId + : throw new ArgumentException("Problem setup id must not be empty.", nameof(problemSetupId)); + + Parameters = parameters is { Count: > 0 } + ? parameters + : throw new ArgumentException("At least one parameter is required.", nameof(parameters)); + + OutputValueType = !string.IsNullOrWhiteSpace(outputValueType) + ? outputValueType + : throw new ArgumentException("Output value type must not be empty.", nameof(outputValueType)); + + TargetCaseCount = + targetCaseCount > 0 + ? targetCaseCount + : throw new ArgumentException("Target case count must be greater than zero.", nameof(targetCaseCount)); + + Seed = seed; + + Version = + version > 0 ? version : throw new ArgumentException("Version must be greater than zero.", nameof(version)); + + CreatedAt = DateTime.UtcNow; + } + + private TestCaseGenerationSpec() { } + + public Guid ProblemSetupId { get; private set; } + public IReadOnlyList Parameters { get; private set; } = []; + public string OutputValueType { get; private set; } = null!; + public int TargetCaseCount { get; private set; } + public int Seed { get; private set; } + public int Version { get; private set; } + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/TestCaseGeneration/Enums/TestCaseGenerationJobStatus.cs b/StackDuel.Domain/TestCaseGeneration/Enums/TestCaseGenerationJobStatus.cs new file mode 100644 index 0000000..82c8641 --- /dev/null +++ b/StackDuel.Domain/TestCaseGeneration/Enums/TestCaseGenerationJobStatus.cs @@ -0,0 +1,9 @@ +namespace StackDuel.Domain.TestCaseGeneration.Enums; + +public enum TestCaseGenerationJobStatus +{ + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3, +} \ No newline at end of file diff --git a/StackDuel.Domain/TestCaseGeneration/ITestCaseGenerationJobRepository.cs b/StackDuel.Domain/TestCaseGeneration/ITestCaseGenerationJobRepository.cs new file mode 100644 index 0000000..e60d578 --- /dev/null +++ b/StackDuel.Domain/TestCaseGeneration/ITestCaseGenerationJobRepository.cs @@ -0,0 +1,22 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestCaseGeneration.Entities; + +namespace StackDuel.Domain.TestCaseGeneration; + +public interface ITestCaseGenerationJobRepository : IRepository +{ + Task> FindPendingAsync( + int batchSize, + CancellationToken cancellationToken = default + ); + + /// + /// Atomically claims the job for exclusive processing by extending its lock lease, but + /// only if it isn't already locked by another worker. Returns false if the job is + /// currently locked, completed, or failed. + /// + Task TryClaimAsync(Guid jobId, TimeSpan leaseDuration, CancellationToken cancellationToken = default); + + /// Releases the processing lock so the job can be picked up again if still pending. + Task ReleaseLockAsync(Guid jobId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/TestCaseGeneration/ValueObjects/GenerationParameterSpec.cs b/StackDuel.Domain/TestCaseGeneration/ValueObjects/GenerationParameterSpec.cs new file mode 100644 index 0000000..c2e8bdf --- /dev/null +++ b/StackDuel.Domain/TestCaseGeneration/ValueObjects/GenerationParameterSpec.cs @@ -0,0 +1,25 @@ +namespace StackDuel.Domain.TestCaseGeneration.ValueObjects; + +/// +/// Declarative constraint for one generated input parameter — data, not code, so it is +/// safe to accept from an untrusted problem author. Interpreted by a generator service +/// (e.g. one backed by Bogus) rather than executed. +/// +public sealed record GenerationParameterSpec( + string Name, + string ValueType, + double? Min, + double? Max, + int? LengthMin, + int? LengthMax, + string? Charset +) +{ + public string Name { get; } = + !string.IsNullOrWhiteSpace(Name) ? Name : throw new ArgumentException("Name must not be empty.", nameof(Name)); + + public string ValueType { get; } = + !string.IsNullOrWhiteSpace(ValueType) + ? ValueType + : throw new ArgumentException("Value type must not be empty.", nameof(ValueType)); +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/Entities/TestCase.cs b/StackDuel.Domain/TestSuites/Entities/TestCase.cs new file mode 100644 index 0000000..3704e4b --- /dev/null +++ b/StackDuel.Domain/TestSuites/Entities/TestCase.cs @@ -0,0 +1,76 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Domain.TestSuites.Entities; + +public sealed class TestCase : Entity +{ + internal TestCase(string name, string? description = null) + { + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + + Description = description; + Source = TestCaseSource.Authored; + } + + internal TestCase(string name, Guid generationSpecId, int generationCaseIndex, string? description = null) + { + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + + if (generationSpecId == Guid.Empty) + throw new ArgumentException("Generation spec id must not be empty.", nameof(generationSpecId)); + + if (generationCaseIndex < 0) + throw new ArgumentException("Generation case index must not be negative.", nameof(generationCaseIndex)); + + Description = description; + Source = TestCaseSource.Generated; + GenerationSpecId = generationSpecId; + GenerationCaseIndex = generationCaseIndex; + } + + public TestCaseInput AddInput(string value, string valueType) + { + var input = new TestCaseInput(value, valueType, _inputs.Count); + _inputs.Add(input); + return input; + } + + public TestCaseExpectedOutput AddExpectedOutput(string value, string valueType) + { + var output = new TestCaseExpectedOutput(value, valueType); + _expectedOutputs.Add(output); + return output; + } + + /// + /// Marks this case retired instead of deleting it — a submission's result can reference + /// a test case indefinitely (FK is ON DELETE RESTRICT), so a generated case that's ever + /// been graded against can never actually be removed. Retired cases are excluded from + /// future grading selection but keep existing for historical submissions to reference. + /// + public void Retire() + { + RetiredAt ??= DateTime.UtcNow; + } + + private TestCase() { } + + public string Name { get; private set; } = null!; + public string? Description { get; private set; } + + public TestCaseSource Source { get; private set; } = TestCaseSource.Authored; + public Guid? GenerationSpecId { get; private set; } + public int? GenerationCaseIndex { get; private set; } + public DateTime? RetiredAt { get; private set; } + + public IReadOnlyCollection Inputs => _inputs.AsReadOnly(); + public IReadOnlyCollection ExpectedOutputs => _expectedOutputs.AsReadOnly(); + + private readonly List _inputs = []; + private readonly List _expectedOutputs = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/Entities/TestCaseExpectedOutput.cs b/StackDuel.Domain/TestSuites/Entities/TestCaseExpectedOutput.cs new file mode 100644 index 0000000..ab2aecc --- /dev/null +++ b/StackDuel.Domain/TestSuites/Entities/TestCaseExpectedOutput.cs @@ -0,0 +1,22 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.TestSuites.Entities; + +public sealed class TestCaseExpectedOutput : Entity +{ + internal TestCaseExpectedOutput(string value, string valueType) + { + Value = !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException("Value must not be empty.", nameof(value)); + + ValueType = !string.IsNullOrWhiteSpace(valueType) + ? valueType + : throw new ArgumentException("Value type must not be empty.", nameof(valueType)); + } + + private TestCaseExpectedOutput() { } + + public string Value { get; private set; } = null!; + public string ValueType { get; private set; } = null!; +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/Entities/TestCaseInput.cs b/StackDuel.Domain/TestSuites/Entities/TestCaseInput.cs new file mode 100644 index 0000000..32b235a --- /dev/null +++ b/StackDuel.Domain/TestSuites/Entities/TestCaseInput.cs @@ -0,0 +1,32 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.TestSuites.Entities; + +public sealed class TestCaseInput : Entity +{ + internal TestCaseInput(string value, string valueType, int position) + { + Value = !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException("Value must not be empty.", nameof(value)); + + ValueType = !string.IsNullOrWhiteSpace(valueType) + ? valueType + : throw new ArgumentException("Value type must not be empty.", nameof(valueType)); + + Position = + position >= 0 ? position : throw new ArgumentException("Position must not be negative.", nameof(position)); + } + + private TestCaseInput() { } + + public string Value { get; private set; } = null!; + public string ValueType { get; private set; } = null!; + + /// + /// This input's zero-based position among the test case's other inputs — the order the + /// function's arguments must be passed in. Inputs are read back from the database + /// unordered by default, so every read site must order by this explicitly. + /// + public int Position { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/Entities/TestSuite.cs b/StackDuel.Domain/TestSuites/Entities/TestSuite.cs new file mode 100644 index 0000000..383a8ca --- /dev/null +++ b/StackDuel.Domain/TestSuites/Entities/TestSuite.cs @@ -0,0 +1,51 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Domain.TestSuites.Entities; + +public sealed class TestSuite : AggregateRoot +{ + public TestSuite(string name, TestSuiteType type) + { + Name = !string.IsNullOrWhiteSpace(name) + ? name + : throw new ArgumentException("Name must not be empty.", nameof(name)); + + Type = type; + CreatedAt = DateTime.UtcNow; + } + + public TestCase AddTestCase(string name, string? description = null) + { + var testCase = new TestCase(name, description); + _testCases.Add(testCase); + return testCase; + } + + public TestCase AddGeneratedTestCase( + string name, + Guid generationSpecId, + int generationCaseIndex, + string? description = null + ) + { + var testCase = new TestCase(name, generationSpecId, generationCaseIndex, description); + _testCases.Add(testCase); + return testCase; + } + + public void ClearGeneratedTestCases() + { + _testCases.RemoveAll(tc => tc.Source == TestCaseSource.Generated); + } + + private TestSuite() { } + + public string Name { get; private set; } = null!; + public TestSuiteType Type { get; private set; } + public DateTime CreatedAt { get; private set; } + + public IReadOnlyCollection TestCases => _testCases.AsReadOnly(); + + private readonly List _testCases = []; +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/Enums/TestCaseSource.cs b/StackDuel.Domain/TestSuites/Enums/TestCaseSource.cs new file mode 100644 index 0000000..19dbab8 --- /dev/null +++ b/StackDuel.Domain/TestSuites/Enums/TestCaseSource.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.TestSuites.Enums; + +public enum TestCaseSource +{ + Authored = 0, + Generated = 1, +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/Enums/TestSuiteType.cs b/StackDuel.Domain/TestSuites/Enums/TestSuiteType.cs new file mode 100644 index 0000000..20fcf12 --- /dev/null +++ b/StackDuel.Domain/TestSuites/Enums/TestSuiteType.cs @@ -0,0 +1,7 @@ +namespace StackDuel.Domain.TestSuites.Enums; + +public enum TestSuiteType +{ + Sample = 1, + Hidden = 2, +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/ITestSuiteWriteRepository.cs b/StackDuel.Domain/TestSuites/ITestSuiteWriteRepository.cs new file mode 100644 index 0000000..43a356e --- /dev/null +++ b/StackDuel.Domain/TestSuites/ITestSuiteWriteRepository.cs @@ -0,0 +1,93 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.ValueObjects; + +namespace StackDuel.Domain.TestSuites; + +public interface ITestSuiteWriteRepository : IRepository +{ + Task> FindTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ); + Task> FindPublicTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ); + + /// + /// Grading's test case selection: every public (sample) case and every hand-authored + /// hidden case always runs — those are guaranteed, not part of the random draw — topped + /// up with a random sample of generated hidden cases until + /// is reached (or until the generated pool runs out). If the guaranteed set alone already + /// meets or exceeds , no random cases are added on top. + /// + Task> FindGradingTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + int targetCount, + CancellationToken cancellationToken = default + ); + + /// + /// Value types aren't supplied by the client — each input is tagged with the value type + /// of the existing test case input at the same position, so strictly-typed languages + /// (C++, Java) can still generate valid parse calls for ad hoc/custom inputs. + /// + Task> CreateAdHocTestCasesAsync( + Guid problemSetupId, + IReadOnlyCollection> customTestCaseInputs, + CancellationToken cancellationToken = default + ); + Task> FindExpectedOutputsByTestCaseIdsAsync( + IEnumerable testCaseIds, + CancellationToken cancellationToken = default + ); + + /// + /// Resolves display-ready input values per test case id — materialized values for + /// authored cases, regenerated on the fly (same as grading does) for generated ones, + /// since their inputs aren't persisted. + /// + Task> FindInputsByTestCaseIdsAsync( + IEnumerable testCaseIds, + CancellationToken cancellationToken = default + ); + Task FindPipelineIdByProblemSetupIdAsync(Guid problemSetupId, CancellationToken cancellationToken = default); + + /// + /// Finds the Hidden test suite used to hold a problem's generated test cases, creating it + /// if it doesn't exist yet, and ensures it is linked to every given problem setup. + /// + Task FindOrCreateGeneratedSuiteAsync( + string suiteName, + IReadOnlyCollection problemSetupIds, + CancellationToken cancellationToken = default + ); + + /// + /// Retires all currently-active Generated-source test cases in a suite, in preparation for + /// repopulating it with a fresh generated batch. Retiring rather than deleting means a + /// submission that already graded against one of these cases keeps a valid reference — + /// submission_results.test_case_id is ON DELETE RESTRICT, so a case that's ever been + /// graded against can never actually be removed. + /// + Task RetireGeneratedTestCasesAsync(Guid testSuiteId, CancellationToken cancellationToken = default); + + /// + /// Finds or creates the Sample test suite for a problem and replaces its admin-authored + /// cases with exactly the given set, ensuring it stays linked to every given problem setup — + /// sample case values are language-agnostic (only the reference solution differs per + /// language), so one suite is shared across all of a problem's setups, same as + /// does for generated cases. Retires (rather + /// than deletes) any cases no longer present, for the same reason + /// retires instead of deleting — a sample case is + /// graded against per , so a past + /// submission may already reference one. + /// + Task ReplaceSampleTestCasesAsync( + IReadOnlyCollection problemSetupIds, + string suiteName, + IReadOnlyList testCases, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Domain/TestSuites/ValueObjects/AuthoredTestCaseSpec.cs b/StackDuel.Domain/TestSuites/ValueObjects/AuthoredTestCaseSpec.cs new file mode 100644 index 0000000..c979466 --- /dev/null +++ b/StackDuel.Domain/TestSuites/ValueObjects/AuthoredTestCaseSpec.cs @@ -0,0 +1,28 @@ +namespace StackDuel.Domain.TestSuites.ValueObjects; + +/// One admin-authored sample test case, as submitted from the create-problem form. +public sealed record AuthoredTestCaseSpec( + string? Name, + IReadOnlyList<(string Value, string ValueType)> Inputs, + string ExpectedOutputValue, + string ExpectedOutputValueType +) +{ + public IReadOnlyList<(string Value, string ValueType)> Inputs { get; } = + Inputs is { Count: > 0 } + ? Inputs + : throw new ArgumentException("At least one input is required.", nameof(Inputs)); + + public string ExpectedOutputValue { get; } = + !string.IsNullOrWhiteSpace(ExpectedOutputValue) + ? ExpectedOutputValue + : throw new ArgumentException("Expected output value must not be empty.", nameof(ExpectedOutputValue)); + + public string ExpectedOutputValueType { get; } = + !string.IsNullOrWhiteSpace(ExpectedOutputValueType) + ? ExpectedOutputValueType + : throw new ArgumentException( + "Expected output value type must not be empty.", + nameof(ExpectedOutputValueType) + ); +} \ No newline at end of file diff --git a/StackDuel.Domain/Tracks/Entities/Track.cs b/StackDuel.Domain/Tracks/Entities/Track.cs new file mode 100644 index 0000000..9470b94 --- /dev/null +++ b/StackDuel.Domain/Tracks/Entities/Track.cs @@ -0,0 +1,35 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Tracks.Entities; + +public sealed class Track : AggregateRoot +{ + public Track(string key, string name) + { + Key = !string.IsNullOrWhiteSpace(key) + ? key.Trim().ToLowerInvariant() + : throw new ArgumentException("Track key must not be empty.", nameof(key)); + + Name = !string.IsNullOrWhiteSpace(name) + ? name.Trim() + : throw new ArgumentException("Track name must not be empty.", nameof(name)); + + IsActive = true; + AllowsLanguageSelection = true; + } + + public void Activate() => IsActive = true; + + public void Deactivate() => IsActive = false; + + public void EnableLanguageSelection() => AllowsLanguageSelection = true; + + public void DisableLanguageSelection() => AllowsLanguageSelection = false; + + private Track() { } + + public string Key { get; private set; } = null!; + public string Name { get; private set; } = null!; + public bool IsActive { get; private set; } + public bool AllowsLanguageSelection { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/User/Entities/User.cs b/StackDuel.Domain/User/Entities/User.cs deleted file mode 100644 index 1523b25..0000000 --- a/StackDuel.Domain/User/Entities/User.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Entities; - -internal class User -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/Exceptions/InvalidBioException.cs b/StackDuel.Domain/User/Exceptions/InvalidBioException.cs deleted file mode 100644 index 321fd8b..0000000 --- a/StackDuel.Domain/User/Exceptions/InvalidBioException.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Exceptions; - -internal class InvalidBioException -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/Exceptions/InvalidImageUrlException.cs b/StackDuel.Domain/User/Exceptions/InvalidImageUrlException.cs deleted file mode 100644 index 470a0b9..0000000 --- a/StackDuel.Domain/User/Exceptions/InvalidImageUrlException.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Exceptions; - -internal class InvalidImageUrlException -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/Exceptions/InvalidUserSubException.cs b/StackDuel.Domain/User/Exceptions/InvalidUserSubException.cs deleted file mode 100644 index a0e2025..0000000 --- a/StackDuel.Domain/User/Exceptions/InvalidUserSubException.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Exceptions; - -internal class InvalidUserSubException -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/Exceptions/InvalidUsernameException.cs b/StackDuel.Domain/User/Exceptions/InvalidUsernameException.cs deleted file mode 100644 index a5e34f1..0000000 --- a/StackDuel.Domain/User/Exceptions/InvalidUsernameException.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Exceptions; - -internal class InvalidUsernameException -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/Exceptions/UsernameCooldownException.cs b/StackDuel.Domain/User/Exceptions/UsernameCooldownException.cs deleted file mode 100644 index 94566c5..0000000 --- a/StackDuel.Domain/User/Exceptions/UsernameCooldownException.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Exceptions; - -internal class UsernameCooldownException -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/Factories/UserFactory.cs b/StackDuel.Domain/User/Factories/UserFactory.cs deleted file mode 100644 index e052465..0000000 --- a/StackDuel.Domain/User/Factories/UserFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.Factories; - -internal class UserFactory -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/ValueObjects/Bio.cs b/StackDuel.Domain/User/ValueObjects/Bio.cs deleted file mode 100644 index 61812d1..0000000 --- a/StackDuel.Domain/User/ValueObjects/Bio.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.ValueObjects; - -internal class Bio -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/ValueObjects/ImageUrl.cs b/StackDuel.Domain/User/ValueObjects/ImageUrl.cs deleted file mode 100644 index 0dc754c..0000000 --- a/StackDuel.Domain/User/ValueObjects/ImageUrl.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.ValueObjects; - -internal class ImageUrl -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/User/ValueObjects/Username.cs b/StackDuel.Domain/User/ValueObjects/Username.cs deleted file mode 100644 index 6f49f0f..0000000 --- a/StackDuel.Domain/User/ValueObjects/Username.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace StackDuel.Domain.User.ValueObjects; - -internal class Username -{ -} \ No newline at end of file diff --git a/StackDuel.Domain/Users/Entities/User.cs b/StackDuel.Domain/Users/Entities/User.cs new file mode 100644 index 0000000..fdabc2d --- /dev/null +++ b/StackDuel.Domain/Users/Entities/User.cs @@ -0,0 +1,59 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users.Events; +using StackDuel.Domain.Users.Exceptions; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Users.Entities; + +public sealed class User(Username username, string sub, string? tenant = null) : AggregateRoot +{ + public void ChangeUsername(Username username) + { + if ( + UsernameLastChangedAt.HasValue + && DateTime.UtcNow - UsernameLastChangedAt.Value < TimeSpan.FromDays(MaxDaysUntilUsernameChange) + ) + throw new UsernameCooldownException(UsernameLastChangedAt.Value); + + Username = username; + UsernameLastChangedAt = DateTime.UtcNow; + } + + public void UpdateBio(Bio? bio) + { + Bio = bio; + } + + public void UpdateImageUrl(ImageUrl? imageUrl) + { + ImageUrl = imageUrl; + } + + public void CompleteSetup() => SetupCompletedAt ??= DateTime.UtcNow; + + public void SetPrivate(bool isPrivate) => IsPrivate = isPrivate; + + public void MarkCreated() => AddDomainEvent(new UserCreatedDomainEvent(Id)); + + public string? Tenant { get; private set; } = tenant; + + public Bio? Bio { get; private set; } + + public ImageUrl? ImageUrl { get; private set; } + + public string Sub { get; private set; } = + string.IsNullOrWhiteSpace(sub) ? throw new InvalidUserSubException() : sub; + + public Username Username { get; private set; } = + username ?? throw new InvalidUsernameException("Username is required."); + + public DateTime? UsernameLastChangedAt { get; private set; } + + public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; + + public DateTime? SetupCompletedAt { get; private set; } + + public bool IsPrivate { get; private set; } + + public static readonly int MaxDaysUntilUsernameChange = 30; +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/Entities/UserAvatar.cs b/StackDuel.Domain/Users/Entities/UserAvatar.cs new file mode 100644 index 0000000..d471205 --- /dev/null +++ b/StackDuel.Domain/Users/Entities/UserAvatar.cs @@ -0,0 +1,22 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Users.Entities; + +public sealed class UserAvatar : AggregateRoot +{ + public UserAvatar(Guid userId, ImageUrl imageUrl) + { + UserId = userId; + ImageUrl = imageUrl ?? throw new ArgumentNullException(nameof(imageUrl)); + CreatedAt = DateTime.UtcNow; + } + + private UserAvatar() { } + + public Guid UserId { get; private set; } + + public ImageUrl ImageUrl { get; private set; } = null!; + + public DateTime CreatedAt { get; private set; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/Events/UserAccessContextChangedDomainEvent.cs b/StackDuel.Domain/Users/Events/UserAccessContextChangedDomainEvent.cs new file mode 100644 index 0000000..ed440c3 --- /dev/null +++ b/StackDuel.Domain/Users/Events/UserAccessContextChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Events; + +public sealed record UserAccessContextChangedDomainEvent(string Sub) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Users/Events/UserCreatedDomainEvent.cs b/StackDuel.Domain/Users/Events/UserCreatedDomainEvent.cs new file mode 100644 index 0000000..03dd5f7 --- /dev/null +++ b/StackDuel.Domain/Users/Events/UserCreatedDomainEvent.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Events; + +public sealed record UserCreatedDomainEvent(Guid UserId) : IDomainEvent; \ No newline at end of file diff --git a/StackDuel.Domain/Users/Exceptions/InvalidBioException.cs b/StackDuel.Domain/Users/Exceptions/InvalidBioException.cs new file mode 100644 index 0000000..f106828 --- /dev/null +++ b/StackDuel.Domain/Users/Exceptions/InvalidBioException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Exceptions; + +public sealed class InvalidBioException : DomainException +{ + public InvalidBioException(string reason) + : base($"Bio is invalid: {reason}") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/Exceptions/InvalidImageUrlException.cs b/StackDuel.Domain/Users/Exceptions/InvalidImageUrlException.cs new file mode 100644 index 0000000..22cfbea --- /dev/null +++ b/StackDuel.Domain/Users/Exceptions/InvalidImageUrlException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Exceptions; + +public sealed class InvalidImageUrlException(string reason) : DomainException($"Image URL is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Users/Exceptions/InvalidUserSubException.cs b/StackDuel.Domain/Users/Exceptions/InvalidUserSubException.cs new file mode 100644 index 0000000..aa7bcb7 --- /dev/null +++ b/StackDuel.Domain/Users/Exceptions/InvalidUserSubException.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Exceptions; + +public sealed class InvalidUserSubException : DomainException +{ + public InvalidUserSubException() + : base("User sub is required.") { } +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/Exceptions/InvalidUsernameException.cs b/StackDuel.Domain/Users/Exceptions/InvalidUsernameException.cs new file mode 100644 index 0000000..b178244 --- /dev/null +++ b/StackDuel.Domain/Users/Exceptions/InvalidUsernameException.cs @@ -0,0 +1,5 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Exceptions; + +public sealed class InvalidUsernameException(string reason) : DomainException($"Username is invalid: {reason}") { } \ No newline at end of file diff --git a/StackDuel.Domain/Users/Exceptions/UsernameCooldownException.cs b/StackDuel.Domain/Users/Exceptions/UsernameCooldownException.cs new file mode 100644 index 0000000..c74b271 --- /dev/null +++ b/StackDuel.Domain/Users/Exceptions/UsernameCooldownException.cs @@ -0,0 +1,7 @@ +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Domain.Users.Exceptions; + +public sealed class UsernameCooldownException(DateTime lastChangedAt) + : DomainException($"Username can only be changed once every 30 days. Last changed at: {lastChangedAt}.") +{ } \ No newline at end of file diff --git a/StackDuel.Domain/Users/Factories/UserFactory.cs b/StackDuel.Domain/Users/Factories/UserFactory.cs new file mode 100644 index 0000000..7f73c0d --- /dev/null +++ b/StackDuel.Domain/Users/Factories/UserFactory.cs @@ -0,0 +1,20 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Users.Factories; + +public sealed record CreateUserParams(string Username, string Sub, string? ImageUrl, string? Tenant = null); + +public sealed class UserFactory : IAggregateFactory +{ + public User Create(CreateUserParams parameters) + { + var username = new Username(parameters.Username); + var imageUrl = parameters.ImageUrl is not null ? new ImageUrl(parameters.ImageUrl) : null; + var user = new User(username, parameters.Sub, parameters.Tenant); + user.UpdateImageUrl(imageUrl); + user.MarkCreated(); + return user; + } +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/IUserAvatarWriteRepository.cs b/StackDuel.Domain/Users/IUserAvatarWriteRepository.cs new file mode 100644 index 0000000..ce4baf5 --- /dev/null +++ b/StackDuel.Domain/Users/IUserAvatarWriteRepository.cs @@ -0,0 +1,15 @@ +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Domain.Users; + +public interface IUserAvatarWriteRepository +{ + Task AddAsync(UserAvatar avatar, CancellationToken cancellationToken = default); + Task FindByIdForUserAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + + Task> GetAllByUserIdNewestFirstAsync( + Guid userId, + CancellationToken cancellationToken = default + ); + Task DeleteAsync(UserAvatar avatar, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/IUserWriteRepository.cs b/StackDuel.Domain/Users/IUserWriteRepository.cs new file mode 100644 index 0000000..658b034 --- /dev/null +++ b/StackDuel.Domain/Users/IUserWriteRepository.cs @@ -0,0 +1,19 @@ +using StackDuel.Domain.SeedWork; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Domain.Users; + +public interface IUserWriteRepository : IRepository +{ + Task FindBySubAsync(string sub, CancellationToken cancellationToken = default); + Task FindByUsername(Username username, CancellationToken cancellationToken = default); + Task AddToGroupAsync(Guid userId, string groupName, CancellationToken cancellationToken = default); + Task> GetGroupIdsAsync(Guid userId, CancellationToken cancellationToken = default); + Task SetGroupsAsync(Guid userId, IReadOnlyCollection groupIds, CancellationToken cancellationToken = default); + Task SetLanguagePreferencesAsync( + Guid userId, + IReadOnlyList languageIdsInOrder, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/ValueObjects/Bio.cs b/StackDuel.Domain/Users/ValueObjects/Bio.cs new file mode 100644 index 0000000..ddd66ff --- /dev/null +++ b/StackDuel.Domain/Users/ValueObjects/Bio.cs @@ -0,0 +1,25 @@ +using StackDuel.Domain.Users.Exceptions; + +namespace StackDuel.Domain.Users.ValueObjects; + +public sealed record Bio +{ + public Bio(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidBioException("Bio cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidBioException($"Bio cannot exceed {MaxLength} characters."); + + Value = value; + } + + public static implicit operator string(Bio bio) => bio.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 500; + + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/ValueObjects/ImageUrl.cs b/StackDuel.Domain/Users/ValueObjects/ImageUrl.cs new file mode 100644 index 0000000..ca2ce6e --- /dev/null +++ b/StackDuel.Domain/Users/ValueObjects/ImageUrl.cs @@ -0,0 +1,31 @@ +using StackDuel.Domain.Users.Exceptions; + +namespace StackDuel.Domain.Users.ValueObjects; + +public sealed record ImageUrl +{ + public ImageUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidImageUrlException("Image URL cannot be empty."); + + if (value.Length > MaxLength) + throw new InvalidImageUrlException($"Image URL cannot exceed {MaxLength} characters."); + + if ( + !Uri.TryCreate(value, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + ) + throw new InvalidImageUrlException("Image URL must be a valid HTTP or HTTPS URL."); + + Value = value; + } + + public static implicit operator string(ImageUrl url) => url.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 2048; + + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Domain/Users/ValueObjects/Username.cs b/StackDuel.Domain/Users/ValueObjects/Username.cs new file mode 100644 index 0000000..a0c4b42 --- /dev/null +++ b/StackDuel.Domain/Users/ValueObjects/Username.cs @@ -0,0 +1,29 @@ +using StackDuel.Domain.Users.Exceptions; + +namespace StackDuel.Domain.Users.ValueObjects; + +public sealed record Username +{ + public Username(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidUsernameException("Username cannot be null or whitespace."); + + if (value.Length < MinLength || value.Length > MaxLength) + throw new InvalidUsernameException($"Username must be between {MinLength} and {MaxLength} characters."); + + if (!value.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + throw new InvalidUsernameException("Username can only contain letters, digits, underscores, or hyphens."); + Value = value; + } + + public static implicit operator string(Username username) => username.Value; + + public override string ToString() => Value; + + public static readonly int MaxLength = 20; + + public static readonly int MinLength = 1; + + public string Value { get; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure.Tests/StackDuel.Infrastructure.Tests.csproj b/StackDuel.Infrastructure.Tests/StackDuel.Infrastructure.Tests.csproj index edb299f..569ea1c 100644 --- a/StackDuel.Infrastructure.Tests/StackDuel.Infrastructure.Tests.csproj +++ b/StackDuel.Infrastructure.Tests/StackDuel.Infrastructure.Tests.csproj @@ -18,4 +18,8 @@ + + + + \ No newline at end of file diff --git a/StackDuel.Infrastructure.Tests/xunit.runner.json b/StackDuel.Infrastructure.Tests/xunit.runner.json new file mode 100644 index 0000000..a8a4636 --- /dev/null +++ b/StackDuel.Infrastructure.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": true, + "parallelizeTestCollections": true +} diff --git a/StackDuel.Infrastructure/Class1.cs b/StackDuel.Infrastructure/Class1.cs deleted file mode 100644 index 3682104..0000000 --- a/StackDuel.Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StackDuel.Infrastructure; - -public class Class1 -{ - -} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/AssertStepConfiguration/AssertStepConfiguration.cs b/StackDuel.Infrastructure/ExecutionEngine/AssertStepConfiguration/AssertStepConfiguration.cs new file mode 100644 index 0000000..9113506 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/AssertStepConfiguration/AssertStepConfiguration.cs @@ -0,0 +1,19 @@ +namespace StackDuel.Infrastructure.ExecutionEngine.Assert; + +public enum AssertStrategy +{ + ExactMatch = 1, + FloatTolerance = 2, + SetEquality = 3, + Regex = 4, +} + +/// Infrastructure-only POCO — not a domain entity. +public sealed class AssertStepConfiguration +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PipelineStepId { get; set; } + public AssertStrategy Strategy { get; set; } = AssertStrategy.ExactMatch; + public decimal? Tolerance { get; set; } + public bool CaseSensitive { get; set; } = true; +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/harness.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/harness.js new file mode 100644 index 0000000..3fc0b21 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/harness.js @@ -0,0 +1,64 @@ +const React = require("react"); +const { createRoot, render, runSync } = require("./host-config"); +const { Solution } = require("./solution"); + +const PRESENTATIONAL_PROPS = new Set(["className", "style"]); + +function serialize(node) { + if (node.type === "#text") return node.text; + + const props = {}; + for (const [key, value] of Object.entries(node.props || {})) { + if (key === "children") continue; + if (typeof value === "function") continue; + if (PRESENTATIONAL_PROPS.has(key)) continue; + props[key] = value; + } + + return { + type: node.type, + props, + children: node.children.map(serialize), + }; +} + +function findByTestId(node, testId) { + if (node.props && node.props["data-testid"] === testId) return node; + for (const child of node.children) { + if (child.type === "#text") continue; + const found = findByTestId(child, testId); + if (found) return found; + } + return null; +} + +const ACTION_HANDLERS = { + click: (node) => node.props.onClick && node.props.onClick(), + change: (node, action) => + node.props.onChange && node.props.onChange({ target: { value: action.value } }), +}; + +function main() { + const raw = require("fs").readFileSync(0, "utf8"); + const { props = {}, actions = [] } = JSON.parse(raw || "{}"); + + const { container, root } = createRoot(); + + render(root, React.createElement(Solution, props)); + + for (const action of actions) { + const target = findByTestId(container, action.testId); + if (!target) throw new Error(`No element with data-testid="${action.testId}" found`); + const handler = ACTION_HANDLERS[action.type]; + if (!handler) throw new Error(`Unsupported action type "${action.type}"`); + + runSync(() => { + handler(target, action); + }); + } + + const output = container.children.map(serialize); + process.stdout.write(JSON.stringify(output.length === 1 ? output[0] : output)); +} + +main(); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/host-config.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/host-config.js new file mode 100644 index 0000000..3611cb4 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/host-config.js @@ -0,0 +1,119 @@ +const React = require("react"); +const Reconciler = require("react-reconciler"); +const { DefaultEventPriority, NoEventPriority } = require("react-reconciler/constants"); + +let currentUpdatePriority = NoEventPriority; + +function insertBeforeIn(list, child, beforeChild) { + const index = list.indexOf(beforeChild); + if (index === -1) list.push(child); + else list.splice(index, 0, child); +} + +const hostConfig = { + supportsMutation: true, + supportsPersistence: false, + supportsHydration: false, + isPrimaryRenderer: true, + noTimeout: -1, + + now: Date.now, + getCurrentEventPriority: () => DefaultEventPriority, + getCurrentUpdatePriority: () => currentUpdatePriority, + setCurrentUpdatePriority: (priority) => { + currentUpdatePriority = priority; + }, + resolveUpdatePriority: () => currentUpdatePriority || DefaultEventPriority, + shouldAttemptEagerTransition: () => false, + NotPendingTransition: null, + HostTransitionContext: React.createContext(null), + requestPostPaintCallback: (callback) => callback(), + maySuspendCommit: () => false, + preloadInstance: () => true, + startSuspendingCommit: () => {}, + suspendInstance: () => {}, + waitForCommitToBeReady: () => null, + resetFormInstance: () => {}, + + getRootHostContext: () => ({}), + getChildHostContext: (parentHostContext) => parentHostContext, + getPublicInstance: (instance) => instance, + prepareForCommit: () => null, + resetAfterCommit: () => {}, + shouldSetTextContent: () => false, + + createInstance: (type, props) => ({ type, props, children: [] }), + createTextInstance: (text) => ({ type: "#text", text }), + + appendInitialChild: (parent, child) => parent.children.push(child), + appendChild: (parent, child) => parent.children.push(child), + appendChildToContainer: (container, child) => container.children.push(child), + + insertBefore: (parent, child, beforeChild) => insertBeforeIn(parent.children, child, beforeChild), + insertInContainerBefore: (container, child, beforeChild) => + insertBeforeIn(container.children, child, beforeChild), + + removeChild: (parent, child) => { + const idx = parent.children.indexOf(child); + if (idx !== -1) parent.children.splice(idx, 1); + }, + removeChildFromContainer: (container, child) => { + const idx = container.children.indexOf(child); + if (idx !== -1) container.children.splice(idx, 1); + }, + clearContainer: (container) => { + container.children = []; + }, + + finalizeInitialChildren: () => false, + prepareUpdate: () => true, + commitUpdate: (instance, _type, _oldProps, newProps) => { + instance.props = newProps; + }, + commitTextUpdate: (textInstance, _oldText, newText) => { + textInstance.text = newText; + }, + + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + + preparePortalMount: () => {}, + detachDeletedInstance: () => {}, +}; + +const StackDuelReconciler = Reconciler(hostConfig); + +function reportFatal(error) { + const message = error && error.stack ? error.stack : String(error); + process.stderr.write(message + "\n"); + process.exit(1); +} + +function createRoot() { + const container = { type: "#root", children: [] }; + const root = StackDuelReconciler.createContainer( + container, + 0, + null, + false, + null, + "stackduel-", + reportFatal, + reportFatal, + reportFatal, + null + ); + return { container, root }; +} + +function render(root, element) { + StackDuelReconciler.updateContainerSync(element, root, null, null); + StackDuelReconciler.flushSyncWork(); +} + +function runSync(fn) { + StackDuelReconciler.flushSyncFromReconciler(fn); + StackDuelReconciler.flushSyncWork(); +} + +module.exports = { createRoot, render, runSync }; diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/cjs/react-reconciler-constants.production.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/cjs/react-reconciler-constants.production.js new file mode 100644 index 0000000..5965dfd --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/cjs/react-reconciler-constants.production.js @@ -0,0 +1,18 @@ +/** + * @license React + * react-reconciler-constants.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +"use strict"; +exports.ConcurrentRoot = 1; +exports.ContinuousEventPriority = 8; +exports.DefaultEventPriority = 32; +exports.DiscreteEventPriority = 2; +exports.IdleEventPriority = 268435456; +exports.LegacyRoot = 0; +exports.NoEventPriority = 0; diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/cjs/react-reconciler.production.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/cjs/react-reconciler.production.js new file mode 100644 index 0000000..849d3a3 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/cjs/react-reconciler.production.js @@ -0,0 +1,10926 @@ +/** + * @license React + * react-reconciler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +"use strict"; +module.exports = function ($$$config) { + function createFiber(tag, pendingProps, key, mode) { + return new FiberNode(tag, pendingProps, key, mode); + } + function noop() {} + function formatProdErrorMessage(code) { + var url = "https://react.dev/errors/" + code; + if (1 < arguments.length) { + url += "?args[]=" + encodeURIComponent(arguments[1]); + for (var i = 2; i < arguments.length; i++) + url += "&args[]=" + encodeURIComponent(arguments[i]); + } + return ( + "Minified React error #" + + code + + "; visit " + + url + + " for the full message or use the non-minified dev environment for full errors and additional helpful warnings." + ); + } + function getIteratorFn(maybeIterable) { + if (null === maybeIterable || "object" !== typeof maybeIterable) + return null; + maybeIterable = + (MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL]) || + maybeIterable["@@iterator"]; + return "function" === typeof maybeIterable ? maybeIterable : null; + } + function getComponentNameFromType(type) { + if (null == type) return null; + if ("function" === typeof type) + return type.$$typeof === REACT_CLIENT_REFERENCE + ? null + : type.displayName || type.name || null; + if ("string" === typeof type) return type; + switch (type) { + case REACT_FRAGMENT_TYPE: + return "Fragment"; + case REACT_PORTAL_TYPE: + return "Portal"; + case REACT_PROFILER_TYPE: + return "Profiler"; + case REACT_STRICT_MODE_TYPE: + return "StrictMode"; + case REACT_SUSPENSE_TYPE: + return "Suspense"; + case REACT_SUSPENSE_LIST_TYPE: + return "SuspenseList"; + } + if ("object" === typeof type) + switch (type.$$typeof) { + case REACT_CONTEXT_TYPE: + return (type.displayName || "Context") + ".Provider"; + case REACT_CONSUMER_TYPE: + return (type._context.displayName || "Context") + ".Consumer"; + case REACT_FORWARD_REF_TYPE: + var innerType = type.render; + type = type.displayName; + type || + ((type = innerType.displayName || innerType.name || ""), + (type = "" !== type ? "ForwardRef(" + type + ")" : "ForwardRef")); + return type; + case REACT_MEMO_TYPE: + return ( + (innerType = type.displayName || null), + null !== innerType + ? innerType + : getComponentNameFromType(type.type) || "Memo" + ); + case REACT_LAZY_TYPE: + innerType = type._payload; + type = type._init; + try { + return getComponentNameFromType(type(innerType)); + } catch (x) {} + } + return null; + } + function describeBuiltInComponentFrame(name) { + if (void 0 === prefix) + try { + throw Error(); + } catch (x) { + var match = x.stack.trim().match(/\n( *(at )?)/); + prefix = (match && match[1]) || ""; + suffix = + -1 < x.stack.indexOf("\n at") + ? " ()" + : -1 < x.stack.indexOf("@") + ? "@unknown:0:0" + : ""; + } + return "\n" + prefix + name + suffix; + } + function describeNativeComponentFrame(fn, construct) { + if (!fn || reentry) return ""; + reentry = !0; + var previousPrepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = void 0; + try { + var RunInRootFrame = { + DetermineComponentFrameRoot: function () { + try { + if (construct) { + var Fake = function () { + throw Error(); + }; + Object.defineProperty(Fake.prototype, "props", { + set: function () { + throw Error(); + } + }); + if ("object" === typeof Reflect && Reflect.construct) { + try { + Reflect.construct(Fake, []); + } catch (x) { + var control = x; + } + Reflect.construct(fn, [], Fake); + } else { + try { + Fake.call(); + } catch (x$0) { + control = x$0; + } + fn.call(Fake.prototype); + } + } else { + try { + throw Error(); + } catch (x$1) { + control = x$1; + } + (Fake = fn()) && + "function" === typeof Fake.catch && + Fake.catch(function () {}); + } + } catch (sample) { + if (sample && control && "string" === typeof sample.stack) + return [sample.stack, control.stack]; + } + return [null, null]; + } + }; + RunInRootFrame.DetermineComponentFrameRoot.displayName = + "DetermineComponentFrameRoot"; + var namePropDescriptor = Object.getOwnPropertyDescriptor( + RunInRootFrame.DetermineComponentFrameRoot, + "name" + ); + namePropDescriptor && + namePropDescriptor.configurable && + Object.defineProperty( + RunInRootFrame.DetermineComponentFrameRoot, + "name", + { value: "DetermineComponentFrameRoot" } + ); + var _RunInRootFrame$Deter = RunInRootFrame.DetermineComponentFrameRoot(), + sampleStack = _RunInRootFrame$Deter[0], + controlStack = _RunInRootFrame$Deter[1]; + if (sampleStack && controlStack) { + var sampleLines = sampleStack.split("\n"), + controlLines = controlStack.split("\n"); + for ( + namePropDescriptor = RunInRootFrame = 0; + RunInRootFrame < sampleLines.length && + !sampleLines[RunInRootFrame].includes("DetermineComponentFrameRoot"); + + ) + RunInRootFrame++; + for ( + ; + namePropDescriptor < controlLines.length && + !controlLines[namePropDescriptor].includes( + "DetermineComponentFrameRoot" + ); + + ) + namePropDescriptor++; + if ( + RunInRootFrame === sampleLines.length || + namePropDescriptor === controlLines.length + ) + for ( + RunInRootFrame = sampleLines.length - 1, + namePropDescriptor = controlLines.length - 1; + 1 <= RunInRootFrame && + 0 <= namePropDescriptor && + sampleLines[RunInRootFrame] !== controlLines[namePropDescriptor]; + + ) + namePropDescriptor--; + for ( + ; + 1 <= RunInRootFrame && 0 <= namePropDescriptor; + RunInRootFrame--, namePropDescriptor-- + ) + if ( + sampleLines[RunInRootFrame] !== controlLines[namePropDescriptor] + ) { + if (1 !== RunInRootFrame || 1 !== namePropDescriptor) { + do + if ( + (RunInRootFrame--, + namePropDescriptor--, + 0 > namePropDescriptor || + sampleLines[RunInRootFrame] !== + controlLines[namePropDescriptor]) + ) { + var frame = + "\n" + + sampleLines[RunInRootFrame].replace(" at new ", " at "); + fn.displayName && + frame.includes("") && + (frame = frame.replace("", fn.displayName)); + return frame; + } + while (1 <= RunInRootFrame && 0 <= namePropDescriptor); + } + break; + } + } + } finally { + (reentry = !1), (Error.prepareStackTrace = previousPrepareStackTrace); + } + return (previousPrepareStackTrace = fn ? fn.displayName || fn.name : "") + ? describeBuiltInComponentFrame(previousPrepareStackTrace) + : ""; + } + function describeFiber(fiber) { + switch (fiber.tag) { + case 26: + case 27: + case 5: + return describeBuiltInComponentFrame(fiber.type); + case 16: + return describeBuiltInComponentFrame("Lazy"); + case 13: + return describeBuiltInComponentFrame("Suspense"); + case 19: + return describeBuiltInComponentFrame("SuspenseList"); + case 0: + case 15: + return (fiber = describeNativeComponentFrame(fiber.type, !1)), fiber; + case 11: + return ( + (fiber = describeNativeComponentFrame(fiber.type.render, !1)), fiber + ); + case 1: + return (fiber = describeNativeComponentFrame(fiber.type, !0)), fiber; + default: + return ""; + } + } + function getStackByFiberInDevAndProd(workInProgress) { + try { + var info = ""; + do + (info += describeFiber(workInProgress)), + (workInProgress = workInProgress.return); + while (workInProgress); + return info; + } catch (x) { + return "\nError generating stack: " + x.message + "\n" + x.stack; + } + } + function getNearestMountedFiber(fiber) { + var node = fiber, + nearestMounted = fiber; + if (fiber.alternate) for (; node.return; ) node = node.return; + else { + fiber = node; + do + (node = fiber), + 0 !== (node.flags & 4098) && (nearestMounted = node.return), + (fiber = node.return); + while (fiber); + } + return 3 === node.tag ? nearestMounted : null; + } + function assertIsMounted(fiber) { + if (getNearestMountedFiber(fiber) !== fiber) + throw Error(formatProdErrorMessage(188)); + } + function findCurrentFiberUsingSlowPath(fiber) { + var alternate = fiber.alternate; + if (!alternate) { + alternate = getNearestMountedFiber(fiber); + if (null === alternate) throw Error(formatProdErrorMessage(188)); + return alternate !== fiber ? null : fiber; + } + for (var a = fiber, b = alternate; ; ) { + var parentA = a.return; + if (null === parentA) break; + var parentB = parentA.alternate; + if (null === parentB) { + b = parentA.return; + if (null !== b) { + a = b; + continue; + } + break; + } + if (parentA.child === parentB.child) { + for (parentB = parentA.child; parentB; ) { + if (parentB === a) return assertIsMounted(parentA), fiber; + if (parentB === b) return assertIsMounted(parentA), alternate; + parentB = parentB.sibling; + } + throw Error(formatProdErrorMessage(188)); + } + if (a.return !== b.return) (a = parentA), (b = parentB); + else { + for (var didFindChild = !1, child$2 = parentA.child; child$2; ) { + if (child$2 === a) { + didFindChild = !0; + a = parentA; + b = parentB; + break; + } + if (child$2 === b) { + didFindChild = !0; + b = parentA; + a = parentB; + break; + } + child$2 = child$2.sibling; + } + if (!didFindChild) { + for (child$2 = parentB.child; child$2; ) { + if (child$2 === a) { + didFindChild = !0; + a = parentB; + b = parentA; + break; + } + if (child$2 === b) { + didFindChild = !0; + b = parentB; + a = parentA; + break; + } + child$2 = child$2.sibling; + } + if (!didFindChild) throw Error(formatProdErrorMessage(189)); + } + } + if (a.alternate !== b) throw Error(formatProdErrorMessage(190)); + } + if (3 !== a.tag) throw Error(formatProdErrorMessage(188)); + return a.stateNode.current === a ? fiber : alternate; + } + function findCurrentHostFiberImpl(node) { + var tag = node.tag; + if (5 === tag || 26 === tag || 27 === tag || 6 === tag) return node; + for (node = node.child; null !== node; ) { + tag = findCurrentHostFiberImpl(node); + if (null !== tag) return tag; + node = node.sibling; + } + return null; + } + function findCurrentHostFiberWithNoPortalsImpl(node) { + var tag = node.tag; + if (5 === tag || 26 === tag || 27 === tag || 6 === tag) return node; + for (node = node.child; null !== node; ) { + if ( + 4 !== node.tag && + ((tag = findCurrentHostFiberWithNoPortalsImpl(node)), null !== tag) + ) + return tag; + node = node.sibling; + } + return null; + } + function createCursor(defaultValue) { + return { current: defaultValue }; + } + function pop(cursor) { + 0 > index$jscomp$0 || + ((cursor.current = valueStack[index$jscomp$0]), + (valueStack[index$jscomp$0] = null), + index$jscomp$0--); + } + function push(cursor, value) { + index$jscomp$0++; + valueStack[index$jscomp$0] = cursor.current; + cursor.current = value; + } + function clz32Fallback(x) { + x >>>= 0; + return 0 === x ? 32 : (31 - ((log$1(x) / LN2) | 0)) | 0; + } + function getHighestPriorityLanes(lanes) { + var pendingSyncLanes = lanes & 42; + if (0 !== pendingSyncLanes) return pendingSyncLanes; + switch (lanes & -lanes) { + case 1: + return 1; + case 2: + return 2; + case 4: + return 4; + case 8: + return 8; + case 16: + return 16; + case 32: + return 32; + case 64: + return 64; + case 128: + case 256: + case 512: + case 1024: + case 2048: + case 4096: + case 8192: + case 16384: + case 32768: + case 65536: + case 131072: + case 262144: + case 524288: + case 1048576: + case 2097152: + return lanes & 4194176; + case 4194304: + case 8388608: + case 16777216: + case 33554432: + return lanes & 62914560; + case 67108864: + return 67108864; + case 134217728: + return 134217728; + case 268435456: + return 268435456; + case 536870912: + return 536870912; + case 1073741824: + return 0; + default: + return lanes; + } + } + function getNextLanes(root, wipLanes) { + var pendingLanes = root.pendingLanes; + if (0 === pendingLanes) return 0; + var nextLanes = 0, + suspendedLanes = root.suspendedLanes, + pingedLanes = root.pingedLanes, + warmLanes = root.warmLanes; + root = 0 !== root.finishedLanes; + var nonIdlePendingLanes = pendingLanes & 134217727; + 0 !== nonIdlePendingLanes + ? ((pendingLanes = nonIdlePendingLanes & ~suspendedLanes), + 0 !== pendingLanes + ? (nextLanes = getHighestPriorityLanes(pendingLanes)) + : ((pingedLanes &= nonIdlePendingLanes), + 0 !== pingedLanes + ? (nextLanes = getHighestPriorityLanes(pingedLanes)) + : root || + ((warmLanes = nonIdlePendingLanes & ~warmLanes), + 0 !== warmLanes && + (nextLanes = getHighestPriorityLanes(warmLanes))))) + : ((nonIdlePendingLanes = pendingLanes & ~suspendedLanes), + 0 !== nonIdlePendingLanes + ? (nextLanes = getHighestPriorityLanes(nonIdlePendingLanes)) + : 0 !== pingedLanes + ? (nextLanes = getHighestPriorityLanes(pingedLanes)) + : root || + ((warmLanes = pendingLanes & ~warmLanes), + 0 !== warmLanes && + (nextLanes = getHighestPriorityLanes(warmLanes)))); + return 0 === nextLanes + ? 0 + : 0 !== wipLanes && + wipLanes !== nextLanes && + 0 === (wipLanes & suspendedLanes) && + ((suspendedLanes = nextLanes & -nextLanes), + (warmLanes = wipLanes & -wipLanes), + suspendedLanes >= warmLanes || + (32 === suspendedLanes && 0 !== (warmLanes & 4194176))) + ? wipLanes + : nextLanes; + } + function checkIfRootIsPrerendering(root, renderLanes) { + return ( + 0 === + (root.pendingLanes & + ~(root.suspendedLanes & ~root.pingedLanes) & + renderLanes) + ); + } + function computeExpirationTime(lane, currentTime) { + switch (lane) { + case 1: + case 2: + case 4: + case 8: + return currentTime + 250; + case 16: + case 32: + case 64: + case 128: + case 256: + case 512: + case 1024: + case 2048: + case 4096: + case 8192: + case 16384: + case 32768: + case 65536: + case 131072: + case 262144: + case 524288: + case 1048576: + case 2097152: + return currentTime + 5e3; + case 4194304: + case 8388608: + case 16777216: + case 33554432: + return -1; + case 67108864: + case 134217728: + case 268435456: + case 536870912: + case 1073741824: + return -1; + default: + return -1; + } + } + function claimNextTransitionLane() { + var lane = nextTransitionLane; + nextTransitionLane <<= 1; + 0 === (nextTransitionLane & 4194176) && (nextTransitionLane = 128); + return lane; + } + function claimNextRetryLane() { + var lane = nextRetryLane; + nextRetryLane <<= 1; + 0 === (nextRetryLane & 62914560) && (nextRetryLane = 4194304); + return lane; + } + function createLaneMap(initial) { + for (var laneMap = [], i = 0; 31 > i; i++) laneMap.push(initial); + return laneMap; + } + function markRootUpdated$1(root, updateLane) { + root.pendingLanes |= updateLane; + 268435456 !== updateLane && + ((root.suspendedLanes = 0), (root.pingedLanes = 0), (root.warmLanes = 0)); + } + function markRootFinished( + root, + finishedLanes, + remainingLanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes + ) { + var previouslyPendingLanes = root.pendingLanes; + root.pendingLanes = remainingLanes; + root.suspendedLanes = 0; + root.pingedLanes = 0; + root.warmLanes = 0; + root.expiredLanes &= remainingLanes; + root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + root.shellSuspendCounter = 0; + var entanglements = root.entanglements, + expirationTimes = root.expirationTimes, + hiddenUpdates = root.hiddenUpdates; + for ( + remainingLanes = previouslyPendingLanes & ~remainingLanes; + 0 < remainingLanes; + + ) { + var index$7 = 31 - clz32(remainingLanes), + lane = 1 << index$7; + entanglements[index$7] = 0; + expirationTimes[index$7] = -1; + var hiddenUpdatesForLane = hiddenUpdates[index$7]; + if (null !== hiddenUpdatesForLane) + for ( + hiddenUpdates[index$7] = null, index$7 = 0; + index$7 < hiddenUpdatesForLane.length; + index$7++ + ) { + var update = hiddenUpdatesForLane[index$7]; + null !== update && (update.lane &= -536870913); + } + remainingLanes &= ~lane; + } + 0 !== spawnedLane && markSpawnedDeferredLane(root, spawnedLane, 0); + 0 !== suspendedRetryLanes && + 0 === updatedLanes && + 0 !== root.tag && + (root.suspendedLanes |= + suspendedRetryLanes & ~(previouslyPendingLanes & ~finishedLanes)); + } + function markSpawnedDeferredLane(root, spawnedLane, entangledLanes) { + root.pendingLanes |= spawnedLane; + root.suspendedLanes &= ~spawnedLane; + var spawnedLaneIndex = 31 - clz32(spawnedLane); + root.entangledLanes |= spawnedLane; + root.entanglements[spawnedLaneIndex] = + root.entanglements[spawnedLaneIndex] | + 1073741824 | + (entangledLanes & 4194218); + } + function markRootEntangled(root, entangledLanes) { + var rootEntangledLanes = (root.entangledLanes |= entangledLanes); + for (root = root.entanglements; rootEntangledLanes; ) { + var index$8 = 31 - clz32(rootEntangledLanes), + lane = 1 << index$8; + (lane & entangledLanes) | (root[index$8] & entangledLanes) && + (root[index$8] |= entangledLanes); + rootEntangledLanes &= ~lane; + } + } + function lanesToEventPriority(lanes) { + lanes &= -lanes; + return 2 < lanes + ? 8 < lanes + ? 0 !== (lanes & 134217727) + ? 32 + : 268435456 + : 8 + : 2; + } + function onCommitRoot(root) { + if (injectedHook && "function" === typeof injectedHook.onCommitFiberRoot) + try { + injectedHook.onCommitFiberRoot( + rendererID, + root, + void 0, + 128 === (root.current.flags & 128) + ); + } catch (err) {} + } + function setIsStrictModeForDevtools(newIsStrictMode) { + "function" === typeof log && unstable_setDisableYieldValue(newIsStrictMode); + if (injectedHook && "function" === typeof injectedHook.setStrictMode) + try { + injectedHook.setStrictMode(rendererID, newIsStrictMode); + } catch (err) {} + } + function is(x, y) { + return (x === y && (0 !== x || 1 / x === 1 / y)) || (x !== x && y !== y); + } + function createCapturedValueAtFiber(value, source) { + if ("object" === typeof value && null !== value) { + var existing = CapturedStacks.get(value); + if (void 0 !== existing) return existing; + source = { + value: value, + source: source, + stack: getStackByFiberInDevAndProd(source) + }; + CapturedStacks.set(value, source); + return source; + } + return { + value: value, + source: source, + stack: getStackByFiberInDevAndProd(source) + }; + } + function pushTreeFork(workInProgress, totalChildren) { + forkStack[forkStackIndex++] = treeForkCount; + forkStack[forkStackIndex++] = treeForkProvider; + treeForkProvider = workInProgress; + treeForkCount = totalChildren; + } + function pushTreeId(workInProgress, totalChildren, index) { + idStack[idStackIndex++] = treeContextId; + idStack[idStackIndex++] = treeContextOverflow; + idStack[idStackIndex++] = treeContextProvider; + treeContextProvider = workInProgress; + var baseIdWithLeadingBit = treeContextId; + workInProgress = treeContextOverflow; + var baseLength = 32 - clz32(baseIdWithLeadingBit) - 1; + baseIdWithLeadingBit &= ~(1 << baseLength); + index += 1; + var length = 32 - clz32(totalChildren) + baseLength; + if (30 < length) { + var numberOfOverflowBits = baseLength - (baseLength % 5); + length = ( + baseIdWithLeadingBit & + ((1 << numberOfOverflowBits) - 1) + ).toString(32); + baseIdWithLeadingBit >>= numberOfOverflowBits; + baseLength -= numberOfOverflowBits; + treeContextId = + (1 << (32 - clz32(totalChildren) + baseLength)) | + (index << baseLength) | + baseIdWithLeadingBit; + treeContextOverflow = length + workInProgress; + } else + (treeContextId = + (1 << length) | (index << baseLength) | baseIdWithLeadingBit), + (treeContextOverflow = workInProgress); + } + function pushMaterializedTreeId(workInProgress) { + null !== workInProgress.return && + (pushTreeFork(workInProgress, 1), pushTreeId(workInProgress, 1, 0)); + } + function popTreeContext(workInProgress) { + for (; workInProgress === treeForkProvider; ) + (treeForkProvider = forkStack[--forkStackIndex]), + (forkStack[forkStackIndex] = null), + (treeForkCount = forkStack[--forkStackIndex]), + (forkStack[forkStackIndex] = null); + for (; workInProgress === treeContextProvider; ) + (treeContextProvider = idStack[--idStackIndex]), + (idStack[idStackIndex] = null), + (treeContextOverflow = idStack[--idStackIndex]), + (idStack[idStackIndex] = null), + (treeContextId = idStack[--idStackIndex]), + (idStack[idStackIndex] = null); + } + function pushHostContainer(fiber, nextRootInstance) { + push(rootInstanceStackCursor, nextRootInstance); + push(contextFiberStackCursor, fiber); + push(contextStackCursor, null); + fiber = getRootHostContext(nextRootInstance); + pop(contextStackCursor); + push(contextStackCursor, fiber); + } + function popHostContainer() { + pop(contextStackCursor); + pop(contextFiberStackCursor); + pop(rootInstanceStackCursor); + } + function pushHostContext(fiber) { + null !== fiber.memoizedState && push(hostTransitionProviderCursor, fiber); + var context = contextStackCursor.current, + nextContext = getChildHostContext(context, fiber.type); + context !== nextContext && + (push(contextFiberStackCursor, fiber), + push(contextStackCursor, nextContext)); + } + function popHostContext(fiber) { + contextFiberStackCursor.current === fiber && + (pop(contextStackCursor), pop(contextFiberStackCursor)); + hostTransitionProviderCursor.current === fiber && + (pop(hostTransitionProviderCursor), + isPrimaryRenderer + ? (HostTransitionContext._currentValue = NotPendingTransition) + : (HostTransitionContext._currentValue2 = NotPendingTransition)); + } + function throwOnHydrationMismatch(fiber) { + var error = Error(formatProdErrorMessage(418, "")); + queueHydrationError(createCapturedValueAtFiber(error, fiber)); + throw HydrationMismatchException; + } + function prepareToHydrateHostInstance(fiber, hostContext) { + if (!supportsHydration) throw Error(formatProdErrorMessage(175)); + hydrateInstance( + fiber.stateNode, + fiber.type, + fiber.memoizedProps, + hostContext, + fiber + ) || throwOnHydrationMismatch(fiber); + } + function popToNextHostParent(fiber) { + for (hydrationParentFiber = fiber.return; hydrationParentFiber; ) + switch (hydrationParentFiber.tag) { + case 3: + case 27: + rootOrSingletonContext = !0; + return; + case 5: + case 13: + rootOrSingletonContext = !1; + return; + default: + hydrationParentFiber = hydrationParentFiber.return; + } + } + function popHydrationState(fiber) { + if (!supportsHydration || fiber !== hydrationParentFiber) return !1; + if (!isHydrating) return popToNextHostParent(fiber), (isHydrating = !0), !1; + var shouldClear = !1; + supportsSingletons + ? 3 !== fiber.tag && + 27 !== fiber.tag && + (5 !== fiber.tag || + (shouldDeleteUnhydratedTailInstances(fiber.type) && + !shouldSetTextContent(fiber.type, fiber.memoizedProps))) && + (shouldClear = !0) + : 3 !== fiber.tag && + (5 !== fiber.tag || + (shouldDeleteUnhydratedTailInstances(fiber.type) && + !shouldSetTextContent(fiber.type, fiber.memoizedProps))) && + (shouldClear = !0); + shouldClear && nextHydratableInstance && throwOnHydrationMismatch(fiber); + popToNextHostParent(fiber); + if (13 === fiber.tag) { + if (!supportsHydration) throw Error(formatProdErrorMessage(316)); + fiber = fiber.memoizedState; + fiber = null !== fiber ? fiber.dehydrated : null; + if (!fiber) throw Error(formatProdErrorMessage(317)); + nextHydratableInstance = + getNextHydratableInstanceAfterSuspenseInstance(fiber); + } else + nextHydratableInstance = hydrationParentFiber + ? getNextHydratableSibling(fiber.stateNode) + : null; + return !0; + } + function resetHydrationState() { + supportsHydration && + ((nextHydratableInstance = hydrationParentFiber = null), + (isHydrating = !1)); + } + function queueHydrationError(error) { + null === hydrationErrors + ? (hydrationErrors = [error]) + : hydrationErrors.push(error); + } + function finishQueueingConcurrentUpdates() { + for ( + var endIndex = concurrentQueuesIndex, + i = (concurrentlyUpdatedLanes = concurrentQueuesIndex = 0); + i < endIndex; + + ) { + var fiber = concurrentQueues[i]; + concurrentQueues[i++] = null; + var queue = concurrentQueues[i]; + concurrentQueues[i++] = null; + var update = concurrentQueues[i]; + concurrentQueues[i++] = null; + var lane = concurrentQueues[i]; + concurrentQueues[i++] = null; + if (null !== queue && null !== update) { + var pending = queue.pending; + null === pending + ? (update.next = update) + : ((update.next = pending.next), (pending.next = update)); + queue.pending = update; + } + 0 !== lane && markUpdateLaneFromFiberToRoot(fiber, update, lane); + } + } + function enqueueUpdate$1(fiber, queue, update, lane) { + concurrentQueues[concurrentQueuesIndex++] = fiber; + concurrentQueues[concurrentQueuesIndex++] = queue; + concurrentQueues[concurrentQueuesIndex++] = update; + concurrentQueues[concurrentQueuesIndex++] = lane; + concurrentlyUpdatedLanes |= lane; + fiber.lanes |= lane; + fiber = fiber.alternate; + null !== fiber && (fiber.lanes |= lane); + } + function enqueueConcurrentHookUpdate(fiber, queue, update, lane) { + enqueueUpdate$1(fiber, queue, update, lane); + return getRootForUpdatedFiber(fiber); + } + function enqueueConcurrentRenderForLane(fiber, lane) { + enqueueUpdate$1(fiber, null, null, lane); + return getRootForUpdatedFiber(fiber); + } + function markUpdateLaneFromFiberToRoot(sourceFiber, update, lane) { + sourceFiber.lanes |= lane; + var alternate = sourceFiber.alternate; + null !== alternate && (alternate.lanes |= lane); + for (var isHidden = !1, parent = sourceFiber.return; null !== parent; ) + (parent.childLanes |= lane), + (alternate = parent.alternate), + null !== alternate && (alternate.childLanes |= lane), + 22 === parent.tag && + ((sourceFiber = parent.stateNode), + null === sourceFiber || + sourceFiber._visibility & 1 || + (isHidden = !0)), + (sourceFiber = parent), + (parent = parent.return); + isHidden && + null !== update && + 3 === sourceFiber.tag && + ((parent = sourceFiber.stateNode), + (isHidden = 31 - clz32(lane)), + (parent = parent.hiddenUpdates), + (sourceFiber = parent[isHidden]), + null === sourceFiber + ? (parent[isHidden] = [update]) + : sourceFiber.push(update), + (update.lane = lane | 536870912)); + } + function getRootForUpdatedFiber(sourceFiber) { + if (50 < nestedUpdateCount) + throw ( + ((nestedUpdateCount = 0), + (rootWithNestedUpdates = null), + Error(formatProdErrorMessage(185))) + ); + for (var parent = sourceFiber.return; null !== parent; ) + (sourceFiber = parent), (parent = sourceFiber.return); + return 3 === sourceFiber.tag ? sourceFiber.stateNode : null; + } + function ensureRootIsScheduled(root) { + root !== lastScheduledRoot && + null === root.next && + (null === lastScheduledRoot + ? (firstScheduledRoot = lastScheduledRoot = root) + : (lastScheduledRoot = lastScheduledRoot.next = root)); + mightHavePendingSyncWork = !0; + didScheduleMicrotask || + ((didScheduleMicrotask = !0), + scheduleImmediateTask(processRootScheduleInMicrotask)); + } + function flushSyncWorkAcrossRoots_impl(syncTransitionLanes, onlyLegacy) { + if (!isFlushingWork && mightHavePendingSyncWork) { + isFlushingWork = !0; + do { + var didPerformSomeWork = !1; + for (var root = firstScheduledRoot; null !== root; ) { + if (!onlyLegacy) + if (0 !== syncTransitionLanes) { + var pendingLanes = root.pendingLanes; + if (0 === pendingLanes) var JSCompiler_inline_result = 0; + else { + var suspendedLanes = root.suspendedLanes, + pingedLanes = root.pingedLanes; + JSCompiler_inline_result = + (1 << (31 - clz32(42 | syncTransitionLanes) + 1)) - 1; + JSCompiler_inline_result &= + pendingLanes & ~(suspendedLanes & ~pingedLanes); + JSCompiler_inline_result = + JSCompiler_inline_result & 201326677 + ? (JSCompiler_inline_result & 201326677) | 1 + : JSCompiler_inline_result + ? JSCompiler_inline_result | 2 + : 0; + } + 0 !== JSCompiler_inline_result && + ((didPerformSomeWork = !0), + performSyncWorkOnRoot(root, JSCompiler_inline_result)); + } else + (JSCompiler_inline_result = workInProgressRootRenderLanes), + (JSCompiler_inline_result = getNextLanes( + root, + root === workInProgressRoot ? JSCompiler_inline_result : 0 + )), + 0 === (JSCompiler_inline_result & 3) || + checkIfRootIsPrerendering(root, JSCompiler_inline_result) || + ((didPerformSomeWork = !0), + performSyncWorkOnRoot(root, JSCompiler_inline_result)); + root = root.next; + } + } while (didPerformSomeWork); + isFlushingWork = !1; + } + } + function processRootScheduleInMicrotask() { + mightHavePendingSyncWork = didScheduleMicrotask = !1; + var syncTransitionLanes = 0; + 0 !== currentEventTransitionLane && + (shouldAttemptEagerTransition() && + (syncTransitionLanes = currentEventTransitionLane), + (currentEventTransitionLane = 0)); + for ( + var currentTime = now(), prev = null, root = firstScheduledRoot; + null !== root; + + ) { + var next = root.next, + nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime); + if (0 === nextLanes) + (root.next = null), + null === prev ? (firstScheduledRoot = next) : (prev.next = next), + null === next && (lastScheduledRoot = prev); + else if ( + ((prev = root), 0 !== syncTransitionLanes || 0 !== (nextLanes & 3)) + ) + mightHavePendingSyncWork = !0; + root = next; + } + flushSyncWorkAcrossRoots_impl(syncTransitionLanes, !1); + } + function scheduleTaskForRootDuringMicrotask(root, currentTime) { + for ( + var suspendedLanes = root.suspendedLanes, + pingedLanes = root.pingedLanes, + expirationTimes = root.expirationTimes, + lanes = root.pendingLanes & -62914561; + 0 < lanes; + + ) { + var index$5 = 31 - clz32(lanes), + lane = 1 << index$5, + expirationTime = expirationTimes[index$5]; + if (-1 === expirationTime) { + if (0 === (lane & suspendedLanes) || 0 !== (lane & pingedLanes)) + expirationTimes[index$5] = computeExpirationTime(lane, currentTime); + } else expirationTime <= currentTime && (root.expiredLanes |= lane); + lanes &= ~lane; + } + currentTime = workInProgressRoot; + suspendedLanes = workInProgressRootRenderLanes; + suspendedLanes = getNextLanes( + root, + root === currentTime ? suspendedLanes : 0 + ); + pingedLanes = root.callbackNode; + if ( + 0 === suspendedLanes || + (root === currentTime && 2 === workInProgressSuspendedReason) || + null !== root.cancelPendingCommit + ) + return ( + null !== pingedLanes && + null !== pingedLanes && + cancelCallback$1(pingedLanes), + (root.callbackNode = null), + (root.callbackPriority = 0) + ); + if ( + 0 === (suspendedLanes & 3) || + checkIfRootIsPrerendering(root, suspendedLanes) + ) { + currentTime = suspendedLanes & -suspendedLanes; + if (currentTime === root.callbackPriority) return currentTime; + null !== pingedLanes && cancelCallback$1(pingedLanes); + switch (lanesToEventPriority(suspendedLanes)) { + case 2: + case 8: + suspendedLanes = UserBlockingPriority; + break; + case 32: + suspendedLanes = NormalPriority$1; + break; + case 268435456: + suspendedLanes = IdlePriority; + break; + default: + suspendedLanes = NormalPriority$1; + } + pingedLanes = performWorkOnRootViaSchedulerTask.bind(null, root); + suspendedLanes = scheduleCallback$3(suspendedLanes, pingedLanes); + root.callbackPriority = currentTime; + root.callbackNode = suspendedLanes; + return currentTime; + } + null !== pingedLanes && + null !== pingedLanes && + cancelCallback$1(pingedLanes); + root.callbackPriority = 2; + root.callbackNode = null; + return 2; + } + function performWorkOnRootViaSchedulerTask(root, didTimeout) { + var originalCallbackNode = root.callbackNode; + if (flushPassiveEffects() && root.callbackNode !== originalCallbackNode) + return null; + var workInProgressRootRenderLanes$jscomp$0 = workInProgressRootRenderLanes; + workInProgressRootRenderLanes$jscomp$0 = getNextLanes( + root, + root === workInProgressRoot ? workInProgressRootRenderLanes$jscomp$0 : 0 + ); + if (0 === workInProgressRootRenderLanes$jscomp$0) return null; + performWorkOnRoot(root, workInProgressRootRenderLanes$jscomp$0, didTimeout); + scheduleTaskForRootDuringMicrotask(root, now()); + return null != root.callbackNode && + root.callbackNode === originalCallbackNode + ? performWorkOnRootViaSchedulerTask.bind(null, root) + : null; + } + function performSyncWorkOnRoot(root, lanes) { + if (flushPassiveEffects()) return null; + performWorkOnRoot(root, lanes, !0); + } + function scheduleImmediateTask(cb) { + supportsMicrotasks + ? scheduleMicrotask(function () { + 0 !== (executionContext & 6) + ? scheduleCallback$3(ImmediatePriority, cb) + : cb(); + }) + : scheduleCallback$3(ImmediatePriority, cb); + } + function requestTransitionLane() { + 0 === currentEventTransitionLane && + (currentEventTransitionLane = claimNextTransitionLane()); + return currentEventTransitionLane; + } + function entangleAsyncAction(transition, thenable) { + if (null === currentEntangledListeners) { + var entangledListeners = (currentEntangledListeners = []); + currentEntangledPendingCount = 0; + currentEntangledLane = requestTransitionLane(); + currentEntangledActionThenable = { + status: "pending", + value: void 0, + then: function (resolve) { + entangledListeners.push(resolve); + } + }; + } + currentEntangledPendingCount++; + thenable.then(pingEngtangledActionScope, pingEngtangledActionScope); + return thenable; + } + function pingEngtangledActionScope() { + if ( + 0 === --currentEntangledPendingCount && + null !== currentEntangledListeners + ) { + null !== currentEntangledActionThenable && + (currentEntangledActionThenable.status = "fulfilled"); + var listeners = currentEntangledListeners; + currentEntangledListeners = null; + currentEntangledLane = 0; + currentEntangledActionThenable = null; + for (var i = 0; i < listeners.length; i++) (0, listeners[i])(); + } + } + function chainThenableValue(thenable, result) { + var listeners = [], + thenableWithOverride = { + status: "pending", + value: null, + reason: null, + then: function (resolve) { + listeners.push(resolve); + } + }; + thenable.then( + function () { + thenableWithOverride.status = "fulfilled"; + thenableWithOverride.value = result; + for (var i = 0; i < listeners.length; i++) (0, listeners[i])(result); + }, + function (error) { + thenableWithOverride.status = "rejected"; + thenableWithOverride.reason = error; + for (error = 0; error < listeners.length; error++) + (0, listeners[error])(void 0); + } + ); + return thenableWithOverride; + } + function initializeUpdateQueue(fiber) { + fiber.updateQueue = { + baseState: fiber.memoizedState, + firstBaseUpdate: null, + lastBaseUpdate: null, + shared: { pending: null, lanes: 0, hiddenCallbacks: null }, + callbacks: null + }; + } + function cloneUpdateQueue(current, workInProgress) { + current = current.updateQueue; + workInProgress.updateQueue === current && + (workInProgress.updateQueue = { + baseState: current.baseState, + firstBaseUpdate: current.firstBaseUpdate, + lastBaseUpdate: current.lastBaseUpdate, + shared: current.shared, + callbacks: null + }); + } + function createUpdate(lane) { + return { lane: lane, tag: 0, payload: null, callback: null, next: null }; + } + function enqueueUpdate(fiber, update, lane) { + var updateQueue = fiber.updateQueue; + if (null === updateQueue) return null; + updateQueue = updateQueue.shared; + if (0 !== (executionContext & 2)) { + var pending = updateQueue.pending; + null === pending + ? (update.next = update) + : ((update.next = pending.next), (pending.next = update)); + updateQueue.pending = update; + update = getRootForUpdatedFiber(fiber); + markUpdateLaneFromFiberToRoot(fiber, null, lane); + return update; + } + enqueueUpdate$1(fiber, updateQueue, update, lane); + return getRootForUpdatedFiber(fiber); + } + function entangleTransitions(root, fiber, lane) { + fiber = fiber.updateQueue; + if (null !== fiber && ((fiber = fiber.shared), 0 !== (lane & 4194176))) { + var queueLanes = fiber.lanes; + queueLanes &= root.pendingLanes; + lane |= queueLanes; + fiber.lanes = lane; + markRootEntangled(root, lane); + } + } + function enqueueCapturedUpdate(workInProgress, capturedUpdate) { + var queue = workInProgress.updateQueue, + current = workInProgress.alternate; + if ( + null !== current && + ((current = current.updateQueue), queue === current) + ) { + var newFirst = null, + newLast = null; + queue = queue.firstBaseUpdate; + if (null !== queue) { + do { + var clone = { + lane: queue.lane, + tag: queue.tag, + payload: queue.payload, + callback: null, + next: null + }; + null === newLast + ? (newFirst = newLast = clone) + : (newLast = newLast.next = clone); + queue = queue.next; + } while (null !== queue); + null === newLast + ? (newFirst = newLast = capturedUpdate) + : (newLast = newLast.next = capturedUpdate); + } else newFirst = newLast = capturedUpdate; + queue = { + baseState: current.baseState, + firstBaseUpdate: newFirst, + lastBaseUpdate: newLast, + shared: current.shared, + callbacks: current.callbacks + }; + workInProgress.updateQueue = queue; + return; + } + workInProgress = queue.lastBaseUpdate; + null === workInProgress + ? (queue.firstBaseUpdate = capturedUpdate) + : (workInProgress.next = capturedUpdate); + queue.lastBaseUpdate = capturedUpdate; + } + function suspendIfUpdateReadFromEntangledAsyncAction() { + if (didReadFromEntangledAsyncAction) { + var entangledActionThenable = currentEntangledActionThenable; + if (null !== entangledActionThenable) throw entangledActionThenable; + } + } + function processUpdateQueue( + workInProgress$jscomp$0, + props, + instance$jscomp$0, + renderLanes + ) { + didReadFromEntangledAsyncAction = !1; + var queue = workInProgress$jscomp$0.updateQueue; + hasForceUpdate = !1; + var firstBaseUpdate = queue.firstBaseUpdate, + lastBaseUpdate = queue.lastBaseUpdate, + pendingQueue = queue.shared.pending; + if (null !== pendingQueue) { + queue.shared.pending = null; + var lastPendingUpdate = pendingQueue, + firstPendingUpdate = lastPendingUpdate.next; + lastPendingUpdate.next = null; + null === lastBaseUpdate + ? (firstBaseUpdate = firstPendingUpdate) + : (lastBaseUpdate.next = firstPendingUpdate); + lastBaseUpdate = lastPendingUpdate; + var current = workInProgress$jscomp$0.alternate; + null !== current && + ((current = current.updateQueue), + (pendingQueue = current.lastBaseUpdate), + pendingQueue !== lastBaseUpdate && + (null === pendingQueue + ? (current.firstBaseUpdate = firstPendingUpdate) + : (pendingQueue.next = firstPendingUpdate), + (current.lastBaseUpdate = lastPendingUpdate))); + } + if (null !== firstBaseUpdate) { + var newState = queue.baseState; + lastBaseUpdate = 0; + current = firstPendingUpdate = lastPendingUpdate = null; + pendingQueue = firstBaseUpdate; + do { + var updateLane = pendingQueue.lane & -536870913, + isHiddenUpdate = updateLane !== pendingQueue.lane; + if ( + isHiddenUpdate + ? (workInProgressRootRenderLanes & updateLane) === updateLane + : (renderLanes & updateLane) === updateLane + ) { + 0 !== updateLane && + updateLane === currentEntangledLane && + (didReadFromEntangledAsyncAction = !0); + null !== current && + (current = current.next = + { + lane: 0, + tag: pendingQueue.tag, + payload: pendingQueue.payload, + callback: null, + next: null + }); + a: { + var workInProgress = workInProgress$jscomp$0, + update = pendingQueue; + updateLane = props; + var instance = instance$jscomp$0; + switch (update.tag) { + case 1: + workInProgress = update.payload; + if ("function" === typeof workInProgress) { + newState = workInProgress.call( + instance, + newState, + updateLane + ); + break a; + } + newState = workInProgress; + break a; + case 3: + workInProgress.flags = (workInProgress.flags & -65537) | 128; + case 0: + workInProgress = update.payload; + updateLane = + "function" === typeof workInProgress + ? workInProgress.call(instance, newState, updateLane) + : workInProgress; + if (null === updateLane || void 0 === updateLane) break a; + newState = assign({}, newState, updateLane); + break a; + case 2: + hasForceUpdate = !0; + } + } + updateLane = pendingQueue.callback; + null !== updateLane && + ((workInProgress$jscomp$0.flags |= 64), + isHiddenUpdate && (workInProgress$jscomp$0.flags |= 8192), + (isHiddenUpdate = queue.callbacks), + null === isHiddenUpdate + ? (queue.callbacks = [updateLane]) + : isHiddenUpdate.push(updateLane)); + } else + (isHiddenUpdate = { + lane: updateLane, + tag: pendingQueue.tag, + payload: pendingQueue.payload, + callback: pendingQueue.callback, + next: null + }), + null === current + ? ((firstPendingUpdate = current = isHiddenUpdate), + (lastPendingUpdate = newState)) + : (current = current.next = isHiddenUpdate), + (lastBaseUpdate |= updateLane); + pendingQueue = pendingQueue.next; + if (null === pendingQueue) + if (((pendingQueue = queue.shared.pending), null === pendingQueue)) + break; + else + (isHiddenUpdate = pendingQueue), + (pendingQueue = isHiddenUpdate.next), + (isHiddenUpdate.next = null), + (queue.lastBaseUpdate = isHiddenUpdate), + (queue.shared.pending = null); + } while (1); + null === current && (lastPendingUpdate = newState); + queue.baseState = lastPendingUpdate; + queue.firstBaseUpdate = firstPendingUpdate; + queue.lastBaseUpdate = current; + null === firstBaseUpdate && (queue.shared.lanes = 0); + workInProgressRootSkippedLanes |= lastBaseUpdate; + workInProgress$jscomp$0.lanes = lastBaseUpdate; + workInProgress$jscomp$0.memoizedState = newState; + } + } + function callCallback(callback, context) { + if ("function" !== typeof callback) + throw Error(formatProdErrorMessage(191, callback)); + callback.call(context); + } + function commitCallbacks(updateQueue, context) { + var callbacks = updateQueue.callbacks; + if (null !== callbacks) + for ( + updateQueue.callbacks = null, updateQueue = 0; + updateQueue < callbacks.length; + updateQueue++ + ) + callCallback(callbacks[updateQueue], context); + } + function shallowEqual(objA, objB) { + if (objectIs(objA, objB)) return !0; + if ( + "object" !== typeof objA || + null === objA || + "object" !== typeof objB || + null === objB + ) + return !1; + var keysA = Object.keys(objA), + keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return !1; + for (keysB = 0; keysB < keysA.length; keysB++) { + var currentKey = keysA[keysB]; + if ( + !hasOwnProperty.call(objB, currentKey) || + !objectIs(objA[currentKey], objB[currentKey]) + ) + return !1; + } + return !0; + } + function isThenableResolved(thenable) { + thenable = thenable.status; + return "fulfilled" === thenable || "rejected" === thenable; + } + function noop$1() {} + function trackUsedThenable(thenableState, thenable, index) { + index = thenableState[index]; + void 0 === index + ? thenableState.push(thenable) + : index !== thenable && + (thenable.then(noop$1, noop$1), (thenable = index)); + switch (thenable.status) { + case "fulfilled": + return thenable.value; + case "rejected": + thenableState = thenable.reason; + if (thenableState === SuspenseException) + throw Error(formatProdErrorMessage(483)); + throw thenableState; + default: + if ("string" === typeof thenable.status) thenable.then(noop$1, noop$1); + else { + thenableState = workInProgressRoot; + if (null !== thenableState && 100 < thenableState.shellSuspendCounter) + throw Error(formatProdErrorMessage(482)); + thenableState = thenable; + thenableState.status = "pending"; + thenableState.then( + function (fulfilledValue) { + if ("pending" === thenable.status) { + var fulfilledThenable = thenable; + fulfilledThenable.status = "fulfilled"; + fulfilledThenable.value = fulfilledValue; + } + }, + function (error) { + if ("pending" === thenable.status) { + var rejectedThenable = thenable; + rejectedThenable.status = "rejected"; + rejectedThenable.reason = error; + } + } + ); + } + switch (thenable.status) { + case "fulfilled": + return thenable.value; + case "rejected": + thenableState = thenable.reason; + if (thenableState === SuspenseException) + throw Error(formatProdErrorMessage(483)); + throw thenableState; + } + suspendedThenable = thenable; + throw SuspenseException; + } + } + function getSuspendedThenable() { + if (null === suspendedThenable) throw Error(formatProdErrorMessage(459)); + var thenable = suspendedThenable; + suspendedThenable = null; + return thenable; + } + function unwrapThenable(thenable) { + var index = thenableIndexCounter$1; + thenableIndexCounter$1 += 1; + null === thenableState$1 && (thenableState$1 = []); + return trackUsedThenable(thenableState$1, thenable, index); + } + function coerceRef(workInProgress, element) { + element = element.props.ref; + workInProgress.ref = void 0 !== element ? element : null; + } + function throwOnInvalidObjectType(returnFiber, newChild) { + if (newChild.$$typeof === REACT_LEGACY_ELEMENT_TYPE) + throw Error(formatProdErrorMessage(525)); + returnFiber = Object.prototype.toString.call(newChild); + throw Error( + formatProdErrorMessage( + 31, + "[object Object]" === returnFiber + ? "object with keys {" + Object.keys(newChild).join(", ") + "}" + : returnFiber + ) + ); + } + function resolveLazy(lazyType) { + var init = lazyType._init; + return init(lazyType._payload); + } + function createChildReconciler(shouldTrackSideEffects) { + function deleteChild(returnFiber, childToDelete) { + if (shouldTrackSideEffects) { + var deletions = returnFiber.deletions; + null === deletions + ? ((returnFiber.deletions = [childToDelete]), + (returnFiber.flags |= 16)) + : deletions.push(childToDelete); + } + } + function deleteRemainingChildren(returnFiber, currentFirstChild) { + if (!shouldTrackSideEffects) return null; + for (; null !== currentFirstChild; ) + deleteChild(returnFiber, currentFirstChild), + (currentFirstChild = currentFirstChild.sibling); + return null; + } + function mapRemainingChildren(currentFirstChild) { + for (var existingChildren = new Map(); null !== currentFirstChild; ) + null !== currentFirstChild.key + ? existingChildren.set(currentFirstChild.key, currentFirstChild) + : existingChildren.set(currentFirstChild.index, currentFirstChild), + (currentFirstChild = currentFirstChild.sibling); + return existingChildren; + } + function useFiber(fiber, pendingProps) { + fiber = createWorkInProgress(fiber, pendingProps); + fiber.index = 0; + fiber.sibling = null; + return fiber; + } + function placeChild(newFiber, lastPlacedIndex, newIndex) { + newFiber.index = newIndex; + if (!shouldTrackSideEffects) + return (newFiber.flags |= 1048576), lastPlacedIndex; + newIndex = newFiber.alternate; + if (null !== newIndex) + return ( + (newIndex = newIndex.index), + newIndex < lastPlacedIndex + ? ((newFiber.flags |= 33554434), lastPlacedIndex) + : newIndex + ); + newFiber.flags |= 33554434; + return lastPlacedIndex; + } + function placeSingleChild(newFiber) { + shouldTrackSideEffects && + null === newFiber.alternate && + (newFiber.flags |= 33554434); + return newFiber; + } + function updateTextNode(returnFiber, current, textContent, lanes) { + if (null === current || 6 !== current.tag) + return ( + (current = createFiberFromText(textContent, returnFiber.mode, lanes)), + (current.return = returnFiber), + current + ); + current = useFiber(current, textContent); + current.return = returnFiber; + return current; + } + function updateElement(returnFiber, current, element, lanes) { + var elementType = element.type; + if (elementType === REACT_FRAGMENT_TYPE) + return updateFragment( + returnFiber, + current, + element.props.children, + lanes, + element.key + ); + if ( + null !== current && + (current.elementType === elementType || + ("object" === typeof elementType && + null !== elementType && + elementType.$$typeof === REACT_LAZY_TYPE && + resolveLazy(elementType) === current.type)) + ) + return ( + (current = useFiber(current, element.props)), + coerceRef(current, element), + (current.return = returnFiber), + current + ); + current = createFiberFromTypeAndProps( + element.type, + element.key, + element.props, + null, + returnFiber.mode, + lanes + ); + coerceRef(current, element); + current.return = returnFiber; + return current; + } + function updatePortal(returnFiber, current, portal, lanes) { + if ( + null === current || + 4 !== current.tag || + current.stateNode.containerInfo !== portal.containerInfo || + current.stateNode.implementation !== portal.implementation + ) + return ( + (current = createFiberFromPortal(portal, returnFiber.mode, lanes)), + (current.return = returnFiber), + current + ); + current = useFiber(current, portal.children || []); + current.return = returnFiber; + return current; + } + function updateFragment(returnFiber, current, fragment, lanes, key) { + if (null === current || 7 !== current.tag) + return ( + (current = createFiberFromFragment( + fragment, + returnFiber.mode, + lanes, + key + )), + (current.return = returnFiber), + current + ); + current = useFiber(current, fragment); + current.return = returnFiber; + return current; + } + function createChild(returnFiber, newChild, lanes) { + if ( + ("string" === typeof newChild && "" !== newChild) || + "number" === typeof newChild || + "bigint" === typeof newChild + ) + return ( + (newChild = createFiberFromText( + "" + newChild, + returnFiber.mode, + lanes + )), + (newChild.return = returnFiber), + newChild + ); + if ("object" === typeof newChild && null !== newChild) { + switch (newChild.$$typeof) { + case REACT_ELEMENT_TYPE: + return ( + (lanes = createFiberFromTypeAndProps( + newChild.type, + newChild.key, + newChild.props, + null, + returnFiber.mode, + lanes + )), + coerceRef(lanes, newChild), + (lanes.return = returnFiber), + lanes + ); + case REACT_PORTAL_TYPE: + return ( + (newChild = createFiberFromPortal( + newChild, + returnFiber.mode, + lanes + )), + (newChild.return = returnFiber), + newChild + ); + case REACT_LAZY_TYPE: + var init = newChild._init; + newChild = init(newChild._payload); + return createChild(returnFiber, newChild, lanes); + } + if (isArrayImpl(newChild) || getIteratorFn(newChild)) + return ( + (newChild = createFiberFromFragment( + newChild, + returnFiber.mode, + lanes, + null + )), + (newChild.return = returnFiber), + newChild + ); + if ("function" === typeof newChild.then) + return createChild(returnFiber, unwrapThenable(newChild), lanes); + if (newChild.$$typeof === REACT_CONTEXT_TYPE) + return createChild( + returnFiber, + readContextDuringReconciliation(returnFiber, newChild), + lanes + ); + throwOnInvalidObjectType(returnFiber, newChild); + } + return null; + } + function updateSlot(returnFiber, oldFiber, newChild, lanes) { + var key = null !== oldFiber ? oldFiber.key : null; + if ( + ("string" === typeof newChild && "" !== newChild) || + "number" === typeof newChild || + "bigint" === typeof newChild + ) + return null !== key + ? null + : updateTextNode(returnFiber, oldFiber, "" + newChild, lanes); + if ("object" === typeof newChild && null !== newChild) { + switch (newChild.$$typeof) { + case REACT_ELEMENT_TYPE: + return newChild.key === key + ? updateElement(returnFiber, oldFiber, newChild, lanes) + : null; + case REACT_PORTAL_TYPE: + return newChild.key === key + ? updatePortal(returnFiber, oldFiber, newChild, lanes) + : null; + case REACT_LAZY_TYPE: + return ( + (key = newChild._init), + (newChild = key(newChild._payload)), + updateSlot(returnFiber, oldFiber, newChild, lanes) + ); + } + if (isArrayImpl(newChild) || getIteratorFn(newChild)) + return null !== key + ? null + : updateFragment(returnFiber, oldFiber, newChild, lanes, null); + if ("function" === typeof newChild.then) + return updateSlot( + returnFiber, + oldFiber, + unwrapThenable(newChild), + lanes + ); + if (newChild.$$typeof === REACT_CONTEXT_TYPE) + return updateSlot( + returnFiber, + oldFiber, + readContextDuringReconciliation(returnFiber, newChild), + lanes + ); + throwOnInvalidObjectType(returnFiber, newChild); + } + return null; + } + function updateFromMap( + existingChildren, + returnFiber, + newIdx, + newChild, + lanes + ) { + if ( + ("string" === typeof newChild && "" !== newChild) || + "number" === typeof newChild || + "bigint" === typeof newChild + ) + return ( + (existingChildren = existingChildren.get(newIdx) || null), + updateTextNode(returnFiber, existingChildren, "" + newChild, lanes) + ); + if ("object" === typeof newChild && null !== newChild) { + switch (newChild.$$typeof) { + case REACT_ELEMENT_TYPE: + return ( + (existingChildren = + existingChildren.get( + null === newChild.key ? newIdx : newChild.key + ) || null), + updateElement(returnFiber, existingChildren, newChild, lanes) + ); + case REACT_PORTAL_TYPE: + return ( + (existingChildren = + existingChildren.get( + null === newChild.key ? newIdx : newChild.key + ) || null), + updatePortal(returnFiber, existingChildren, newChild, lanes) + ); + case REACT_LAZY_TYPE: + var init = newChild._init; + newChild = init(newChild._payload); + return updateFromMap( + existingChildren, + returnFiber, + newIdx, + newChild, + lanes + ); + } + if (isArrayImpl(newChild) || getIteratorFn(newChild)) + return ( + (existingChildren = existingChildren.get(newIdx) || null), + updateFragment(returnFiber, existingChildren, newChild, lanes, null) + ); + if ("function" === typeof newChild.then) + return updateFromMap( + existingChildren, + returnFiber, + newIdx, + unwrapThenable(newChild), + lanes + ); + if (newChild.$$typeof === REACT_CONTEXT_TYPE) + return updateFromMap( + existingChildren, + returnFiber, + newIdx, + readContextDuringReconciliation(returnFiber, newChild), + lanes + ); + throwOnInvalidObjectType(returnFiber, newChild); + } + return null; + } + function reconcileChildrenArray( + returnFiber, + currentFirstChild, + newChildren, + lanes + ) { + for ( + var resultingFirstChild = null, + previousNewFiber = null, + oldFiber = currentFirstChild, + newIdx = (currentFirstChild = 0), + nextOldFiber = null; + null !== oldFiber && newIdx < newChildren.length; + newIdx++ + ) { + oldFiber.index > newIdx + ? ((nextOldFiber = oldFiber), (oldFiber = null)) + : (nextOldFiber = oldFiber.sibling); + var newFiber = updateSlot( + returnFiber, + oldFiber, + newChildren[newIdx], + lanes + ); + if (null === newFiber) { + null === oldFiber && (oldFiber = nextOldFiber); + break; + } + shouldTrackSideEffects && + oldFiber && + null === newFiber.alternate && + deleteChild(returnFiber, oldFiber); + currentFirstChild = placeChild(newFiber, currentFirstChild, newIdx); + null === previousNewFiber + ? (resultingFirstChild = newFiber) + : (previousNewFiber.sibling = newFiber); + previousNewFiber = newFiber; + oldFiber = nextOldFiber; + } + if (newIdx === newChildren.length) + return ( + deleteRemainingChildren(returnFiber, oldFiber), + isHydrating && pushTreeFork(returnFiber, newIdx), + resultingFirstChild + ); + if (null === oldFiber) { + for (; newIdx < newChildren.length; newIdx++) + (oldFiber = createChild(returnFiber, newChildren[newIdx], lanes)), + null !== oldFiber && + ((currentFirstChild = placeChild( + oldFiber, + currentFirstChild, + newIdx + )), + null === previousNewFiber + ? (resultingFirstChild = oldFiber) + : (previousNewFiber.sibling = oldFiber), + (previousNewFiber = oldFiber)); + isHydrating && pushTreeFork(returnFiber, newIdx); + return resultingFirstChild; + } + for ( + oldFiber = mapRemainingChildren(oldFiber); + newIdx < newChildren.length; + newIdx++ + ) + (nextOldFiber = updateFromMap( + oldFiber, + returnFiber, + newIdx, + newChildren[newIdx], + lanes + )), + null !== nextOldFiber && + (shouldTrackSideEffects && + null !== nextOldFiber.alternate && + oldFiber.delete( + null === nextOldFiber.key ? newIdx : nextOldFiber.key + ), + (currentFirstChild = placeChild( + nextOldFiber, + currentFirstChild, + newIdx + )), + null === previousNewFiber + ? (resultingFirstChild = nextOldFiber) + : (previousNewFiber.sibling = nextOldFiber), + (previousNewFiber = nextOldFiber)); + shouldTrackSideEffects && + oldFiber.forEach(function (child) { + return deleteChild(returnFiber, child); + }); + isHydrating && pushTreeFork(returnFiber, newIdx); + return resultingFirstChild; + } + function reconcileChildrenIterator( + returnFiber, + currentFirstChild, + newChildren, + lanes + ) { + if (null == newChildren) throw Error(formatProdErrorMessage(151)); + for ( + var resultingFirstChild = null, + previousNewFiber = null, + oldFiber = currentFirstChild, + newIdx = (currentFirstChild = 0), + nextOldFiber = null, + step = newChildren.next(); + null !== oldFiber && !step.done; + newIdx++, step = newChildren.next() + ) { + oldFiber.index > newIdx + ? ((nextOldFiber = oldFiber), (oldFiber = null)) + : (nextOldFiber = oldFiber.sibling); + var newFiber = updateSlot(returnFiber, oldFiber, step.value, lanes); + if (null === newFiber) { + null === oldFiber && (oldFiber = nextOldFiber); + break; + } + shouldTrackSideEffects && + oldFiber && + null === newFiber.alternate && + deleteChild(returnFiber, oldFiber); + currentFirstChild = placeChild(newFiber, currentFirstChild, newIdx); + null === previousNewFiber + ? (resultingFirstChild = newFiber) + : (previousNewFiber.sibling = newFiber); + previousNewFiber = newFiber; + oldFiber = nextOldFiber; + } + if (step.done) + return ( + deleteRemainingChildren(returnFiber, oldFiber), + isHydrating && pushTreeFork(returnFiber, newIdx), + resultingFirstChild + ); + if (null === oldFiber) { + for (; !step.done; newIdx++, step = newChildren.next()) + (step = createChild(returnFiber, step.value, lanes)), + null !== step && + ((currentFirstChild = placeChild( + step, + currentFirstChild, + newIdx + )), + null === previousNewFiber + ? (resultingFirstChild = step) + : (previousNewFiber.sibling = step), + (previousNewFiber = step)); + isHydrating && pushTreeFork(returnFiber, newIdx); + return resultingFirstChild; + } + for ( + oldFiber = mapRemainingChildren(oldFiber); + !step.done; + newIdx++, step = newChildren.next() + ) + (step = updateFromMap( + oldFiber, + returnFiber, + newIdx, + step.value, + lanes + )), + null !== step && + (shouldTrackSideEffects && + null !== step.alternate && + oldFiber.delete(null === step.key ? newIdx : step.key), + (currentFirstChild = placeChild(step, currentFirstChild, newIdx)), + null === previousNewFiber + ? (resultingFirstChild = step) + : (previousNewFiber.sibling = step), + (previousNewFiber = step)); + shouldTrackSideEffects && + oldFiber.forEach(function (child) { + return deleteChild(returnFiber, child); + }); + isHydrating && pushTreeFork(returnFiber, newIdx); + return resultingFirstChild; + } + function reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + newChild, + lanes + ) { + "object" === typeof newChild && + null !== newChild && + newChild.type === REACT_FRAGMENT_TYPE && + null === newChild.key && + (newChild = newChild.props.children); + if ("object" === typeof newChild && null !== newChild) { + switch (newChild.$$typeof) { + case REACT_ELEMENT_TYPE: + a: { + for (var key = newChild.key; null !== currentFirstChild; ) { + if (currentFirstChild.key === key) { + key = newChild.type; + if (key === REACT_FRAGMENT_TYPE) { + if (7 === currentFirstChild.tag) { + deleteRemainingChildren( + returnFiber, + currentFirstChild.sibling + ); + lanes = useFiber( + currentFirstChild, + newChild.props.children + ); + lanes.return = returnFiber; + returnFiber = lanes; + break a; + } + } else if ( + currentFirstChild.elementType === key || + ("object" === typeof key && + null !== key && + key.$$typeof === REACT_LAZY_TYPE && + resolveLazy(key) === currentFirstChild.type) + ) { + deleteRemainingChildren( + returnFiber, + currentFirstChild.sibling + ); + lanes = useFiber(currentFirstChild, newChild.props); + coerceRef(lanes, newChild); + lanes.return = returnFiber; + returnFiber = lanes; + break a; + } + deleteRemainingChildren(returnFiber, currentFirstChild); + break; + } else deleteChild(returnFiber, currentFirstChild); + currentFirstChild = currentFirstChild.sibling; + } + newChild.type === REACT_FRAGMENT_TYPE + ? ((lanes = createFiberFromFragment( + newChild.props.children, + returnFiber.mode, + lanes, + newChild.key + )), + (lanes.return = returnFiber), + (returnFiber = lanes)) + : ((lanes = createFiberFromTypeAndProps( + newChild.type, + newChild.key, + newChild.props, + null, + returnFiber.mode, + lanes + )), + coerceRef(lanes, newChild), + (lanes.return = returnFiber), + (returnFiber = lanes)); + } + return placeSingleChild(returnFiber); + case REACT_PORTAL_TYPE: + a: { + for (key = newChild.key; null !== currentFirstChild; ) { + if (currentFirstChild.key === key) + if ( + 4 === currentFirstChild.tag && + currentFirstChild.stateNode.containerInfo === + newChild.containerInfo && + currentFirstChild.stateNode.implementation === + newChild.implementation + ) { + deleteRemainingChildren( + returnFiber, + currentFirstChild.sibling + ); + lanes = useFiber( + currentFirstChild, + newChild.children || [] + ); + lanes.return = returnFiber; + returnFiber = lanes; + break a; + } else { + deleteRemainingChildren(returnFiber, currentFirstChild); + break; + } + else deleteChild(returnFiber, currentFirstChild); + currentFirstChild = currentFirstChild.sibling; + } + lanes = createFiberFromPortal(newChild, returnFiber.mode, lanes); + lanes.return = returnFiber; + returnFiber = lanes; + } + return placeSingleChild(returnFiber); + case REACT_LAZY_TYPE: + return ( + (key = newChild._init), + (newChild = key(newChild._payload)), + reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + newChild, + lanes + ) + ); + } + if (isArrayImpl(newChild)) + return reconcileChildrenArray( + returnFiber, + currentFirstChild, + newChild, + lanes + ); + if (getIteratorFn(newChild)) { + key = getIteratorFn(newChild); + if ("function" !== typeof key) + throw Error(formatProdErrorMessage(150)); + newChild = key.call(newChild); + return reconcileChildrenIterator( + returnFiber, + currentFirstChild, + newChild, + lanes + ); + } + if ("function" === typeof newChild.then) + return reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + unwrapThenable(newChild), + lanes + ); + if (newChild.$$typeof === REACT_CONTEXT_TYPE) + return reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + readContextDuringReconciliation(returnFiber, newChild), + lanes + ); + throwOnInvalidObjectType(returnFiber, newChild); + } + return ("string" === typeof newChild && "" !== newChild) || + "number" === typeof newChild || + "bigint" === typeof newChild + ? ((newChild = "" + newChild), + null !== currentFirstChild && 6 === currentFirstChild.tag + ? (deleteRemainingChildren(returnFiber, currentFirstChild.sibling), + (lanes = useFiber(currentFirstChild, newChild)), + (lanes.return = returnFiber), + (returnFiber = lanes)) + : (deleteRemainingChildren(returnFiber, currentFirstChild), + (lanes = createFiberFromText(newChild, returnFiber.mode, lanes)), + (lanes.return = returnFiber), + (returnFiber = lanes)), + placeSingleChild(returnFiber)) + : deleteRemainingChildren(returnFiber, currentFirstChild); + } + return function (returnFiber, currentFirstChild, newChild, lanes) { + try { + thenableIndexCounter$1 = 0; + var firstChildFiber = reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + newChild, + lanes + ); + thenableState$1 = null; + return firstChildFiber; + } catch (x) { + if (x === SuspenseException) throw x; + var fiber = createFiber(29, x, null, returnFiber.mode); + fiber.lanes = lanes; + fiber.return = returnFiber; + return fiber; + } finally { + } + }; + } + function pushHiddenContext(fiber, context) { + fiber = entangledRenderLanes; + push(prevEntangledRenderLanesCursor, fiber); + push(currentTreeHiddenStackCursor, context); + entangledRenderLanes = fiber | context.baseLanes; + } + function reuseHiddenContextOnStack() { + push(prevEntangledRenderLanesCursor, entangledRenderLanes); + push(currentTreeHiddenStackCursor, currentTreeHiddenStackCursor.current); + } + function popHiddenContext() { + entangledRenderLanes = prevEntangledRenderLanesCursor.current; + pop(currentTreeHiddenStackCursor); + pop(prevEntangledRenderLanesCursor); + } + function pushPrimaryTreeSuspenseHandler(handler) { + var current = handler.alternate; + push(suspenseStackCursor, suspenseStackCursor.current & 1); + push(suspenseHandlerStackCursor, handler); + null === shellBoundary && + (null === current || null !== currentTreeHiddenStackCursor.current + ? (shellBoundary = handler) + : null !== current.memoizedState && (shellBoundary = handler)); + } + function pushOffscreenSuspenseHandler(fiber) { + if (22 === fiber.tag) { + if ( + (push(suspenseStackCursor, suspenseStackCursor.current), + push(suspenseHandlerStackCursor, fiber), + null === shellBoundary) + ) { + var current = fiber.alternate; + null !== current && + null !== current.memoizedState && + (shellBoundary = fiber); + } + } else reuseSuspenseHandlerOnStack(fiber); + } + function reuseSuspenseHandlerOnStack() { + push(suspenseStackCursor, suspenseStackCursor.current); + push(suspenseHandlerStackCursor, suspenseHandlerStackCursor.current); + } + function popSuspenseHandler(fiber) { + pop(suspenseHandlerStackCursor); + shellBoundary === fiber && (shellBoundary = null); + pop(suspenseStackCursor); + } + function findFirstSuspended(row) { + for (var node = row; null !== node; ) { + if (13 === node.tag) { + var state = node.memoizedState; + if ( + null !== state && + ((state = state.dehydrated), + null === state || + isSuspenseInstancePending(state) || + isSuspenseInstanceFallback(state)) + ) + return node; + } else if (19 === node.tag && void 0 !== node.memoizedProps.revealOrder) { + if (0 !== (node.flags & 128)) return node; + } else if (null !== node.child) { + node.child.return = node; + node = node.child; + continue; + } + if (node === row) break; + for (; null === node.sibling; ) { + if (null === node.return || node.return === row) return null; + node = node.return; + } + node.sibling.return = node.return; + node = node.sibling; + } + return null; + } + function throwInvalidHookError() { + throw Error(formatProdErrorMessage(321)); + } + function areHookInputsEqual(nextDeps, prevDeps) { + if (null === prevDeps) return !1; + for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++) + if (!objectIs(nextDeps[i], prevDeps[i])) return !1; + return !0; + } + function renderWithHooks( + current, + workInProgress, + Component, + props, + secondArg, + nextRenderLanes + ) { + renderLanes = nextRenderLanes; + currentlyRenderingFiber$1 = workInProgress; + workInProgress.memoizedState = null; + workInProgress.updateQueue = null; + workInProgress.lanes = 0; + ReactSharedInternals.H = + null === current || null === current.memoizedState + ? HooksDispatcherOnMount + : HooksDispatcherOnUpdate; + shouldDoubleInvokeUserFnsInHooksDEV = !1; + nextRenderLanes = Component(props, secondArg); + shouldDoubleInvokeUserFnsInHooksDEV = !1; + didScheduleRenderPhaseUpdateDuringThisPass && + (nextRenderLanes = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg + )); + finishRenderingHooks(current); + return nextRenderLanes; + } + function finishRenderingHooks(current) { + ReactSharedInternals.H = ContextOnlyDispatcher; + var didRenderTooFewHooks = + null !== currentHook && null !== currentHook.next; + renderLanes = 0; + workInProgressHook = currentHook = currentlyRenderingFiber$1 = null; + didScheduleRenderPhaseUpdate = !1; + thenableIndexCounter = 0; + thenableState = null; + if (didRenderTooFewHooks) throw Error(formatProdErrorMessage(300)); + null === current || + didReceiveUpdate || + ((current = current.dependencies), + null !== current && + checkIfContextChanged(current) && + (didReceiveUpdate = !0)); + } + function renderWithHooksAgain(workInProgress, Component, props, secondArg) { + currentlyRenderingFiber$1 = workInProgress; + var numberOfReRenders = 0; + do { + didScheduleRenderPhaseUpdateDuringThisPass && (thenableState = null); + thenableIndexCounter = 0; + didScheduleRenderPhaseUpdateDuringThisPass = !1; + if (25 <= numberOfReRenders) throw Error(formatProdErrorMessage(301)); + numberOfReRenders += 1; + workInProgressHook = currentHook = null; + if (null != workInProgress.updateQueue) { + var children = workInProgress.updateQueue; + children.lastEffect = null; + children.events = null; + children.stores = null; + null != children.memoCache && (children.memoCache.index = 0); + } + ReactSharedInternals.H = HooksDispatcherOnRerender; + children = Component(props, secondArg); + } while (didScheduleRenderPhaseUpdateDuringThisPass); + return children; + } + function TransitionAwareHostComponent() { + var dispatcher = ReactSharedInternals.H, + maybeThenable = dispatcher.useState()[0]; + maybeThenable = + "function" === typeof maybeThenable.then + ? useThenable(maybeThenable) + : maybeThenable; + dispatcher = dispatcher.useState()[0]; + (null !== currentHook ? currentHook.memoizedState : null) !== dispatcher && + (currentlyRenderingFiber$1.flags |= 1024); + return maybeThenable; + } + function checkDidRenderIdHook() { + var didRenderIdHook = 0 !== localIdCounter; + localIdCounter = 0; + return didRenderIdHook; + } + function bailoutHooks(current, workInProgress, lanes) { + workInProgress.updateQueue = current.updateQueue; + workInProgress.flags &= -2053; + current.lanes &= ~lanes; + } + function resetHooksOnUnwind(workInProgress) { + if (didScheduleRenderPhaseUpdate) { + for ( + workInProgress = workInProgress.memoizedState; + null !== workInProgress; + + ) { + var queue = workInProgress.queue; + null !== queue && (queue.pending = null); + workInProgress = workInProgress.next; + } + didScheduleRenderPhaseUpdate = !1; + } + renderLanes = 0; + workInProgressHook = currentHook = currentlyRenderingFiber$1 = null; + didScheduleRenderPhaseUpdateDuringThisPass = !1; + thenableIndexCounter = localIdCounter = 0; + thenableState = null; + } + function mountWorkInProgressHook() { + var hook = { + memoizedState: null, + baseState: null, + baseQueue: null, + queue: null, + next: null + }; + null === workInProgressHook + ? (currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook) + : (workInProgressHook = workInProgressHook.next = hook); + return workInProgressHook; + } + function updateWorkInProgressHook() { + if (null === currentHook) { + var nextCurrentHook = currentlyRenderingFiber$1.alternate; + nextCurrentHook = + null !== nextCurrentHook ? nextCurrentHook.memoizedState : null; + } else nextCurrentHook = currentHook.next; + var nextWorkInProgressHook = + null === workInProgressHook + ? currentlyRenderingFiber$1.memoizedState + : workInProgressHook.next; + if (null !== nextWorkInProgressHook) + (workInProgressHook = nextWorkInProgressHook), + (currentHook = nextCurrentHook); + else { + if (null === nextCurrentHook) { + if (null === currentlyRenderingFiber$1.alternate) + throw Error(formatProdErrorMessage(467)); + throw Error(formatProdErrorMessage(310)); + } + currentHook = nextCurrentHook; + nextCurrentHook = { + memoizedState: currentHook.memoizedState, + baseState: currentHook.baseState, + baseQueue: currentHook.baseQueue, + queue: currentHook.queue, + next: null + }; + null === workInProgressHook + ? (currentlyRenderingFiber$1.memoizedState = workInProgressHook = + nextCurrentHook) + : (workInProgressHook = workInProgressHook.next = nextCurrentHook); + } + return workInProgressHook; + } + function useThenable(thenable) { + var index = thenableIndexCounter; + thenableIndexCounter += 1; + null === thenableState && (thenableState = []); + thenable = trackUsedThenable(thenableState, thenable, index); + index = currentlyRenderingFiber$1; + null === + (null === workInProgressHook + ? index.memoizedState + : workInProgressHook.next) && + ((index = index.alternate), + (ReactSharedInternals.H = + null === index || null === index.memoizedState + ? HooksDispatcherOnMount + : HooksDispatcherOnUpdate)); + return thenable; + } + function use(usable) { + if (null !== usable && "object" === typeof usable) { + if ("function" === typeof usable.then) return useThenable(usable); + if (usable.$$typeof === REACT_CONTEXT_TYPE) return readContext(usable); + } + throw Error(formatProdErrorMessage(438, String(usable))); + } + function useMemoCache(size) { + var memoCache = null, + updateQueue = currentlyRenderingFiber$1.updateQueue; + null !== updateQueue && (memoCache = updateQueue.memoCache); + if (null == memoCache) { + var current = currentlyRenderingFiber$1.alternate; + null !== current && + ((current = current.updateQueue), + null !== current && + ((current = current.memoCache), + null != current && + (memoCache = { + data: current.data.map(function (array) { + return array.slice(); + }), + index: 0 + }))); + } + null == memoCache && (memoCache = { data: [], index: 0 }); + null === updateQueue && + ((updateQueue = createFunctionComponentUpdateQueue()), + (currentlyRenderingFiber$1.updateQueue = updateQueue)); + updateQueue.memoCache = memoCache; + updateQueue = memoCache.data[memoCache.index]; + if (void 0 === updateQueue) + for ( + updateQueue = memoCache.data[memoCache.index] = Array(size), + current = 0; + current < size; + current++ + ) + updateQueue[current] = REACT_MEMO_CACHE_SENTINEL; + memoCache.index++; + return updateQueue; + } + function basicStateReducer(state, action) { + return "function" === typeof action ? action(state) : action; + } + function updateReducer(reducer) { + var hook = updateWorkInProgressHook(); + return updateReducerImpl(hook, currentHook, reducer); + } + function updateReducerImpl(hook, current, reducer) { + var queue = hook.queue; + if (null === queue) throw Error(formatProdErrorMessage(311)); + queue.lastRenderedReducer = reducer; + var baseQueue = hook.baseQueue, + pendingQueue = queue.pending; + if (null !== pendingQueue) { + if (null !== baseQueue) { + var baseFirst = baseQueue.next; + baseQueue.next = pendingQueue.next; + pendingQueue.next = baseFirst; + } + current.baseQueue = baseQueue = pendingQueue; + queue.pending = null; + } + pendingQueue = hook.baseState; + if (null === baseQueue) hook.memoizedState = pendingQueue; + else { + current = baseQueue.next; + var newBaseQueueFirst = (baseFirst = null), + newBaseQueueLast = null, + update = current, + didReadFromEntangledAsyncAction$48 = !1; + do { + var updateLane = update.lane & -536870913; + if ( + updateLane !== update.lane + ? (workInProgressRootRenderLanes & updateLane) === updateLane + : (renderLanes & updateLane) === updateLane + ) { + var revertLane = update.revertLane; + if (0 === revertLane) + null !== newBaseQueueLast && + (newBaseQueueLast = newBaseQueueLast.next = + { + lane: 0, + revertLane: 0, + action: update.action, + hasEagerState: update.hasEagerState, + eagerState: update.eagerState, + next: null + }), + updateLane === currentEntangledLane && + (didReadFromEntangledAsyncAction$48 = !0); + else if ((renderLanes & revertLane) === revertLane) { + update = update.next; + revertLane === currentEntangledLane && + (didReadFromEntangledAsyncAction$48 = !0); + continue; + } else + (updateLane = { + lane: 0, + revertLane: update.revertLane, + action: update.action, + hasEagerState: update.hasEagerState, + eagerState: update.eagerState, + next: null + }), + null === newBaseQueueLast + ? ((newBaseQueueFirst = newBaseQueueLast = updateLane), + (baseFirst = pendingQueue)) + : (newBaseQueueLast = newBaseQueueLast.next = updateLane), + (currentlyRenderingFiber$1.lanes |= revertLane), + (workInProgressRootSkippedLanes |= revertLane); + updateLane = update.action; + shouldDoubleInvokeUserFnsInHooksDEV && + reducer(pendingQueue, updateLane); + pendingQueue = update.hasEagerState + ? update.eagerState + : reducer(pendingQueue, updateLane); + } else + (revertLane = { + lane: updateLane, + revertLane: update.revertLane, + action: update.action, + hasEagerState: update.hasEagerState, + eagerState: update.eagerState, + next: null + }), + null === newBaseQueueLast + ? ((newBaseQueueFirst = newBaseQueueLast = revertLane), + (baseFirst = pendingQueue)) + : (newBaseQueueLast = newBaseQueueLast.next = revertLane), + (currentlyRenderingFiber$1.lanes |= updateLane), + (workInProgressRootSkippedLanes |= updateLane); + update = update.next; + } while (null !== update && update !== current); + null === newBaseQueueLast + ? (baseFirst = pendingQueue) + : (newBaseQueueLast.next = newBaseQueueFirst); + if ( + !objectIs(pendingQueue, hook.memoizedState) && + ((didReceiveUpdate = !0), + didReadFromEntangledAsyncAction$48 && + ((reducer = currentEntangledActionThenable), null !== reducer)) + ) + throw reducer; + hook.memoizedState = pendingQueue; + hook.baseState = baseFirst; + hook.baseQueue = newBaseQueueLast; + queue.lastRenderedState = pendingQueue; + } + null === baseQueue && (queue.lanes = 0); + return [hook.memoizedState, queue.dispatch]; + } + function rerenderReducer(reducer) { + var hook = updateWorkInProgressHook(), + queue = hook.queue; + if (null === queue) throw Error(formatProdErrorMessage(311)); + queue.lastRenderedReducer = reducer; + var dispatch = queue.dispatch, + lastRenderPhaseUpdate = queue.pending, + newState = hook.memoizedState; + if (null !== lastRenderPhaseUpdate) { + queue.pending = null; + var update = (lastRenderPhaseUpdate = lastRenderPhaseUpdate.next); + do (newState = reducer(newState, update.action)), (update = update.next); + while (update !== lastRenderPhaseUpdate); + objectIs(newState, hook.memoizedState) || (didReceiveUpdate = !0); + hook.memoizedState = newState; + null === hook.baseQueue && (hook.baseState = newState); + queue.lastRenderedState = newState; + } + return [newState, dispatch]; + } + function updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) { + var fiber = currentlyRenderingFiber$1, + hook = updateWorkInProgressHook(), + isHydrating$jscomp$0 = isHydrating; + if (isHydrating$jscomp$0) { + if (void 0 === getServerSnapshot) + throw Error(formatProdErrorMessage(407)); + getServerSnapshot = getServerSnapshot(); + } else getServerSnapshot = getSnapshot(); + var snapshotChanged = !objectIs( + (currentHook || hook).memoizedState, + getServerSnapshot + ); + snapshotChanged && + ((hook.memoizedState = getServerSnapshot), (didReceiveUpdate = !0)); + hook = hook.queue; + updateEffect(subscribeToStore.bind(null, fiber, hook, subscribe), [ + subscribe + ]); + if ( + hook.getSnapshot !== getSnapshot || + snapshotChanged || + (null !== workInProgressHook && workInProgressHook.memoizedState.tag & 1) + ) { + fiber.flags |= 2048; + pushEffect( + 9, + updateStoreInstance.bind( + null, + fiber, + hook, + getServerSnapshot, + getSnapshot + ), + { destroy: void 0 }, + null + ); + if (null === workInProgressRoot) throw Error(formatProdErrorMessage(349)); + isHydrating$jscomp$0 || + 0 !== (renderLanes & 60) || + pushStoreConsistencyCheck(fiber, getSnapshot, getServerSnapshot); + } + return getServerSnapshot; + } + function pushStoreConsistencyCheck(fiber, getSnapshot, renderedSnapshot) { + fiber.flags |= 16384; + fiber = { getSnapshot: getSnapshot, value: renderedSnapshot }; + getSnapshot = currentlyRenderingFiber$1.updateQueue; + null === getSnapshot + ? ((getSnapshot = createFunctionComponentUpdateQueue()), + (currentlyRenderingFiber$1.updateQueue = getSnapshot), + (getSnapshot.stores = [fiber])) + : ((renderedSnapshot = getSnapshot.stores), + null === renderedSnapshot + ? (getSnapshot.stores = [fiber]) + : renderedSnapshot.push(fiber)); + } + function updateStoreInstance(fiber, inst, nextSnapshot, getSnapshot) { + inst.value = nextSnapshot; + inst.getSnapshot = getSnapshot; + checkIfSnapshotChanged(inst) && forceStoreRerender(fiber); + } + function subscribeToStore(fiber, inst, subscribe) { + return subscribe(function () { + checkIfSnapshotChanged(inst) && forceStoreRerender(fiber); + }); + } + function checkIfSnapshotChanged(inst) { + var latestGetSnapshot = inst.getSnapshot; + inst = inst.value; + try { + var nextValue = latestGetSnapshot(); + return !objectIs(inst, nextValue); + } catch (error) { + return !0; + } + } + function forceStoreRerender(fiber) { + var root = enqueueConcurrentRenderForLane(fiber, 2); + null !== root && scheduleUpdateOnFiber(root, fiber, 2); + } + function mountStateImpl(initialState) { + var hook = mountWorkInProgressHook(); + if ("function" === typeof initialState) { + var initialStateInitializer = initialState; + initialState = initialStateInitializer(); + if (shouldDoubleInvokeUserFnsInHooksDEV) { + setIsStrictModeForDevtools(!0); + try { + initialStateInitializer(); + } finally { + setIsStrictModeForDevtools(!1); + } + } + } + hook.memoizedState = hook.baseState = initialState; + hook.queue = { + pending: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: basicStateReducer, + lastRenderedState: initialState + }; + return hook; + } + function updateOptimisticImpl(hook, current, passthrough, reducer) { + hook.baseState = passthrough; + return updateReducerImpl( + hook, + currentHook, + "function" === typeof reducer ? reducer : basicStateReducer + ); + } + function dispatchActionState( + fiber, + actionQueue, + setPendingState, + setState, + payload + ) { + if (isRenderPhaseUpdate(fiber)) throw Error(formatProdErrorMessage(485)); + fiber = actionQueue.action; + if (null !== fiber) { + var actionNode = { + payload: payload, + action: fiber, + next: null, + isTransition: !0, + status: "pending", + value: null, + reason: null, + listeners: [], + then: function (listener) { + actionNode.listeners.push(listener); + } + }; + null !== ReactSharedInternals.T + ? setPendingState(!0) + : (actionNode.isTransition = !1); + setState(actionNode); + setPendingState = actionQueue.pending; + null === setPendingState + ? ((actionNode.next = actionQueue.pending = actionNode), + runActionStateAction(actionQueue, actionNode)) + : ((actionNode.next = setPendingState.next), + (actionQueue.pending = setPendingState.next = actionNode)); + } + } + function runActionStateAction(actionQueue, node) { + var action = node.action, + payload = node.payload, + prevState = actionQueue.state; + if (node.isTransition) { + var prevTransition = ReactSharedInternals.T, + currentTransition = {}; + ReactSharedInternals.T = currentTransition; + try { + var returnValue = action(prevState, payload), + onStartTransitionFinish = ReactSharedInternals.S; + null !== onStartTransitionFinish && + onStartTransitionFinish(currentTransition, returnValue); + handleActionReturnValue(actionQueue, node, returnValue); + } catch (error) { + onActionError(actionQueue, node, error); + } finally { + ReactSharedInternals.T = prevTransition; + } + } else + try { + (prevTransition = action(prevState, payload)), + handleActionReturnValue(actionQueue, node, prevTransition); + } catch (error$52) { + onActionError(actionQueue, node, error$52); + } + } + function handleActionReturnValue(actionQueue, node, returnValue) { + null !== returnValue && + "object" === typeof returnValue && + "function" === typeof returnValue.then + ? returnValue.then( + function (nextState) { + onActionSuccess(actionQueue, node, nextState); + }, + function (error) { + return onActionError(actionQueue, node, error); + } + ) + : onActionSuccess(actionQueue, node, returnValue); + } + function onActionSuccess(actionQueue, actionNode, nextState) { + actionNode.status = "fulfilled"; + actionNode.value = nextState; + notifyActionListeners(actionNode); + actionQueue.state = nextState; + actionNode = actionQueue.pending; + null !== actionNode && + ((nextState = actionNode.next), + nextState === actionNode + ? (actionQueue.pending = null) + : ((nextState = nextState.next), + (actionNode.next = nextState), + runActionStateAction(actionQueue, nextState))); + } + function onActionError(actionQueue, actionNode, error) { + var last = actionQueue.pending; + actionQueue.pending = null; + if (null !== last) { + last = last.next; + do + (actionNode.status = "rejected"), + (actionNode.reason = error), + notifyActionListeners(actionNode), + (actionNode = actionNode.next); + while (actionNode !== last); + } + actionQueue.action = null; + } + function notifyActionListeners(actionNode) { + actionNode = actionNode.listeners; + for (var i = 0; i < actionNode.length; i++) (0, actionNode[i])(); + } + function actionStateReducer(oldState, newState) { + return newState; + } + function mountActionState(action, initialStateProp) { + if (isHydrating) { + var ssrFormState = workInProgressRoot.formState; + if (null !== ssrFormState) { + a: { + var JSCompiler_inline_result = currentlyRenderingFiber$1; + if (isHydrating) { + if (nextHydratableInstance) { + var markerInstance = canHydrateFormStateMarker( + nextHydratableInstance, + rootOrSingletonContext + ); + if (markerInstance) { + nextHydratableInstance = + getNextHydratableSibling(markerInstance); + JSCompiler_inline_result = + isFormStateMarkerMatching(markerInstance); + break a; + } + } + throwOnHydrationMismatch(JSCompiler_inline_result); + } + JSCompiler_inline_result = !1; + } + JSCompiler_inline_result && (initialStateProp = ssrFormState[0]); + } + } + ssrFormState = mountWorkInProgressHook(); + ssrFormState.memoizedState = ssrFormState.baseState = initialStateProp; + JSCompiler_inline_result = { + pending: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: actionStateReducer, + lastRenderedState: initialStateProp + }; + ssrFormState.queue = JSCompiler_inline_result; + ssrFormState = dispatchSetState.bind( + null, + currentlyRenderingFiber$1, + JSCompiler_inline_result + ); + JSCompiler_inline_result.dispatch = ssrFormState; + JSCompiler_inline_result = mountStateImpl(!1); + var setPendingState = dispatchOptimisticSetState.bind( + null, + currentlyRenderingFiber$1, + !1, + JSCompiler_inline_result.queue + ); + JSCompiler_inline_result = mountWorkInProgressHook(); + markerInstance = { + state: initialStateProp, + dispatch: null, + action: action, + pending: null + }; + JSCompiler_inline_result.queue = markerInstance; + ssrFormState = dispatchActionState.bind( + null, + currentlyRenderingFiber$1, + markerInstance, + setPendingState, + ssrFormState + ); + markerInstance.dispatch = ssrFormState; + JSCompiler_inline_result.memoizedState = action; + return [initialStateProp, ssrFormState, !1]; + } + function updateActionState(action) { + var stateHook = updateWorkInProgressHook(); + return updateActionStateImpl(stateHook, currentHook, action); + } + function updateActionStateImpl(stateHook, currentStateHook, action) { + currentStateHook = updateReducerImpl( + stateHook, + currentStateHook, + actionStateReducer + )[0]; + stateHook = updateReducer(basicStateReducer)[0]; + currentStateHook = + "object" === typeof currentStateHook && + null !== currentStateHook && + "function" === typeof currentStateHook.then + ? useThenable(currentStateHook) + : currentStateHook; + var actionQueueHook = updateWorkInProgressHook(), + actionQueue = actionQueueHook.queue, + dispatch = actionQueue.dispatch; + action !== actionQueueHook.memoizedState && + ((currentlyRenderingFiber$1.flags |= 2048), + pushEffect( + 9, + actionStateActionEffect.bind(null, actionQueue, action), + { destroy: void 0 }, + null + )); + return [currentStateHook, dispatch, stateHook]; + } + function actionStateActionEffect(actionQueue, action) { + actionQueue.action = action; + } + function rerenderActionState(action) { + var stateHook = updateWorkInProgressHook(), + currentStateHook = currentHook; + if (null !== currentStateHook) + return updateActionStateImpl(stateHook, currentStateHook, action); + updateWorkInProgressHook(); + stateHook = stateHook.memoizedState; + currentStateHook = updateWorkInProgressHook(); + var dispatch = currentStateHook.queue.dispatch; + currentStateHook.memoizedState = action; + return [stateHook, dispatch, !1]; + } + function pushEffect(tag, create, inst, deps) { + tag = { tag: tag, create: create, inst: inst, deps: deps, next: null }; + create = currentlyRenderingFiber$1.updateQueue; + null === create && + ((create = createFunctionComponentUpdateQueue()), + (currentlyRenderingFiber$1.updateQueue = create)); + inst = create.lastEffect; + null === inst + ? (create.lastEffect = tag.next = tag) + : ((deps = inst.next), + (inst.next = tag), + (tag.next = deps), + (create.lastEffect = tag)); + return tag; + } + function updateRef() { + return updateWorkInProgressHook().memoizedState; + } + function mountEffectImpl(fiberFlags, hookFlags, create, deps) { + var hook = mountWorkInProgressHook(); + currentlyRenderingFiber$1.flags |= fiberFlags; + hook.memoizedState = pushEffect( + 1 | hookFlags, + create, + { destroy: void 0 }, + void 0 === deps ? null : deps + ); + } + function updateEffectImpl(fiberFlags, hookFlags, create, deps) { + var hook = updateWorkInProgressHook(); + deps = void 0 === deps ? null : deps; + var inst = hook.memoizedState.inst; + null !== currentHook && + null !== deps && + areHookInputsEqual(deps, currentHook.memoizedState.deps) + ? (hook.memoizedState = pushEffect(hookFlags, create, inst, deps)) + : ((currentlyRenderingFiber$1.flags |= fiberFlags), + (hook.memoizedState = pushEffect(1 | hookFlags, create, inst, deps))); + } + function mountEffect(create, deps) { + mountEffectImpl(8390656, 8, create, deps); + } + function updateEffect(create, deps) { + updateEffectImpl(2048, 8, create, deps); + } + function updateInsertionEffect(create, deps) { + return updateEffectImpl(4, 2, create, deps); + } + function updateLayoutEffect(create, deps) { + return updateEffectImpl(4, 4, create, deps); + } + function imperativeHandleEffect(create, ref) { + if ("function" === typeof ref) { + create = create(); + var refCleanup = ref(create); + return function () { + "function" === typeof refCleanup ? refCleanup() : ref(null); + }; + } + if (null !== ref && void 0 !== ref) + return ( + (create = create()), + (ref.current = create), + function () { + ref.current = null; + } + ); + } + function updateImperativeHandle(ref, create, deps) { + deps = null !== deps && void 0 !== deps ? deps.concat([ref]) : null; + updateEffectImpl( + 4, + 4, + imperativeHandleEffect.bind(null, create, ref), + deps + ); + } + function mountDebugValue() {} + function updateCallback(callback, deps) { + var hook = updateWorkInProgressHook(); + deps = void 0 === deps ? null : deps; + var prevState = hook.memoizedState; + if (null !== deps && areHookInputsEqual(deps, prevState[1])) + return prevState[0]; + hook.memoizedState = [callback, deps]; + return callback; + } + function updateMemo(nextCreate, deps) { + var hook = updateWorkInProgressHook(); + deps = void 0 === deps ? null : deps; + var prevState = hook.memoizedState; + if (null !== deps && areHookInputsEqual(deps, prevState[1])) + return prevState[0]; + prevState = nextCreate(); + if (shouldDoubleInvokeUserFnsInHooksDEV) { + setIsStrictModeForDevtools(!0); + try { + nextCreate(); + } finally { + setIsStrictModeForDevtools(!1); + } + } + hook.memoizedState = [prevState, deps]; + return prevState; + } + function mountDeferredValueImpl(hook, value, initialValue) { + if (void 0 === initialValue || 0 !== (renderLanes & 1073741824)) + return (hook.memoizedState = value); + hook.memoizedState = initialValue; + hook = requestDeferredLane(); + currentlyRenderingFiber$1.lanes |= hook; + workInProgressRootSkippedLanes |= hook; + return initialValue; + } + function updateDeferredValueImpl(hook, prevValue, value, initialValue) { + if (objectIs(value, prevValue)) return value; + if (null !== currentTreeHiddenStackCursor.current) + return ( + (hook = mountDeferredValueImpl(hook, value, initialValue)), + objectIs(hook, prevValue) || (didReceiveUpdate = !0), + hook + ); + if (0 === (renderLanes & 42)) + return (didReceiveUpdate = !0), (hook.memoizedState = value); + hook = requestDeferredLane(); + currentlyRenderingFiber$1.lanes |= hook; + workInProgressRootSkippedLanes |= hook; + return prevValue; + } + function startTransition( + fiber, + queue, + pendingState, + finishedState, + callback + ) { + var previousPriority = getCurrentUpdatePriority(); + setCurrentUpdatePriority( + 0 !== previousPriority && 8 > previousPriority ? previousPriority : 8 + ); + var prevTransition = ReactSharedInternals.T, + currentTransition = {}; + ReactSharedInternals.T = currentTransition; + dispatchOptimisticSetState(fiber, !1, queue, pendingState); + try { + var returnValue = callback(), + onStartTransitionFinish = ReactSharedInternals.S; + null !== onStartTransitionFinish && + onStartTransitionFinish(currentTransition, returnValue); + if ( + null !== returnValue && + "object" === typeof returnValue && + "function" === typeof returnValue.then + ) { + var thenableForFinishedState = chainThenableValue( + returnValue, + finishedState + ); + dispatchSetStateInternal( + fiber, + queue, + thenableForFinishedState, + requestUpdateLane(fiber) + ); + } else + dispatchSetStateInternal( + fiber, + queue, + finishedState, + requestUpdateLane(fiber) + ); + } catch (error) { + dispatchSetStateInternal( + fiber, + queue, + { then: function () {}, status: "rejected", reason: error }, + requestUpdateLane() + ); + } finally { + setCurrentUpdatePriority(previousPriority), + (ReactSharedInternals.T = prevTransition); + } + } + function ensureFormComponentIsStateful(formFiber) { + var existingStateHook = formFiber.memoizedState; + if (null !== existingStateHook) return existingStateHook; + existingStateHook = { + memoizedState: NotPendingTransition, + baseState: NotPendingTransition, + baseQueue: null, + queue: { + pending: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: basicStateReducer, + lastRenderedState: NotPendingTransition + }, + next: null + }; + var initialResetState = {}; + existingStateHook.next = { + memoizedState: initialResetState, + baseState: initialResetState, + baseQueue: null, + queue: { + pending: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: basicStateReducer, + lastRenderedState: initialResetState + }, + next: null + }; + formFiber.memoizedState = existingStateHook; + formFiber = formFiber.alternate; + null !== formFiber && (formFiber.memoizedState = existingStateHook); + return existingStateHook; + } + function useHostTransitionStatus() { + return readContext(HostTransitionContext); + } + function updateId() { + return updateWorkInProgressHook().memoizedState; + } + function updateRefresh() { + return updateWorkInProgressHook().memoizedState; + } + function refreshCache(fiber) { + for (var provider = fiber.return; null !== provider; ) { + switch (provider.tag) { + case 24: + case 3: + var lane = requestUpdateLane(); + fiber = createUpdate(lane); + var root = enqueueUpdate(provider, fiber, lane); + null !== root && + (scheduleUpdateOnFiber(root, provider, lane), + entangleTransitions(root, provider, lane)); + provider = { cache: createCache() }; + fiber.payload = provider; + return; + } + provider = provider.return; + } + } + function dispatchReducerAction(fiber, queue, action) { + var lane = requestUpdateLane(); + action = { + lane: lane, + revertLane: 0, + action: action, + hasEagerState: !1, + eagerState: null, + next: null + }; + isRenderPhaseUpdate(fiber) + ? enqueueRenderPhaseUpdate(queue, action) + : ((action = enqueueConcurrentHookUpdate(fiber, queue, action, lane)), + null !== action && + (scheduleUpdateOnFiber(action, fiber, lane), + entangleTransitionUpdate(action, queue, lane))); + } + function dispatchSetState(fiber, queue, action) { + var lane = requestUpdateLane(); + dispatchSetStateInternal(fiber, queue, action, lane); + } + function dispatchSetStateInternal(fiber, queue, action, lane) { + var update = { + lane: lane, + revertLane: 0, + action: action, + hasEagerState: !1, + eagerState: null, + next: null + }; + if (isRenderPhaseUpdate(fiber)) enqueueRenderPhaseUpdate(queue, update); + else { + var alternate = fiber.alternate; + if ( + 0 === fiber.lanes && + (null === alternate || 0 === alternate.lanes) && + ((alternate = queue.lastRenderedReducer), null !== alternate) + ) + try { + var currentState = queue.lastRenderedState, + eagerState = alternate(currentState, action); + update.hasEagerState = !0; + update.eagerState = eagerState; + if (objectIs(eagerState, currentState)) + return ( + enqueueUpdate$1(fiber, queue, update, 0), + null === workInProgressRoot && finishQueueingConcurrentUpdates(), + !1 + ); + } catch (error) { + } finally { + } + action = enqueueConcurrentHookUpdate(fiber, queue, update, lane); + if (null !== action) + return ( + scheduleUpdateOnFiber(action, fiber, lane), + entangleTransitionUpdate(action, queue, lane), + !0 + ); + } + return !1; + } + function dispatchOptimisticSetState( + fiber, + throwIfDuringRender, + queue, + action + ) { + action = { + lane: 2, + revertLane: requestTransitionLane(), + action: action, + hasEagerState: !1, + eagerState: null, + next: null + }; + if (isRenderPhaseUpdate(fiber)) { + if (throwIfDuringRender) throw Error(formatProdErrorMessage(479)); + } else + (throwIfDuringRender = enqueueConcurrentHookUpdate( + fiber, + queue, + action, + 2 + )), + null !== throwIfDuringRender && + scheduleUpdateOnFiber(throwIfDuringRender, fiber, 2); + } + function isRenderPhaseUpdate(fiber) { + var alternate = fiber.alternate; + return ( + fiber === currentlyRenderingFiber$1 || + (null !== alternate && alternate === currentlyRenderingFiber$1) + ); + } + function enqueueRenderPhaseUpdate(queue, update) { + didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = + !0; + var pending = queue.pending; + null === pending + ? (update.next = update) + : ((update.next = pending.next), (pending.next = update)); + queue.pending = update; + } + function entangleTransitionUpdate(root, queue, lane) { + if (0 !== (lane & 4194176)) { + var queueLanes = queue.lanes; + queueLanes &= root.pendingLanes; + lane |= queueLanes; + queue.lanes = lane; + markRootEntangled(root, lane); + } + } + function applyDerivedStateFromProps( + workInProgress, + ctor, + getDerivedStateFromProps, + nextProps + ) { + ctor = workInProgress.memoizedState; + getDerivedStateFromProps = getDerivedStateFromProps(nextProps, ctor); + getDerivedStateFromProps = + null === getDerivedStateFromProps || void 0 === getDerivedStateFromProps + ? ctor + : assign({}, ctor, getDerivedStateFromProps); + workInProgress.memoizedState = getDerivedStateFromProps; + 0 === workInProgress.lanes && + (workInProgress.updateQueue.baseState = getDerivedStateFromProps); + } + function checkShouldComponentUpdate( + workInProgress, + ctor, + oldProps, + newProps, + oldState, + newState, + nextContext + ) { + workInProgress = workInProgress.stateNode; + return "function" === typeof workInProgress.shouldComponentUpdate + ? workInProgress.shouldComponentUpdate(newProps, newState, nextContext) + : ctor.prototype && ctor.prototype.isPureReactComponent + ? !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState) + : !0; + } + function callComponentWillReceiveProps( + workInProgress, + instance, + newProps, + nextContext + ) { + workInProgress = instance.state; + "function" === typeof instance.componentWillReceiveProps && + instance.componentWillReceiveProps(newProps, nextContext); + "function" === typeof instance.UNSAFE_componentWillReceiveProps && + instance.UNSAFE_componentWillReceiveProps(newProps, nextContext); + instance.state !== workInProgress && + classComponentUpdater.enqueueReplaceState(instance, instance.state, null); + } + function resolveClassComponentProps(Component, baseProps) { + var newProps = baseProps; + if ("ref" in baseProps) { + newProps = {}; + for (var propName in baseProps) + "ref" !== propName && (newProps[propName] = baseProps[propName]); + } + if ((Component = Component.defaultProps)) { + newProps === baseProps && (newProps = assign({}, newProps)); + for (var propName$54 in Component) + void 0 === newProps[propName$54] && + (newProps[propName$54] = Component[propName$54]); + } + return newProps; + } + function logUncaughtError(root, errorInfo) { + try { + var onUncaughtError = root.onUncaughtError; + onUncaughtError(errorInfo.value, { componentStack: errorInfo.stack }); + } catch (e) { + setTimeout(function () { + throw e; + }); + } + } + function logCaughtError(root, boundary, errorInfo) { + try { + var onCaughtError = root.onCaughtError; + onCaughtError(errorInfo.value, { + componentStack: errorInfo.stack, + errorBoundary: 1 === boundary.tag ? boundary.stateNode : null + }); + } catch (e) { + setTimeout(function () { + throw e; + }); + } + } + function createRootErrorUpdate(root, errorInfo, lane) { + lane = createUpdate(lane); + lane.tag = 3; + lane.payload = { element: null }; + lane.callback = function () { + logUncaughtError(root, errorInfo); + }; + return lane; + } + function createClassErrorUpdate(lane) { + lane = createUpdate(lane); + lane.tag = 3; + return lane; + } + function initializeClassErrorUpdate(update, root, fiber, errorInfo) { + var getDerivedStateFromError = fiber.type.getDerivedStateFromError; + if ("function" === typeof getDerivedStateFromError) { + var error = errorInfo.value; + update.payload = function () { + return getDerivedStateFromError(error); + }; + update.callback = function () { + logCaughtError(root, fiber, errorInfo); + }; + } + var inst = fiber.stateNode; + null !== inst && + "function" === typeof inst.componentDidCatch && + (update.callback = function () { + logCaughtError(root, fiber, errorInfo); + "function" !== typeof getDerivedStateFromError && + (null === legacyErrorBoundariesThatAlreadyFailed + ? (legacyErrorBoundariesThatAlreadyFailed = new Set([this])) + : legacyErrorBoundariesThatAlreadyFailed.add(this)); + var stack = errorInfo.stack; + this.componentDidCatch(errorInfo.value, { + componentStack: null !== stack ? stack : "" + }); + }); + } + function throwException( + root, + returnFiber, + sourceFiber, + value, + rootRenderLanes + ) { + sourceFiber.flags |= 32768; + if ( + null !== value && + "object" === typeof value && + "function" === typeof value.then + ) { + returnFiber = sourceFiber.alternate; + null !== returnFiber && + propagateParentContextChanges( + returnFiber, + sourceFiber, + rootRenderLanes, + !0 + ); + sourceFiber = suspenseHandlerStackCursor.current; + if (null !== sourceFiber) { + switch (sourceFiber.tag) { + case 13: + return ( + null === shellBoundary + ? renderDidSuspendDelayIfPossible() + : null === sourceFiber.alternate && + 0 === workInProgressRootExitStatus && + (workInProgressRootExitStatus = 3), + (sourceFiber.flags &= -257), + (sourceFiber.flags |= 65536), + (sourceFiber.lanes = rootRenderLanes), + value === noopSuspenseyCommitThenable + ? (sourceFiber.flags |= 16384) + : ((returnFiber = sourceFiber.updateQueue), + null === returnFiber + ? (sourceFiber.updateQueue = new Set([value])) + : returnFiber.add(value), + attachPingListener(root, value, rootRenderLanes)), + !1 + ); + case 22: + return ( + (sourceFiber.flags |= 65536), + value === noopSuspenseyCommitThenable + ? (sourceFiber.flags |= 16384) + : ((returnFiber = sourceFiber.updateQueue), + null === returnFiber + ? ((returnFiber = { + transitions: null, + markerInstances: null, + retryQueue: new Set([value]) + }), + (sourceFiber.updateQueue = returnFiber)) + : ((sourceFiber = returnFiber.retryQueue), + null === sourceFiber + ? (returnFiber.retryQueue = new Set([value])) + : sourceFiber.add(value)), + attachPingListener(root, value, rootRenderLanes)), + !1 + ); + } + throw Error(formatProdErrorMessage(435, sourceFiber.tag)); + } + attachPingListener(root, value, rootRenderLanes); + renderDidSuspendDelayIfPossible(); + return !1; + } + if (isHydrating) + return ( + (returnFiber = suspenseHandlerStackCursor.current), + null !== returnFiber + ? (0 === (returnFiber.flags & 65536) && (returnFiber.flags |= 256), + (returnFiber.flags |= 65536), + (returnFiber.lanes = rootRenderLanes), + value !== HydrationMismatchException && + ((root = Error(formatProdErrorMessage(422), { cause: value })), + queueHydrationError( + createCapturedValueAtFiber(root, sourceFiber) + ))) + : (value !== HydrationMismatchException && + ((returnFiber = Error(formatProdErrorMessage(423), { + cause: value + })), + queueHydrationError( + createCapturedValueAtFiber(returnFiber, sourceFiber) + )), + (root = root.current.alternate), + (root.flags |= 65536), + (rootRenderLanes &= -rootRenderLanes), + (root.lanes |= rootRenderLanes), + (value = createCapturedValueAtFiber(value, sourceFiber)), + (rootRenderLanes = createRootErrorUpdate( + root.stateNode, + value, + rootRenderLanes + )), + enqueueCapturedUpdate(root, rootRenderLanes), + 4 !== workInProgressRootExitStatus && + (workInProgressRootExitStatus = 2)), + !1 + ); + var wrapperError = Error(formatProdErrorMessage(520), { cause: value }); + wrapperError = createCapturedValueAtFiber(wrapperError, sourceFiber); + null === workInProgressRootConcurrentErrors + ? (workInProgressRootConcurrentErrors = [wrapperError]) + : workInProgressRootConcurrentErrors.push(wrapperError); + 4 !== workInProgressRootExitStatus && (workInProgressRootExitStatus = 2); + if (null === returnFiber) return !0; + value = createCapturedValueAtFiber(value, sourceFiber); + sourceFiber = returnFiber; + do { + switch (sourceFiber.tag) { + case 3: + return ( + (sourceFiber.flags |= 65536), + (root = rootRenderLanes & -rootRenderLanes), + (sourceFiber.lanes |= root), + (root = createRootErrorUpdate(sourceFiber.stateNode, value, root)), + enqueueCapturedUpdate(sourceFiber, root), + !1 + ); + case 1: + if ( + ((returnFiber = sourceFiber.type), + (wrapperError = sourceFiber.stateNode), + 0 === (sourceFiber.flags & 128) && + ("function" === typeof returnFiber.getDerivedStateFromError || + (null !== wrapperError && + "function" === typeof wrapperError.componentDidCatch && + (null === legacyErrorBoundariesThatAlreadyFailed || + !legacyErrorBoundariesThatAlreadyFailed.has( + wrapperError + ))))) + ) + return ( + (sourceFiber.flags |= 65536), + (rootRenderLanes &= -rootRenderLanes), + (sourceFiber.lanes |= rootRenderLanes), + (rootRenderLanes = createClassErrorUpdate(rootRenderLanes)), + initializeClassErrorUpdate( + rootRenderLanes, + root, + sourceFiber, + value + ), + enqueueCapturedUpdate(sourceFiber, rootRenderLanes), + !1 + ); + } + sourceFiber = sourceFiber.return; + } while (null !== sourceFiber); + return !1; + } + function reconcileChildren( + current, + workInProgress, + nextChildren, + renderLanes + ) { + workInProgress.child = + null === current + ? mountChildFibers(workInProgress, null, nextChildren, renderLanes) + : reconcileChildFibers( + workInProgress, + current.child, + nextChildren, + renderLanes + ); + } + function updateForwardRef( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) { + Component = Component.render; + var ref = workInProgress.ref; + if ("ref" in nextProps) { + var propsWithoutRef = {}; + for (var key in nextProps) + "ref" !== key && (propsWithoutRef[key] = nextProps[key]); + } else propsWithoutRef = nextProps; + prepareToReadContext(workInProgress); + nextProps = renderWithHooks( + current, + workInProgress, + Component, + propsWithoutRef, + ref, + renderLanes + ); + key = checkDidRenderIdHook(); + if (null !== current && !didReceiveUpdate) + return ( + bailoutHooks(current, workInProgress, renderLanes), + bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + ); + isHydrating && key && pushMaterializedTreeId(workInProgress); + workInProgress.flags |= 1; + reconcileChildren(current, workInProgress, nextProps, renderLanes); + return workInProgress.child; + } + function updateMemoComponent( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) { + if (null === current) { + var type = Component.type; + if ( + "function" === typeof type && + !shouldConstruct(type) && + void 0 === type.defaultProps && + null === Component.compare + ) + return ( + (workInProgress.tag = 15), + (workInProgress.type = type), + updateSimpleMemoComponent( + current, + workInProgress, + type, + nextProps, + renderLanes + ) + ); + current = createFiberFromTypeAndProps( + Component.type, + null, + nextProps, + workInProgress, + workInProgress.mode, + renderLanes + ); + current.ref = workInProgress.ref; + current.return = workInProgress; + return (workInProgress.child = current); + } + type = current.child; + if (!checkScheduledUpdateOrContext(current, renderLanes)) { + var prevProps = type.memoizedProps; + Component = Component.compare; + Component = null !== Component ? Component : shallowEqual; + if (Component(prevProps, nextProps) && current.ref === workInProgress.ref) + return bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes + ); + } + workInProgress.flags |= 1; + current = createWorkInProgress(type, nextProps); + current.ref = workInProgress.ref; + current.return = workInProgress; + return (workInProgress.child = current); + } + function updateSimpleMemoComponent( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) { + if (null !== current) { + var prevProps = current.memoizedProps; + if ( + shallowEqual(prevProps, nextProps) && + current.ref === workInProgress.ref + ) + if ( + ((didReceiveUpdate = !1), + (workInProgress.pendingProps = nextProps = prevProps), + checkScheduledUpdateOrContext(current, renderLanes)) + ) + 0 !== (current.flags & 131072) && (didReceiveUpdate = !0); + else + return ( + (workInProgress.lanes = current.lanes), + bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + ); + } + return updateFunctionComponent( + current, + workInProgress, + Component, + nextProps, + renderLanes + ); + } + function updateOffscreenComponent(current, workInProgress, renderLanes) { + var nextProps = workInProgress.pendingProps, + nextChildren = nextProps.children, + nextIsDetached = 0 !== (workInProgress.stateNode._pendingVisibility & 2), + prevState = null !== current ? current.memoizedState : null; + markRef(current, workInProgress); + if ("hidden" === nextProps.mode || nextIsDetached) { + if (0 !== (workInProgress.flags & 128)) { + nextProps = + null !== prevState ? prevState.baseLanes | renderLanes : renderLanes; + if (null !== current) { + nextChildren = workInProgress.child = current.child; + for (nextIsDetached = 0; null !== nextChildren; ) + (nextIsDetached = + nextIsDetached | nextChildren.lanes | nextChildren.childLanes), + (nextChildren = nextChildren.sibling); + workInProgress.childLanes = nextIsDetached & ~nextProps; + } else (workInProgress.childLanes = 0), (workInProgress.child = null); + return deferHiddenOffscreenComponent( + current, + workInProgress, + nextProps, + renderLanes + ); + } + if (0 !== (renderLanes & 536870912)) + (workInProgress.memoizedState = { baseLanes: 0, cachePool: null }), + null !== current && + pushTransition( + workInProgress, + null !== prevState ? prevState.cachePool : null + ), + null !== prevState + ? pushHiddenContext(workInProgress, prevState) + : reuseHiddenContextOnStack(), + pushOffscreenSuspenseHandler(workInProgress); + else + return ( + (workInProgress.lanes = workInProgress.childLanes = 536870912), + deferHiddenOffscreenComponent( + current, + workInProgress, + null !== prevState + ? prevState.baseLanes | renderLanes + : renderLanes, + renderLanes + ) + ); + } else + null !== prevState + ? (pushTransition(workInProgress, prevState.cachePool), + pushHiddenContext(workInProgress, prevState), + reuseSuspenseHandlerOnStack(workInProgress), + (workInProgress.memoizedState = null)) + : (null !== current && pushTransition(workInProgress, null), + reuseHiddenContextOnStack(), + reuseSuspenseHandlerOnStack(workInProgress)); + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; + } + function deferHiddenOffscreenComponent( + current, + workInProgress, + nextBaseLanes, + renderLanes + ) { + var JSCompiler_inline_result = peekCacheFromPool(); + JSCompiler_inline_result = + null === JSCompiler_inline_result + ? null + : { + parent: isPrimaryRenderer + ? CacheContext._currentValue + : CacheContext._currentValue2, + pool: JSCompiler_inline_result + }; + workInProgress.memoizedState = { + baseLanes: nextBaseLanes, + cachePool: JSCompiler_inline_result + }; + null !== current && pushTransition(workInProgress, null); + reuseHiddenContextOnStack(); + pushOffscreenSuspenseHandler(workInProgress); + null !== current && + propagateParentContextChanges(current, workInProgress, renderLanes, !0); + return null; + } + function markRef(current, workInProgress) { + var ref = workInProgress.ref; + if (null === ref) + null !== current && + null !== current.ref && + (workInProgress.flags |= 2097664); + else { + if ("function" !== typeof ref && "object" !== typeof ref) + throw Error(formatProdErrorMessage(284)); + if (null === current || current.ref !== ref) + workInProgress.flags |= 2097664; + } + } + function updateFunctionComponent( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) { + prepareToReadContext(workInProgress); + Component = renderWithHooks( + current, + workInProgress, + Component, + nextProps, + void 0, + renderLanes + ); + nextProps = checkDidRenderIdHook(); + if (null !== current && !didReceiveUpdate) + return ( + bailoutHooks(current, workInProgress, renderLanes), + bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + ); + isHydrating && nextProps && pushMaterializedTreeId(workInProgress); + workInProgress.flags |= 1; + reconcileChildren(current, workInProgress, Component, renderLanes); + return workInProgress.child; + } + function replayFunctionComponent( + current, + workInProgress, + nextProps, + Component, + secondArg, + renderLanes + ) { + prepareToReadContext(workInProgress); + workInProgress.updateQueue = null; + nextProps = renderWithHooksAgain( + workInProgress, + Component, + nextProps, + secondArg + ); + finishRenderingHooks(current); + Component = checkDidRenderIdHook(); + if (null !== current && !didReceiveUpdate) + return ( + bailoutHooks(current, workInProgress, renderLanes), + bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) + ); + isHydrating && Component && pushMaterializedTreeId(workInProgress); + workInProgress.flags |= 1; + reconcileChildren(current, workInProgress, nextProps, renderLanes); + return workInProgress.child; + } + function updateClassComponent( + current, + workInProgress, + Component, + nextProps, + renderLanes + ) { + prepareToReadContext(workInProgress); + if (null === workInProgress.stateNode) { + var context = emptyContextObject, + contextType = Component.contextType; + "object" === typeof contextType && + null !== contextType && + (context = readContext(contextType)); + context = new Component(nextProps, context); + workInProgress.memoizedState = + null !== context.state && void 0 !== context.state + ? context.state + : null; + context.updater = classComponentUpdater; + workInProgress.stateNode = context; + context._reactInternals = workInProgress; + context = workInProgress.stateNode; + context.props = nextProps; + context.state = workInProgress.memoizedState; + context.refs = {}; + initializeUpdateQueue(workInProgress); + contextType = Component.contextType; + context.context = + "object" === typeof contextType && null !== contextType + ? readContext(contextType) + : emptyContextObject; + context.state = workInProgress.memoizedState; + contextType = Component.getDerivedStateFromProps; + "function" === typeof contextType && + (applyDerivedStateFromProps( + workInProgress, + Component, + contextType, + nextProps + ), + (context.state = workInProgress.memoizedState)); + "function" === typeof Component.getDerivedStateFromProps || + "function" === typeof context.getSnapshotBeforeUpdate || + ("function" !== typeof context.UNSAFE_componentWillMount && + "function" !== typeof context.componentWillMount) || + ((contextType = context.state), + "function" === typeof context.componentWillMount && + context.componentWillMount(), + "function" === typeof context.UNSAFE_componentWillMount && + context.UNSAFE_componentWillMount(), + contextType !== context.state && + classComponentUpdater.enqueueReplaceState( + context, + context.state, + null + ), + processUpdateQueue(workInProgress, nextProps, context, renderLanes), + suspendIfUpdateReadFromEntangledAsyncAction(), + (context.state = workInProgress.memoizedState)); + "function" === typeof context.componentDidMount && + (workInProgress.flags |= 4194308); + nextProps = !0; + } else if (null === current) { + context = workInProgress.stateNode; + var unresolvedOldProps = workInProgress.memoizedProps, + oldProps = resolveClassComponentProps(Component, unresolvedOldProps); + context.props = oldProps; + var oldContext = context.context, + contextType$jscomp$0 = Component.contextType; + contextType = emptyContextObject; + "object" === typeof contextType$jscomp$0 && + null !== contextType$jscomp$0 && + (contextType = readContext(contextType$jscomp$0)); + var getDerivedStateFromProps = Component.getDerivedStateFromProps; + contextType$jscomp$0 = + "function" === typeof getDerivedStateFromProps || + "function" === typeof context.getSnapshotBeforeUpdate; + unresolvedOldProps = workInProgress.pendingProps !== unresolvedOldProps; + contextType$jscomp$0 || + ("function" !== typeof context.UNSAFE_componentWillReceiveProps && + "function" !== typeof context.componentWillReceiveProps) || + ((unresolvedOldProps || oldContext !== contextType) && + callComponentWillReceiveProps( + workInProgress, + context, + nextProps, + contextType + )); + hasForceUpdate = !1; + var oldState = workInProgress.memoizedState; + context.state = oldState; + processUpdateQueue(workInProgress, nextProps, context, renderLanes); + suspendIfUpdateReadFromEntangledAsyncAction(); + oldContext = workInProgress.memoizedState; + unresolvedOldProps || oldState !== oldContext || hasForceUpdate + ? ("function" === typeof getDerivedStateFromProps && + (applyDerivedStateFromProps( + workInProgress, + Component, + getDerivedStateFromProps, + nextProps + ), + (oldContext = workInProgress.memoizedState)), + (oldProps = + hasForceUpdate || + checkShouldComponentUpdate( + workInProgress, + Component, + oldProps, + nextProps, + oldState, + oldContext, + contextType + )) + ? (contextType$jscomp$0 || + ("function" !== typeof context.UNSAFE_componentWillMount && + "function" !== typeof context.componentWillMount) || + ("function" === typeof context.componentWillMount && + context.componentWillMount(), + "function" === typeof context.UNSAFE_componentWillMount && + context.UNSAFE_componentWillMount()), + "function" === typeof context.componentDidMount && + (workInProgress.flags |= 4194308)) + : ("function" === typeof context.componentDidMount && + (workInProgress.flags |= 4194308), + (workInProgress.memoizedProps = nextProps), + (workInProgress.memoizedState = oldContext)), + (context.props = nextProps), + (context.state = oldContext), + (context.context = contextType), + (nextProps = oldProps)) + : ("function" === typeof context.componentDidMount && + (workInProgress.flags |= 4194308), + (nextProps = !1)); + } else { + context = workInProgress.stateNode; + cloneUpdateQueue(current, workInProgress); + contextType = workInProgress.memoizedProps; + contextType$jscomp$0 = resolveClassComponentProps(Component, contextType); + context.props = contextType$jscomp$0; + getDerivedStateFromProps = workInProgress.pendingProps; + oldState = context.context; + oldContext = Component.contextType; + oldProps = emptyContextObject; + "object" === typeof oldContext && + null !== oldContext && + (oldProps = readContext(oldContext)); + unresolvedOldProps = Component.getDerivedStateFromProps; + (oldContext = + "function" === typeof unresolvedOldProps || + "function" === typeof context.getSnapshotBeforeUpdate) || + ("function" !== typeof context.UNSAFE_componentWillReceiveProps && + "function" !== typeof context.componentWillReceiveProps) || + ((contextType !== getDerivedStateFromProps || oldState !== oldProps) && + callComponentWillReceiveProps( + workInProgress, + context, + nextProps, + oldProps + )); + hasForceUpdate = !1; + oldState = workInProgress.memoizedState; + context.state = oldState; + processUpdateQueue(workInProgress, nextProps, context, renderLanes); + suspendIfUpdateReadFromEntangledAsyncAction(); + var newState = workInProgress.memoizedState; + contextType !== getDerivedStateFromProps || + oldState !== newState || + hasForceUpdate || + (null !== current && + null !== current.dependencies && + checkIfContextChanged(current.dependencies)) + ? ("function" === typeof unresolvedOldProps && + (applyDerivedStateFromProps( + workInProgress, + Component, + unresolvedOldProps, + nextProps + ), + (newState = workInProgress.memoizedState)), + (contextType$jscomp$0 = + hasForceUpdate || + checkShouldComponentUpdate( + workInProgress, + Component, + contextType$jscomp$0, + nextProps, + oldState, + newState, + oldProps + ) || + (null !== current && + null !== current.dependencies && + checkIfContextChanged(current.dependencies))) + ? (oldContext || + ("function" !== typeof context.UNSAFE_componentWillUpdate && + "function" !== typeof context.componentWillUpdate) || + ("function" === typeof context.componentWillUpdate && + context.componentWillUpdate(nextProps, newState, oldProps), + "function" === typeof context.UNSAFE_componentWillUpdate && + context.UNSAFE_componentWillUpdate( + nextProps, + newState, + oldProps + )), + "function" === typeof context.componentDidUpdate && + (workInProgress.flags |= 4), + "function" === typeof context.getSnapshotBeforeUpdate && + (workInProgress.flags |= 1024)) + : ("function" !== typeof context.componentDidUpdate || + (contextType === current.memoizedProps && + oldState === current.memoizedState) || + (workInProgress.flags |= 4), + "function" !== typeof context.getSnapshotBeforeUpdate || + (contextType === current.memoizedProps && + oldState === current.memoizedState) || + (workInProgress.flags |= 1024), + (workInProgress.memoizedProps = nextProps), + (workInProgress.memoizedState = newState)), + (context.props = nextProps), + (context.state = newState), + (context.context = oldProps), + (nextProps = contextType$jscomp$0)) + : ("function" !== typeof context.componentDidUpdate || + (contextType === current.memoizedProps && + oldState === current.memoizedState) || + (workInProgress.flags |= 4), + "function" !== typeof context.getSnapshotBeforeUpdate || + (contextType === current.memoizedProps && + oldState === current.memoizedState) || + (workInProgress.flags |= 1024), + (nextProps = !1)); + } + context = nextProps; + markRef(current, workInProgress); + nextProps = 0 !== (workInProgress.flags & 128); + context || nextProps + ? ((context = workInProgress.stateNode), + (Component = + nextProps && "function" !== typeof Component.getDerivedStateFromError + ? null + : context.render()), + (workInProgress.flags |= 1), + null !== current && nextProps + ? ((workInProgress.child = reconcileChildFibers( + workInProgress, + current.child, + null, + renderLanes + )), + (workInProgress.child = reconcileChildFibers( + workInProgress, + null, + Component, + renderLanes + ))) + : reconcileChildren(current, workInProgress, Component, renderLanes), + (workInProgress.memoizedState = context.state), + (current = workInProgress.child)) + : (current = bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes + )); + return current; + } + function mountHostRootWithoutHydrating( + current, + workInProgress, + nextChildren, + renderLanes + ) { + resetHydrationState(); + workInProgress.flags |= 256; + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; + } + function mountSuspenseOffscreenState(renderLanes) { + return { baseLanes: renderLanes, cachePool: getSuspendedCache() }; + } + function getRemainingWorkInPrimaryTree( + current, + primaryTreeDidDefer, + renderLanes + ) { + current = null !== current ? current.childLanes & ~renderLanes : 0; + primaryTreeDidDefer && (current |= workInProgressDeferredLane); + return current; + } + function updateSuspenseComponent(current, workInProgress, renderLanes) { + var nextProps = workInProgress.pendingProps, + showFallback = !1, + didSuspend = 0 !== (workInProgress.flags & 128), + JSCompiler_temp; + (JSCompiler_temp = didSuspend) || + (JSCompiler_temp = + null !== current && null === current.memoizedState + ? !1 + : 0 !== (suspenseStackCursor.current & 2)); + JSCompiler_temp && ((showFallback = !0), (workInProgress.flags &= -129)); + JSCompiler_temp = 0 !== (workInProgress.flags & 32); + workInProgress.flags &= -33; + if (null === current) { + if (isHydrating) { + showFallback + ? pushPrimaryTreeSuspenseHandler(workInProgress) + : reuseSuspenseHandlerOnStack(workInProgress); + if (isHydrating) { + var nextInstance = nextHydratableInstance, + JSCompiler_temp$jscomp$0; + if ((JSCompiler_temp$jscomp$0 = nextInstance)) + (nextInstance = canHydrateSuspenseInstance( + nextInstance, + rootOrSingletonContext + )), + null !== nextInstance + ? ((workInProgress.memoizedState = { + dehydrated: nextInstance, + treeContext: + null !== treeContextProvider + ? { id: treeContextId, overflow: treeContextOverflow } + : null, + retryLane: 536870912 + }), + (JSCompiler_temp$jscomp$0 = createFiber(18, null, null, 0)), + (JSCompiler_temp$jscomp$0.stateNode = nextInstance), + (JSCompiler_temp$jscomp$0.return = workInProgress), + (workInProgress.child = JSCompiler_temp$jscomp$0), + (hydrationParentFiber = workInProgress), + (nextHydratableInstance = null), + (JSCompiler_temp$jscomp$0 = !0)) + : (JSCompiler_temp$jscomp$0 = !1); + JSCompiler_temp$jscomp$0 || throwOnHydrationMismatch(workInProgress); + } + nextInstance = workInProgress.memoizedState; + if ( + null !== nextInstance && + ((nextInstance = nextInstance.dehydrated), null !== nextInstance) + ) + return ( + isSuspenseInstanceFallback(nextInstance) + ? (workInProgress.lanes = 16) + : (workInProgress.lanes = 536870912), + null + ); + popSuspenseHandler(workInProgress); + } + nextInstance = nextProps.children; + nextProps = nextProps.fallback; + if (showFallback) + return ( + reuseSuspenseHandlerOnStack(workInProgress), + (showFallback = workInProgress.mode), + (nextInstance = mountWorkInProgressOffscreenFiber( + { mode: "hidden", children: nextInstance }, + showFallback + )), + (nextProps = createFiberFromFragment( + nextProps, + showFallback, + renderLanes, + null + )), + (nextInstance.return = workInProgress), + (nextProps.return = workInProgress), + (nextInstance.sibling = nextProps), + (workInProgress.child = nextInstance), + (showFallback = workInProgress.child), + (showFallback.memoizedState = + mountSuspenseOffscreenState(renderLanes)), + (showFallback.childLanes = getRemainingWorkInPrimaryTree( + current, + JSCompiler_temp, + renderLanes + )), + (workInProgress.memoizedState = SUSPENDED_MARKER), + nextProps + ); + pushPrimaryTreeSuspenseHandler(workInProgress); + return mountSuspensePrimaryChildren(workInProgress, nextInstance); + } + JSCompiler_temp$jscomp$0 = current.memoizedState; + if ( + null !== JSCompiler_temp$jscomp$0 && + ((nextInstance = JSCompiler_temp$jscomp$0.dehydrated), + null !== nextInstance) + ) { + if (didSuspend) + workInProgress.flags & 256 + ? (pushPrimaryTreeSuspenseHandler(workInProgress), + (workInProgress.flags &= -257), + (workInProgress = retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ))) + : null !== workInProgress.memoizedState + ? (reuseSuspenseHandlerOnStack(workInProgress), + (workInProgress.child = current.child), + (workInProgress.flags |= 128), + (workInProgress = null)) + : (reuseSuspenseHandlerOnStack(workInProgress), + (showFallback = nextProps.fallback), + (nextInstance = workInProgress.mode), + (nextProps = mountWorkInProgressOffscreenFiber( + { mode: "visible", children: nextProps.children }, + nextInstance + )), + (showFallback = createFiberFromFragment( + showFallback, + nextInstance, + renderLanes, + null + )), + (showFallback.flags |= 2), + (nextProps.return = workInProgress), + (showFallback.return = workInProgress), + (nextProps.sibling = showFallback), + (workInProgress.child = nextProps), + reconcileChildFibers( + workInProgress, + current.child, + null, + renderLanes + ), + (nextProps = workInProgress.child), + (nextProps.memoizedState = + mountSuspenseOffscreenState(renderLanes)), + (nextProps.childLanes = getRemainingWorkInPrimaryTree( + current, + JSCompiler_temp, + renderLanes + )), + (workInProgress.memoizedState = SUSPENDED_MARKER), + (workInProgress = showFallback)); + else if ( + (pushPrimaryTreeSuspenseHandler(workInProgress), + isSuspenseInstanceFallback(nextInstance)) + ) + (JSCompiler_temp = + getSuspenseInstanceFallbackErrorDetails(nextInstance).digest), + (nextProps = Error(formatProdErrorMessage(419))), + (nextProps.stack = ""), + (nextProps.digest = JSCompiler_temp), + queueHydrationError({ value: nextProps, source: null, stack: null }), + (workInProgress = retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + )); + else if ( + (didReceiveUpdate || + propagateParentContextChanges( + current, + workInProgress, + renderLanes, + !1 + ), + (JSCompiler_temp = 0 !== (renderLanes & current.childLanes)), + didReceiveUpdate || JSCompiler_temp) + ) { + JSCompiler_temp = workInProgressRoot; + if (null !== JSCompiler_temp) { + nextProps = renderLanes & -renderLanes; + if (0 !== (nextProps & 42)) nextProps = 1; + else + switch (nextProps) { + case 2: + nextProps = 1; + break; + case 8: + nextProps = 4; + break; + case 32: + nextProps = 16; + break; + case 128: + case 256: + case 512: + case 1024: + case 2048: + case 4096: + case 8192: + case 16384: + case 32768: + case 65536: + case 131072: + case 262144: + case 524288: + case 1048576: + case 2097152: + case 4194304: + case 8388608: + case 16777216: + case 33554432: + nextProps = 64; + break; + case 268435456: + nextProps = 134217728; + break; + default: + nextProps = 0; + } + nextProps = + 0 !== (nextProps & (JSCompiler_temp.suspendedLanes | renderLanes)) + ? 0 + : nextProps; + if ( + 0 !== nextProps && + nextProps !== JSCompiler_temp$jscomp$0.retryLane + ) + throw ( + ((JSCompiler_temp$jscomp$0.retryLane = nextProps), + enqueueConcurrentRenderForLane(current, nextProps), + scheduleUpdateOnFiber(JSCompiler_temp, current, nextProps), + SelectiveHydrationException) + ); + } + isSuspenseInstancePending(nextInstance) || + renderDidSuspendDelayIfPossible(); + workInProgress = retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ); + } else + isSuspenseInstancePending(nextInstance) + ? ((workInProgress.flags |= 128), + (workInProgress.child = current.child), + (workInProgress = retryDehydratedSuspenseBoundary.bind( + null, + current + )), + registerSuspenseInstanceRetry(nextInstance, workInProgress), + (workInProgress = null)) + : ((current = JSCompiler_temp$jscomp$0.treeContext), + supportsHydration && + ((nextHydratableInstance = + getFirstHydratableChildWithinSuspenseInstance(nextInstance)), + (hydrationParentFiber = workInProgress), + (isHydrating = !0), + (hydrationErrors = null), + (rootOrSingletonContext = !1), + null !== current && + ((idStack[idStackIndex++] = treeContextId), + (idStack[idStackIndex++] = treeContextOverflow), + (idStack[idStackIndex++] = treeContextProvider), + (treeContextId = current.id), + (treeContextOverflow = current.overflow), + (treeContextProvider = workInProgress))), + (workInProgress = mountSuspensePrimaryChildren( + workInProgress, + nextProps.children + )), + (workInProgress.flags |= 4096)); + return workInProgress; + } + if (showFallback) + return ( + reuseSuspenseHandlerOnStack(workInProgress), + (showFallback = nextProps.fallback), + (nextInstance = workInProgress.mode), + (JSCompiler_temp$jscomp$0 = current.child), + (didSuspend = JSCompiler_temp$jscomp$0.sibling), + (nextProps = createWorkInProgress(JSCompiler_temp$jscomp$0, { + mode: "hidden", + children: nextProps.children + })), + (nextProps.subtreeFlags = + JSCompiler_temp$jscomp$0.subtreeFlags & 31457280), + null !== didSuspend + ? (showFallback = createWorkInProgress(didSuspend, showFallback)) + : ((showFallback = createFiberFromFragment( + showFallback, + nextInstance, + renderLanes, + null + )), + (showFallback.flags |= 2)), + (showFallback.return = workInProgress), + (nextProps.return = workInProgress), + (nextProps.sibling = showFallback), + (workInProgress.child = nextProps), + (nextProps = showFallback), + (showFallback = workInProgress.child), + (nextInstance = current.child.memoizedState), + null === nextInstance + ? (nextInstance = mountSuspenseOffscreenState(renderLanes)) + : ((JSCompiler_temp$jscomp$0 = nextInstance.cachePool), + null !== JSCompiler_temp$jscomp$0 + ? ((didSuspend = isPrimaryRenderer + ? CacheContext._currentValue + : CacheContext._currentValue2), + (JSCompiler_temp$jscomp$0 = + JSCompiler_temp$jscomp$0.parent !== didSuspend + ? { parent: didSuspend, pool: didSuspend } + : JSCompiler_temp$jscomp$0)) + : (JSCompiler_temp$jscomp$0 = getSuspendedCache()), + (nextInstance = { + baseLanes: nextInstance.baseLanes | renderLanes, + cachePool: JSCompiler_temp$jscomp$0 + })), + (showFallback.memoizedState = nextInstance), + (showFallback.childLanes = getRemainingWorkInPrimaryTree( + current, + JSCompiler_temp, + renderLanes + )), + (workInProgress.memoizedState = SUSPENDED_MARKER), + nextProps + ); + pushPrimaryTreeSuspenseHandler(workInProgress); + renderLanes = current.child; + current = renderLanes.sibling; + renderLanes = createWorkInProgress(renderLanes, { + mode: "visible", + children: nextProps.children + }); + renderLanes.return = workInProgress; + renderLanes.sibling = null; + null !== current && + ((JSCompiler_temp = workInProgress.deletions), + null === JSCompiler_temp + ? ((workInProgress.deletions = [current]), (workInProgress.flags |= 16)) + : JSCompiler_temp.push(current)); + workInProgress.child = renderLanes; + workInProgress.memoizedState = null; + return renderLanes; + } + function mountSuspensePrimaryChildren(workInProgress, primaryChildren) { + primaryChildren = mountWorkInProgressOffscreenFiber( + { mode: "visible", children: primaryChildren }, + workInProgress.mode + ); + primaryChildren.return = workInProgress; + return (workInProgress.child = primaryChildren); + } + function mountWorkInProgressOffscreenFiber(offscreenProps, mode) { + return createFiberFromOffscreen(offscreenProps, mode, 0, null); + } + function retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes + ) { + reconcileChildFibers(workInProgress, current.child, null, renderLanes); + current = mountSuspensePrimaryChildren( + workInProgress, + workInProgress.pendingProps.children + ); + current.flags |= 2; + workInProgress.memoizedState = null; + return current; + } + function scheduleSuspenseWorkOnFiber(fiber, renderLanes, propagationRoot) { + fiber.lanes |= renderLanes; + var alternate = fiber.alternate; + null !== alternate && (alternate.lanes |= renderLanes); + scheduleContextWorkOnParentPath(fiber.return, renderLanes, propagationRoot); + } + function initSuspenseListRenderState( + workInProgress, + isBackwards, + tail, + lastContentRow, + tailMode + ) { + var renderState = workInProgress.memoizedState; + null === renderState + ? (workInProgress.memoizedState = { + isBackwards: isBackwards, + rendering: null, + renderingStartTime: 0, + last: lastContentRow, + tail: tail, + tailMode: tailMode + }) + : ((renderState.isBackwards = isBackwards), + (renderState.rendering = null), + (renderState.renderingStartTime = 0), + (renderState.last = lastContentRow), + (renderState.tail = tail), + (renderState.tailMode = tailMode)); + } + function updateSuspenseListComponent(current, workInProgress, renderLanes) { + var nextProps = workInProgress.pendingProps, + revealOrder = nextProps.revealOrder, + tailMode = nextProps.tail; + reconcileChildren(current, workInProgress, nextProps.children, renderLanes); + nextProps = suspenseStackCursor.current; + if (0 !== (nextProps & 2)) + (nextProps = (nextProps & 1) | 2), (workInProgress.flags |= 128); + else { + if (null !== current && 0 !== (current.flags & 128)) + a: for (current = workInProgress.child; null !== current; ) { + if (13 === current.tag) + null !== current.memoizedState && + scheduleSuspenseWorkOnFiber(current, renderLanes, workInProgress); + else if (19 === current.tag) + scheduleSuspenseWorkOnFiber(current, renderLanes, workInProgress); + else if (null !== current.child) { + current.child.return = current; + current = current.child; + continue; + } + if (current === workInProgress) break a; + for (; null === current.sibling; ) { + if (null === current.return || current.return === workInProgress) + break a; + current = current.return; + } + current.sibling.return = current.return; + current = current.sibling; + } + nextProps &= 1; + } + push(suspenseStackCursor, nextProps); + switch (revealOrder) { + case "forwards": + renderLanes = workInProgress.child; + for (revealOrder = null; null !== renderLanes; ) + (current = renderLanes.alternate), + null !== current && + null === findFirstSuspended(current) && + (revealOrder = renderLanes), + (renderLanes = renderLanes.sibling); + renderLanes = revealOrder; + null === renderLanes + ? ((revealOrder = workInProgress.child), + (workInProgress.child = null)) + : ((revealOrder = renderLanes.sibling), (renderLanes.sibling = null)); + initSuspenseListRenderState( + workInProgress, + !1, + revealOrder, + renderLanes, + tailMode + ); + break; + case "backwards": + renderLanes = null; + revealOrder = workInProgress.child; + for (workInProgress.child = null; null !== revealOrder; ) { + current = revealOrder.alternate; + if (null !== current && null === findFirstSuspended(current)) { + workInProgress.child = revealOrder; + break; + } + current = revealOrder.sibling; + revealOrder.sibling = renderLanes; + renderLanes = revealOrder; + revealOrder = current; + } + initSuspenseListRenderState( + workInProgress, + !0, + renderLanes, + null, + tailMode + ); + break; + case "together": + initSuspenseListRenderState(workInProgress, !1, null, null, void 0); + break; + default: + workInProgress.memoizedState = null; + } + return workInProgress.child; + } + function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) { + null !== current && (workInProgress.dependencies = current.dependencies); + workInProgressRootSkippedLanes |= workInProgress.lanes; + if (0 === (renderLanes & workInProgress.childLanes)) + if (null !== current) { + if ( + (propagateParentContextChanges( + current, + workInProgress, + renderLanes, + !1 + ), + 0 === (renderLanes & workInProgress.childLanes)) + ) + return null; + } else return null; + if (null !== current && workInProgress.child !== current.child) + throw Error(formatProdErrorMessage(153)); + if (null !== workInProgress.child) { + current = workInProgress.child; + renderLanes = createWorkInProgress(current, current.pendingProps); + workInProgress.child = renderLanes; + for (renderLanes.return = workInProgress; null !== current.sibling; ) + (current = current.sibling), + (renderLanes = renderLanes.sibling = + createWorkInProgress(current, current.pendingProps)), + (renderLanes.return = workInProgress); + renderLanes.sibling = null; + } + return workInProgress.child; + } + function checkScheduledUpdateOrContext(current, renderLanes) { + if (0 !== (current.lanes & renderLanes)) return !0; + current = current.dependencies; + return null !== current && checkIfContextChanged(current) ? !0 : !1; + } + function attemptEarlyBailoutIfNoScheduledUpdate( + current, + workInProgress, + renderLanes + ) { + switch (workInProgress.tag) { + case 3: + pushHostContainer( + workInProgress, + workInProgress.stateNode.containerInfo + ); + pushProvider(workInProgress, CacheContext, current.memoizedState.cache); + resetHydrationState(); + break; + case 27: + case 5: + pushHostContext(workInProgress); + break; + case 4: + pushHostContainer( + workInProgress, + workInProgress.stateNode.containerInfo + ); + break; + case 10: + pushProvider( + workInProgress, + workInProgress.type, + workInProgress.memoizedProps.value + ); + break; + case 13: + var state = workInProgress.memoizedState; + if (null !== state) { + if (null !== state.dehydrated) + return ( + pushPrimaryTreeSuspenseHandler(workInProgress), + (workInProgress.flags |= 128), + null + ); + if (0 !== (renderLanes & workInProgress.child.childLanes)) + return updateSuspenseComponent( + current, + workInProgress, + renderLanes + ); + pushPrimaryTreeSuspenseHandler(workInProgress); + current = bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes + ); + return null !== current ? current.sibling : null; + } + pushPrimaryTreeSuspenseHandler(workInProgress); + break; + case 19: + var didSuspendBefore = 0 !== (current.flags & 128); + state = 0 !== (renderLanes & workInProgress.childLanes); + state || + (propagateParentContextChanges( + current, + workInProgress, + renderLanes, + !1 + ), + (state = 0 !== (renderLanes & workInProgress.childLanes))); + if (didSuspendBefore) { + if (state) + return updateSuspenseListComponent( + current, + workInProgress, + renderLanes + ); + workInProgress.flags |= 128; + } + didSuspendBefore = workInProgress.memoizedState; + null !== didSuspendBefore && + ((didSuspendBefore.rendering = null), + (didSuspendBefore.tail = null), + (didSuspendBefore.lastEffect = null)); + push(suspenseStackCursor, suspenseStackCursor.current); + if (state) break; + else return null; + case 22: + case 23: + return ( + (workInProgress.lanes = 0), + updateOffscreenComponent(current, workInProgress, renderLanes) + ); + case 24: + pushProvider(workInProgress, CacheContext, current.memoizedState.cache); + } + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); + } + function beginWork(current, workInProgress, renderLanes) { + if (null !== current) + if (current.memoizedProps !== workInProgress.pendingProps) + didReceiveUpdate = !0; + else { + if ( + !checkScheduledUpdateOrContext(current, renderLanes) && + 0 === (workInProgress.flags & 128) + ) + return ( + (didReceiveUpdate = !1), + attemptEarlyBailoutIfNoScheduledUpdate( + current, + workInProgress, + renderLanes + ) + ); + didReceiveUpdate = 0 !== (current.flags & 131072) ? !0 : !1; + } + else + (didReceiveUpdate = !1), + isHydrating && + 0 !== (workInProgress.flags & 1048576) && + pushTreeId(workInProgress, treeForkCount, workInProgress.index); + workInProgress.lanes = 0; + switch (workInProgress.tag) { + case 16: + a: { + current = workInProgress.pendingProps; + var lazyComponent = workInProgress.elementType, + init = lazyComponent._init; + lazyComponent = init(lazyComponent._payload); + workInProgress.type = lazyComponent; + if ("function" === typeof lazyComponent) + shouldConstruct(lazyComponent) + ? ((current = resolveClassComponentProps(lazyComponent, current)), + (workInProgress.tag = 1), + (workInProgress = updateClassComponent( + null, + workInProgress, + lazyComponent, + current, + renderLanes + ))) + : ((workInProgress.tag = 0), + (workInProgress = updateFunctionComponent( + null, + workInProgress, + lazyComponent, + current, + renderLanes + ))); + else { + if (void 0 !== lazyComponent && null !== lazyComponent) + if ( + ((init = lazyComponent.$$typeof), + init === REACT_FORWARD_REF_TYPE) + ) { + workInProgress.tag = 11; + workInProgress = updateForwardRef( + null, + workInProgress, + lazyComponent, + current, + renderLanes + ); + break a; + } else if (init === REACT_MEMO_TYPE) { + workInProgress.tag = 14; + workInProgress = updateMemoComponent( + null, + workInProgress, + lazyComponent, + current, + renderLanes + ); + break a; + } + workInProgress = + getComponentNameFromType(lazyComponent) || lazyComponent; + throw Error(formatProdErrorMessage(306, workInProgress, "")); + } + } + return workInProgress; + case 0: + return updateFunctionComponent( + current, + workInProgress, + workInProgress.type, + workInProgress.pendingProps, + renderLanes + ); + case 1: + return ( + (lazyComponent = workInProgress.type), + (init = resolveClassComponentProps( + lazyComponent, + workInProgress.pendingProps + )), + updateClassComponent( + current, + workInProgress, + lazyComponent, + init, + renderLanes + ) + ); + case 3: + a: { + pushHostContainer( + workInProgress, + workInProgress.stateNode.containerInfo + ); + if (null === current) throw Error(formatProdErrorMessage(387)); + var nextProps = workInProgress.pendingProps; + init = workInProgress.memoizedState; + lazyComponent = init.element; + cloneUpdateQueue(current, workInProgress); + processUpdateQueue(workInProgress, nextProps, null, renderLanes); + var nextState = workInProgress.memoizedState; + nextProps = nextState.cache; + pushProvider(workInProgress, CacheContext, nextProps); + nextProps !== init.cache && + propagateContextChanges( + workInProgress, + [CacheContext], + renderLanes, + !0 + ); + suspendIfUpdateReadFromEntangledAsyncAction(); + nextProps = nextState.element; + if (supportsHydration && init.isDehydrated) + if ( + ((init = { + element: nextProps, + isDehydrated: !1, + cache: nextState.cache + }), + (workInProgress.updateQueue.baseState = init), + (workInProgress.memoizedState = init), + workInProgress.flags & 256) + ) { + workInProgress = mountHostRootWithoutHydrating( + current, + workInProgress, + nextProps, + renderLanes + ); + break a; + } else if (nextProps !== lazyComponent) { + lazyComponent = createCapturedValueAtFiber( + Error(formatProdErrorMessage(424)), + workInProgress + ); + queueHydrationError(lazyComponent); + workInProgress = mountHostRootWithoutHydrating( + current, + workInProgress, + nextProps, + renderLanes + ); + break a; + } else + for ( + supportsHydration && + ((nextHydratableInstance = + getFirstHydratableChildWithinContainer( + workInProgress.stateNode.containerInfo + )), + (hydrationParentFiber = workInProgress), + (isHydrating = !0), + (hydrationErrors = null), + (rootOrSingletonContext = !0)), + renderLanes = mountChildFibers( + workInProgress, + null, + nextProps, + renderLanes + ), + workInProgress.child = renderLanes; + renderLanes; + + ) + (renderLanes.flags = (renderLanes.flags & -3) | 4096), + (renderLanes = renderLanes.sibling); + else { + resetHydrationState(); + if (nextProps === lazyComponent) { + workInProgress = bailoutOnAlreadyFinishedWork( + current, + workInProgress, + renderLanes + ); + break a; + } + reconcileChildren(current, workInProgress, nextProps, renderLanes); + } + workInProgress = workInProgress.child; + } + return workInProgress; + case 26: + if (supportsResources) + return ( + markRef(current, workInProgress), + null === current + ? (renderLanes = getResource( + workInProgress.type, + null, + workInProgress.pendingProps, + null + )) + ? (workInProgress.memoizedState = renderLanes) + : isHydrating || + (workInProgress.stateNode = createHoistableInstance( + workInProgress.type, + workInProgress.pendingProps, + rootInstanceStackCursor.current, + workInProgress + )) + : (workInProgress.memoizedState = getResource( + workInProgress.type, + current.memoizedProps, + workInProgress.pendingProps, + current.memoizedState + )), + null + ); + case 27: + if (supportsSingletons) + return ( + pushHostContext(workInProgress), + null === current && + supportsSingletons && + isHydrating && + ((lazyComponent = workInProgress.stateNode = + resolveSingletonInstance( + workInProgress.type, + workInProgress.pendingProps, + rootInstanceStackCursor.current, + contextStackCursor.current, + !1 + )), + (hydrationParentFiber = workInProgress), + (rootOrSingletonContext = !0), + (nextHydratableInstance = + getFirstHydratableChild(lazyComponent))), + (lazyComponent = workInProgress.pendingProps.children), + null !== current || isHydrating + ? reconcileChildren( + current, + workInProgress, + lazyComponent, + renderLanes + ) + : (workInProgress.child = reconcileChildFibers( + workInProgress, + null, + lazyComponent, + renderLanes + )), + markRef(current, workInProgress), + workInProgress.child + ); + case 5: + if (null === current && isHydrating) { + validateHydratableInstance( + workInProgress.type, + workInProgress.pendingProps, + contextStackCursor.current + ); + if ((init = lazyComponent = nextHydratableInstance)) + (lazyComponent = canHydrateInstance( + lazyComponent, + workInProgress.type, + workInProgress.pendingProps, + rootOrSingletonContext + )), + null !== lazyComponent + ? ((workInProgress.stateNode = lazyComponent), + (hydrationParentFiber = workInProgress), + (nextHydratableInstance = + getFirstHydratableChild(lazyComponent)), + (rootOrSingletonContext = !1), + (init = !0)) + : (init = !1); + init || throwOnHydrationMismatch(workInProgress); + } + pushHostContext(workInProgress); + init = workInProgress.type; + nextProps = workInProgress.pendingProps; + nextState = null !== current ? current.memoizedProps : null; + lazyComponent = nextProps.children; + shouldSetTextContent(init, nextProps) + ? (lazyComponent = null) + : null !== nextState && + shouldSetTextContent(init, nextState) && + (workInProgress.flags |= 32); + null !== workInProgress.memoizedState && + ((init = renderWithHooks( + current, + workInProgress, + TransitionAwareHostComponent, + null, + null, + renderLanes + )), + isPrimaryRenderer + ? (HostTransitionContext._currentValue = init) + : (HostTransitionContext._currentValue2 = init)); + markRef(current, workInProgress); + reconcileChildren(current, workInProgress, lazyComponent, renderLanes); + return workInProgress.child; + case 6: + if (null === current && isHydrating) { + validateHydratableTextInstance( + workInProgress.pendingProps, + contextStackCursor.current + ); + if ((current = renderLanes = nextHydratableInstance)) + (renderLanes = canHydrateTextInstance( + renderLanes, + workInProgress.pendingProps, + rootOrSingletonContext + )), + null !== renderLanes + ? ((workInProgress.stateNode = renderLanes), + (hydrationParentFiber = workInProgress), + (nextHydratableInstance = null), + (current = !0)) + : (current = !1); + current || throwOnHydrationMismatch(workInProgress); + } + return null; + case 13: + return updateSuspenseComponent(current, workInProgress, renderLanes); + case 4: + return ( + pushHostContainer( + workInProgress, + workInProgress.stateNode.containerInfo + ), + (lazyComponent = workInProgress.pendingProps), + null === current + ? (workInProgress.child = reconcileChildFibers( + workInProgress, + null, + lazyComponent, + renderLanes + )) + : reconcileChildren( + current, + workInProgress, + lazyComponent, + renderLanes + ), + workInProgress.child + ); + case 11: + return updateForwardRef( + current, + workInProgress, + workInProgress.type, + workInProgress.pendingProps, + renderLanes + ); + case 7: + return ( + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps, + renderLanes + ), + workInProgress.child + ); + case 8: + return ( + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes + ), + workInProgress.child + ); + case 12: + return ( + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes + ), + workInProgress.child + ); + case 10: + return ( + (lazyComponent = workInProgress.pendingProps), + pushProvider( + workInProgress, + workInProgress.type, + lazyComponent.value + ), + reconcileChildren( + current, + workInProgress, + lazyComponent.children, + renderLanes + ), + workInProgress.child + ); + case 9: + return ( + (init = workInProgress.type._context), + (lazyComponent = workInProgress.pendingProps.children), + prepareToReadContext(workInProgress), + (init = readContext(init)), + (lazyComponent = lazyComponent(init)), + (workInProgress.flags |= 1), + reconcileChildren( + current, + workInProgress, + lazyComponent, + renderLanes + ), + workInProgress.child + ); + case 14: + return updateMemoComponent( + current, + workInProgress, + workInProgress.type, + workInProgress.pendingProps, + renderLanes + ); + case 15: + return updateSimpleMemoComponent( + current, + workInProgress, + workInProgress.type, + workInProgress.pendingProps, + renderLanes + ); + case 19: + return updateSuspenseListComponent( + current, + workInProgress, + renderLanes + ); + case 22: + return updateOffscreenComponent(current, workInProgress, renderLanes); + case 24: + return ( + prepareToReadContext(workInProgress), + (lazyComponent = readContext(CacheContext)), + null === current + ? ((init = peekCacheFromPool()), + null === init && + ((init = workInProgressRoot), + (nextProps = createCache()), + (init.pooledCache = nextProps), + nextProps.refCount++, + null !== nextProps && (init.pooledCacheLanes |= renderLanes), + (init = nextProps)), + (workInProgress.memoizedState = { + parent: lazyComponent, + cache: init + }), + initializeUpdateQueue(workInProgress), + pushProvider(workInProgress, CacheContext, init)) + : (0 !== (current.lanes & renderLanes) && + (cloneUpdateQueue(current, workInProgress), + processUpdateQueue(workInProgress, null, null, renderLanes), + suspendIfUpdateReadFromEntangledAsyncAction()), + (init = current.memoizedState), + (nextProps = workInProgress.memoizedState), + init.parent !== lazyComponent + ? ((init = { parent: lazyComponent, cache: lazyComponent }), + (workInProgress.memoizedState = init), + 0 === workInProgress.lanes && + (workInProgress.memoizedState = + workInProgress.updateQueue.baseState = + init), + pushProvider(workInProgress, CacheContext, lazyComponent)) + : ((lazyComponent = nextProps.cache), + pushProvider(workInProgress, CacheContext, lazyComponent), + lazyComponent !== init.cache && + propagateContextChanges( + workInProgress, + [CacheContext], + renderLanes, + !0 + ))), + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes + ), + workInProgress.child + ); + case 29: + throw workInProgress.pendingProps; + } + throw Error(formatProdErrorMessage(156, workInProgress.tag)); + } + function pushProvider(providerFiber, context, nextValue) { + isPrimaryRenderer + ? (push(valueCursor, context._currentValue), + (context._currentValue = nextValue)) + : (push(valueCursor, context._currentValue2), + (context._currentValue2 = nextValue)); + } + function popProvider(context) { + var currentValue = valueCursor.current; + isPrimaryRenderer + ? (context._currentValue = currentValue) + : (context._currentValue2 = currentValue); + pop(valueCursor); + } + function scheduleContextWorkOnParentPath( + parent, + renderLanes, + propagationRoot + ) { + for (; null !== parent; ) { + var alternate = parent.alternate; + (parent.childLanes & renderLanes) !== renderLanes + ? ((parent.childLanes |= renderLanes), + null !== alternate && (alternate.childLanes |= renderLanes)) + : null !== alternate && + (alternate.childLanes & renderLanes) !== renderLanes && + (alternate.childLanes |= renderLanes); + if (parent === propagationRoot) break; + parent = parent.return; + } + } + function propagateContextChanges( + workInProgress, + contexts, + renderLanes, + forcePropagateEntireTree + ) { + var fiber = workInProgress.child; + null !== fiber && (fiber.return = workInProgress); + for (; null !== fiber; ) { + var list = fiber.dependencies; + if (null !== list) { + var nextFiber = fiber.child; + list = list.firstContext; + a: for (; null !== list; ) { + var dependency = list; + list = fiber; + for (var i = 0; i < contexts.length; i++) + if (dependency.context === contexts[i]) { + list.lanes |= renderLanes; + dependency = list.alternate; + null !== dependency && (dependency.lanes |= renderLanes); + scheduleContextWorkOnParentPath( + list.return, + renderLanes, + workInProgress + ); + forcePropagateEntireTree || (nextFiber = null); + break a; + } + list = dependency.next; + } + } else if (18 === fiber.tag) { + nextFiber = fiber.return; + if (null === nextFiber) throw Error(formatProdErrorMessage(341)); + nextFiber.lanes |= renderLanes; + list = nextFiber.alternate; + null !== list && (list.lanes |= renderLanes); + scheduleContextWorkOnParentPath(nextFiber, renderLanes, workInProgress); + nextFiber = null; + } else nextFiber = fiber.child; + if (null !== nextFiber) nextFiber.return = fiber; + else + for (nextFiber = fiber; null !== nextFiber; ) { + if (nextFiber === workInProgress) { + nextFiber = null; + break; + } + fiber = nextFiber.sibling; + if (null !== fiber) { + fiber.return = nextFiber.return; + nextFiber = fiber; + break; + } + nextFiber = nextFiber.return; + } + fiber = nextFiber; + } + } + function propagateParentContextChanges( + current, + workInProgress, + renderLanes, + forcePropagateEntireTree + ) { + current = null; + for ( + var parent = workInProgress, isInsidePropagationBailout = !1; + null !== parent; + + ) { + if (!isInsidePropagationBailout) + if (0 !== (parent.flags & 524288)) isInsidePropagationBailout = !0; + else if (0 !== (parent.flags & 262144)) break; + if (10 === parent.tag) { + var currentParent = parent.alternate; + if (null === currentParent) throw Error(formatProdErrorMessage(387)); + currentParent = currentParent.memoizedProps; + if (null !== currentParent) { + var context = parent.type; + objectIs(parent.pendingProps.value, currentParent.value) || + (null !== current ? current.push(context) : (current = [context])); + } + } else if (parent === hostTransitionProviderCursor.current) { + currentParent = parent.alternate; + if (null === currentParent) throw Error(formatProdErrorMessage(387)); + currentParent.memoizedState.memoizedState !== + parent.memoizedState.memoizedState && + (null !== current + ? current.push(HostTransitionContext) + : (current = [HostTransitionContext])); + } + parent = parent.return; + } + null !== current && + propagateContextChanges( + workInProgress, + current, + renderLanes, + forcePropagateEntireTree + ); + workInProgress.flags |= 262144; + } + function checkIfContextChanged(currentDependencies) { + for ( + currentDependencies = currentDependencies.firstContext; + null !== currentDependencies; + + ) { + var context = currentDependencies.context; + if ( + !objectIs( + isPrimaryRenderer ? context._currentValue : context._currentValue2, + currentDependencies.memoizedValue + ) + ) + return !0; + currentDependencies = currentDependencies.next; + } + return !1; + } + function prepareToReadContext(workInProgress) { + currentlyRenderingFiber = workInProgress; + lastContextDependency = null; + workInProgress = workInProgress.dependencies; + null !== workInProgress && (workInProgress.firstContext = null); + } + function readContext(context) { + return readContextForConsumer(currentlyRenderingFiber, context); + } + function readContextDuringReconciliation(consumer, context) { + null === currentlyRenderingFiber && prepareToReadContext(consumer); + return readContextForConsumer(consumer, context); + } + function readContextForConsumer(consumer, context) { + var value = isPrimaryRenderer + ? context._currentValue + : context._currentValue2; + context = { context: context, memoizedValue: value, next: null }; + if (null === lastContextDependency) { + if (null === consumer) throw Error(formatProdErrorMessage(308)); + lastContextDependency = context; + consumer.dependencies = { lanes: 0, firstContext: context }; + consumer.flags |= 524288; + } else lastContextDependency = lastContextDependency.next = context; + return value; + } + function createCache() { + return { + controller: new AbortControllerLocal(), + data: new Map(), + refCount: 0 + }; + } + function releaseCache(cache) { + cache.refCount--; + 0 === cache.refCount && + scheduleCallback$1(NormalPriority, function () { + cache.controller.abort(); + }); + } + function peekCacheFromPool() { + var cacheResumedFromPreviousRender = resumedCache.current; + return null !== cacheResumedFromPreviousRender + ? cacheResumedFromPreviousRender + : workInProgressRoot.pooledCache; + } + function pushTransition(offscreenWorkInProgress, prevCachePool) { + null === prevCachePool + ? push(resumedCache, resumedCache.current) + : push(resumedCache, prevCachePool.pool); + } + function getSuspendedCache() { + var cacheFromPool = peekCacheFromPool(); + return null === cacheFromPool + ? null + : { + parent: isPrimaryRenderer + ? CacheContext._currentValue + : CacheContext._currentValue2, + pool: cacheFromPool + }; + } + function markUpdate(workInProgress) { + workInProgress.flags |= 4; + } + function doesRequireClone(current, completedWork) { + if (null !== current && current.child === completedWork.child) return !1; + if (0 !== (completedWork.flags & 16)) return !0; + for (current = completedWork.child; null !== current; ) { + if (0 !== (current.flags & 13878) || 0 !== (current.subtreeFlags & 13878)) + return !0; + current = current.sibling; + } + return !1; + } + function appendAllChildren( + parent, + workInProgress, + needsVisibilityToggle, + isHidden + ) { + if (supportsMutation) + for ( + needsVisibilityToggle = workInProgress.child; + null !== needsVisibilityToggle; + + ) { + if (5 === needsVisibilityToggle.tag || 6 === needsVisibilityToggle.tag) + appendInitialChild(parent, needsVisibilityToggle.stateNode); + else if ( + !( + 4 === needsVisibilityToggle.tag || + (supportsSingletons && 27 === needsVisibilityToggle.tag) + ) && + null !== needsVisibilityToggle.child + ) { + needsVisibilityToggle.child.return = needsVisibilityToggle; + needsVisibilityToggle = needsVisibilityToggle.child; + continue; + } + if (needsVisibilityToggle === workInProgress) break; + for (; null === needsVisibilityToggle.sibling; ) { + if ( + null === needsVisibilityToggle.return || + needsVisibilityToggle.return === workInProgress + ) + return; + needsVisibilityToggle = needsVisibilityToggle.return; + } + needsVisibilityToggle.sibling.return = needsVisibilityToggle.return; + needsVisibilityToggle = needsVisibilityToggle.sibling; + } + else if (supportsPersistence) + for (var node$91 = workInProgress.child; null !== node$91; ) { + if (5 === node$91.tag) { + var instance = node$91.stateNode; + needsVisibilityToggle && + isHidden && + (instance = cloneHiddenInstance( + instance, + node$91.type, + node$91.memoizedProps + )); + appendInitialChild(parent, instance); + } else if (6 === node$91.tag) + (instance = node$91.stateNode), + needsVisibilityToggle && + isHidden && + (instance = cloneHiddenTextInstance( + instance, + node$91.memoizedProps + )), + appendInitialChild(parent, instance); + else if (4 !== node$91.tag) + if (22 === node$91.tag && null !== node$91.memoizedState) + (instance = node$91.child), + null !== instance && (instance.return = node$91), + appendAllChildren(parent, node$91, !0, !0); + else if (null !== node$91.child) { + node$91.child.return = node$91; + node$91 = node$91.child; + continue; + } + if (node$91 === workInProgress) break; + for (; null === node$91.sibling; ) { + if (null === node$91.return || node$91.return === workInProgress) + return; + node$91 = node$91.return; + } + node$91.sibling.return = node$91.return; + node$91 = node$91.sibling; + } + } + function appendAllChildrenToContainer( + containerChildSet, + workInProgress, + needsVisibilityToggle, + isHidden + ) { + if (supportsPersistence) + for (var node = workInProgress.child; null !== node; ) { + if (5 === node.tag) { + var instance = node.stateNode; + needsVisibilityToggle && + isHidden && + (instance = cloneHiddenInstance( + instance, + node.type, + node.memoizedProps + )); + appendChildToContainerChildSet(containerChildSet, instance); + } else if (6 === node.tag) + (instance = node.stateNode), + needsVisibilityToggle && + isHidden && + (instance = cloneHiddenTextInstance( + instance, + node.memoizedProps + )), + appendChildToContainerChildSet(containerChildSet, instance); + else if (4 !== node.tag) + if (22 === node.tag && null !== node.memoizedState) + (instance = node.child), + null !== instance && (instance.return = node), + appendAllChildrenToContainer( + containerChildSet, + node, + !( + null !== node.memoizedProps && + "manual" === node.memoizedProps.mode + ), + !0 + ); + else if (null !== node.child) { + node.child.return = node; + node = node.child; + continue; + } + if (node === workInProgress) break; + for (; null === node.sibling; ) { + if (null === node.return || node.return === workInProgress) return; + node = node.return; + } + node.sibling.return = node.return; + node = node.sibling; + } + } + function updateHostContainer(current, workInProgress) { + if (supportsPersistence && doesRequireClone(current, workInProgress)) { + current = workInProgress.stateNode; + var container = current.containerInfo, + newChildSet = createContainerChildSet(); + appendAllChildrenToContainer(newChildSet, workInProgress, !1, !1); + current.pendingChildren = newChildSet; + markUpdate(workInProgress); + finalizeContainerChildren(container, newChildSet); + } + } + function updateHostComponent(current, workInProgress, type, newProps) { + if (supportsMutation) + current.memoizedProps !== newProps && markUpdate(workInProgress); + else if (supportsPersistence) { + var currentInstance = current.stateNode, + oldProps$94 = current.memoizedProps; + if ( + (current = doesRequireClone(current, workInProgress)) || + oldProps$94 !== newProps + ) { + var currentHostContext = contextStackCursor.current; + oldProps$94 = cloneInstance( + currentInstance, + type, + oldProps$94, + newProps, + !current, + null + ); + oldProps$94 === currentInstance + ? (workInProgress.stateNode = currentInstance) + : (finalizeInitialChildren( + oldProps$94, + type, + newProps, + currentHostContext + ) && markUpdate(workInProgress), + (workInProgress.stateNode = oldProps$94), + current + ? appendAllChildren(oldProps$94, workInProgress, !1, !1) + : markUpdate(workInProgress)); + } else workInProgress.stateNode = currentInstance; + } + } + function preloadInstanceAndSuspendIfNeeded(workInProgress, type, props) { + if (maySuspendCommit(type, props)) { + if (((workInProgress.flags |= 16777216), !preloadInstance(type, props))) + if (shouldRemainOnPreviousScreen()) workInProgress.flags |= 8192; + else + throw ( + ((suspendedThenable = noopSuspenseyCommitThenable), + SuspenseyCommitException) + ); + } else workInProgress.flags &= -16777217; + } + function preloadResourceAndSuspendIfNeeded(workInProgress, resource) { + if (mayResourceSuspendCommit(resource)) { + if (((workInProgress.flags |= 16777216), !preloadResource(resource))) + if (shouldRemainOnPreviousScreen()) workInProgress.flags |= 8192; + else + throw ( + ((suspendedThenable = noopSuspenseyCommitThenable), + SuspenseyCommitException) + ); + } else workInProgress.flags &= -16777217; + } + function scheduleRetryEffect(workInProgress, retryQueue) { + null !== retryQueue && (workInProgress.flags |= 4); + workInProgress.flags & 16384 && + ((retryQueue = + 22 !== workInProgress.tag ? claimNextRetryLane() : 536870912), + (workInProgress.lanes |= retryQueue), + (workInProgressSuspendedRetryLanes |= retryQueue)); + } + function cutOffTailIfNeeded(renderState, hasRenderedATailFallback) { + if (!isHydrating) + switch (renderState.tailMode) { + case "hidden": + hasRenderedATailFallback = renderState.tail; + for (var lastTailNode = null; null !== hasRenderedATailFallback; ) + null !== hasRenderedATailFallback.alternate && + (lastTailNode = hasRenderedATailFallback), + (hasRenderedATailFallback = hasRenderedATailFallback.sibling); + null === lastTailNode + ? (renderState.tail = null) + : (lastTailNode.sibling = null); + break; + case "collapsed": + lastTailNode = renderState.tail; + for (var lastTailNode$96 = null; null !== lastTailNode; ) + null !== lastTailNode.alternate && (lastTailNode$96 = lastTailNode), + (lastTailNode = lastTailNode.sibling); + null === lastTailNode$96 + ? hasRenderedATailFallback || null === renderState.tail + ? (renderState.tail = null) + : (renderState.tail.sibling = null) + : (lastTailNode$96.sibling = null); + } + } + function bubbleProperties(completedWork) { + var didBailout = + null !== completedWork.alternate && + completedWork.alternate.child === completedWork.child, + newChildLanes = 0, + subtreeFlags = 0; + if (didBailout) + for (var child$97 = completedWork.child; null !== child$97; ) + (newChildLanes |= child$97.lanes | child$97.childLanes), + (subtreeFlags |= child$97.subtreeFlags & 31457280), + (subtreeFlags |= child$97.flags & 31457280), + (child$97.return = completedWork), + (child$97 = child$97.sibling); + else + for (child$97 = completedWork.child; null !== child$97; ) + (newChildLanes |= child$97.lanes | child$97.childLanes), + (subtreeFlags |= child$97.subtreeFlags), + (subtreeFlags |= child$97.flags), + (child$97.return = completedWork), + (child$97 = child$97.sibling); + completedWork.subtreeFlags |= subtreeFlags; + completedWork.childLanes = newChildLanes; + return didBailout; + } + function completeWork(current, workInProgress, renderLanes) { + var newProps = workInProgress.pendingProps; + popTreeContext(workInProgress); + switch (workInProgress.tag) { + case 16: + case 15: + case 0: + case 11: + case 7: + case 8: + case 12: + case 9: + case 14: + return bubbleProperties(workInProgress), null; + case 1: + return bubbleProperties(workInProgress), null; + case 3: + renderLanes = workInProgress.stateNode; + newProps = null; + null !== current && (newProps = current.memoizedState.cache); + workInProgress.memoizedState.cache !== newProps && + (workInProgress.flags |= 2048); + popProvider(CacheContext); + popHostContainer(); + renderLanes.pendingContext && + ((renderLanes.context = renderLanes.pendingContext), + (renderLanes.pendingContext = null)); + if (null === current || null === current.child) + popHydrationState(workInProgress) + ? markUpdate(workInProgress) + : null === current || + (current.memoizedState.isDehydrated && + 0 === (workInProgress.flags & 256)) || + ((workInProgress.flags |= 1024), + null !== hydrationErrors && + (queueRecoverableErrors(hydrationErrors), + (hydrationErrors = null))); + updateHostContainer(current, workInProgress); + bubbleProperties(workInProgress); + return null; + case 26: + if (supportsResources) { + renderLanes = workInProgress.type; + var nextResource = workInProgress.memoizedState; + null === current + ? (markUpdate(workInProgress), + null !== nextResource + ? (bubbleProperties(workInProgress), + preloadResourceAndSuspendIfNeeded( + workInProgress, + nextResource + )) + : (bubbleProperties(workInProgress), + preloadInstanceAndSuspendIfNeeded( + workInProgress, + renderLanes, + newProps + ))) + : nextResource + ? nextResource !== current.memoizedState + ? (markUpdate(workInProgress), + bubbleProperties(workInProgress), + preloadResourceAndSuspendIfNeeded( + workInProgress, + nextResource + )) + : (bubbleProperties(workInProgress), + (workInProgress.flags &= -16777217)) + : (supportsMutation + ? current.memoizedProps !== newProps && + markUpdate(workInProgress) + : updateHostComponent( + current, + workInProgress, + renderLanes, + newProps + ), + bubbleProperties(workInProgress), + preloadInstanceAndSuspendIfNeeded( + workInProgress, + renderLanes, + newProps + )); + return null; + } + case 27: + if (supportsSingletons) { + popHostContext(workInProgress); + renderLanes = rootInstanceStackCursor.current; + nextResource = workInProgress.type; + if (null !== current && null != workInProgress.stateNode) + supportsMutation + ? current.memoizedProps !== newProps && markUpdate(workInProgress) + : updateHostComponent( + current, + workInProgress, + nextResource, + newProps + ); + else { + if (!newProps) { + if (null === workInProgress.stateNode) + throw Error(formatProdErrorMessage(166)); + bubbleProperties(workInProgress); + return null; + } + current = contextStackCursor.current; + popHydrationState(workInProgress) + ? prepareToHydrateHostInstance(workInProgress, current) + : ((current = resolveSingletonInstance( + nextResource, + newProps, + renderLanes, + current, + !0 + )), + (workInProgress.stateNode = current), + markUpdate(workInProgress)); + } + bubbleProperties(workInProgress); + return null; + } + case 5: + popHostContext(workInProgress); + renderLanes = workInProgress.type; + if (null !== current && null != workInProgress.stateNode) + updateHostComponent(current, workInProgress, renderLanes, newProps); + else { + if (!newProps) { + if (null === workInProgress.stateNode) + throw Error(formatProdErrorMessage(166)); + bubbleProperties(workInProgress); + return null; + } + current = contextStackCursor.current; + popHydrationState(workInProgress) + ? prepareToHydrateHostInstance(workInProgress, current) + : ((nextResource = createInstance( + renderLanes, + newProps, + rootInstanceStackCursor.current, + current, + workInProgress + )), + appendAllChildren(nextResource, workInProgress, !1, !1), + (workInProgress.stateNode = nextResource), + finalizeInitialChildren( + nextResource, + renderLanes, + newProps, + current + ) && markUpdate(workInProgress)); + } + bubbleProperties(workInProgress); + preloadInstanceAndSuspendIfNeeded( + workInProgress, + workInProgress.type, + workInProgress.pendingProps + ); + return null; + case 6: + if (current && null != workInProgress.stateNode) + (renderLanes = current.memoizedProps), + supportsMutation + ? renderLanes !== newProps && markUpdate(workInProgress) + : supportsPersistence && + (renderLanes !== newProps + ? ((workInProgress.stateNode = createTextInstance( + newProps, + rootInstanceStackCursor.current, + contextStackCursor.current, + workInProgress + )), + markUpdate(workInProgress)) + : (workInProgress.stateNode = current.stateNode)); + else { + if ("string" !== typeof newProps && null === workInProgress.stateNode) + throw Error(formatProdErrorMessage(166)); + current = rootInstanceStackCursor.current; + renderLanes = contextStackCursor.current; + if (popHydrationState(workInProgress)) { + if (!supportsHydration) throw Error(formatProdErrorMessage(176)); + current = workInProgress.stateNode; + renderLanes = workInProgress.memoizedProps; + newProps = null; + nextResource = hydrationParentFiber; + if (null !== nextResource) + switch (nextResource.tag) { + case 27: + case 5: + newProps = nextResource.memoizedProps; + } + hydrateTextInstance( + current, + renderLanes, + workInProgress, + newProps + ) || throwOnHydrationMismatch(workInProgress); + } else + workInProgress.stateNode = createTextInstance( + newProps, + current, + renderLanes, + workInProgress + ); + } + bubbleProperties(workInProgress); + return null; + case 13: + newProps = workInProgress.memoizedState; + if ( + null === current || + (null !== current.memoizedState && + null !== current.memoizedState.dehydrated) + ) { + nextResource = popHydrationState(workInProgress); + if (null !== newProps && null !== newProps.dehydrated) { + if (null === current) { + if (!nextResource) throw Error(formatProdErrorMessage(318)); + if (!supportsHydration) throw Error(formatProdErrorMessage(344)); + nextResource = workInProgress.memoizedState; + nextResource = + null !== nextResource ? nextResource.dehydrated : null; + if (!nextResource) throw Error(formatProdErrorMessage(317)); + hydrateSuspenseInstance(nextResource, workInProgress); + } else + resetHydrationState(), + 0 === (workInProgress.flags & 128) && + (workInProgress.memoizedState = null), + (workInProgress.flags |= 4); + bubbleProperties(workInProgress); + nextResource = !1; + } else + null !== hydrationErrors && + (queueRecoverableErrors(hydrationErrors), + (hydrationErrors = null)), + (nextResource = !0); + if (!nextResource) { + if (workInProgress.flags & 256) + return popSuspenseHandler(workInProgress), workInProgress; + popSuspenseHandler(workInProgress); + return null; + } + } + popSuspenseHandler(workInProgress); + if (0 !== (workInProgress.flags & 128)) + return (workInProgress.lanes = renderLanes), workInProgress; + renderLanes = null !== newProps; + current = null !== current && null !== current.memoizedState; + if (renderLanes) { + newProps = workInProgress.child; + nextResource = null; + null !== newProps.alternate && + null !== newProps.alternate.memoizedState && + null !== newProps.alternate.memoizedState.cachePool && + (nextResource = newProps.alternate.memoizedState.cachePool.pool); + var cache$110 = null; + null !== newProps.memoizedState && + null !== newProps.memoizedState.cachePool && + (cache$110 = newProps.memoizedState.cachePool.pool); + cache$110 !== nextResource && (newProps.flags |= 2048); + } + renderLanes !== current && + renderLanes && + (workInProgress.child.flags |= 8192); + scheduleRetryEffect(workInProgress, workInProgress.updateQueue); + bubbleProperties(workInProgress); + return null; + case 4: + return ( + popHostContainer(), + updateHostContainer(current, workInProgress), + null === current && + preparePortalMount(workInProgress.stateNode.containerInfo), + bubbleProperties(workInProgress), + null + ); + case 10: + return ( + popProvider(workInProgress.type), + bubbleProperties(workInProgress), + null + ); + case 19: + pop(suspenseStackCursor); + nextResource = workInProgress.memoizedState; + if (null === nextResource) + return bubbleProperties(workInProgress), null; + newProps = 0 !== (workInProgress.flags & 128); + cache$110 = nextResource.rendering; + if (null === cache$110) + if (newProps) cutOffTailIfNeeded(nextResource, !1); + else { + if ( + 0 !== workInProgressRootExitStatus || + (null !== current && 0 !== (current.flags & 128)) + ) + for (current = workInProgress.child; null !== current; ) { + cache$110 = findFirstSuspended(current); + if (null !== cache$110) { + workInProgress.flags |= 128; + cutOffTailIfNeeded(nextResource, !1); + current = cache$110.updateQueue; + workInProgress.updateQueue = current; + scheduleRetryEffect(workInProgress, current); + workInProgress.subtreeFlags = 0; + current = renderLanes; + for ( + renderLanes = workInProgress.child; + null !== renderLanes; + + ) + resetWorkInProgress(renderLanes, current), + (renderLanes = renderLanes.sibling); + push( + suspenseStackCursor, + (suspenseStackCursor.current & 1) | 2 + ); + return workInProgress.child; + } + current = current.sibling; + } + null !== nextResource.tail && + now() > workInProgressRootRenderTargetTime && + ((workInProgress.flags |= 128), + (newProps = !0), + cutOffTailIfNeeded(nextResource, !1), + (workInProgress.lanes = 4194304)); + } + else { + if (!newProps) + if (((current = findFirstSuspended(cache$110)), null !== current)) { + if ( + ((workInProgress.flags |= 128), + (newProps = !0), + (current = current.updateQueue), + (workInProgress.updateQueue = current), + scheduleRetryEffect(workInProgress, current), + cutOffTailIfNeeded(nextResource, !0), + null === nextResource.tail && + "hidden" === nextResource.tailMode && + !cache$110.alternate && + !isHydrating) + ) + return bubbleProperties(workInProgress), null; + } else + 2 * now() - nextResource.renderingStartTime > + workInProgressRootRenderTargetTime && + 536870912 !== renderLanes && + ((workInProgress.flags |= 128), + (newProps = !0), + cutOffTailIfNeeded(nextResource, !1), + (workInProgress.lanes = 4194304)); + nextResource.isBackwards + ? ((cache$110.sibling = workInProgress.child), + (workInProgress.child = cache$110)) + : ((current = nextResource.last), + null !== current + ? (current.sibling = cache$110) + : (workInProgress.child = cache$110), + (nextResource.last = cache$110)); + } + if (null !== nextResource.tail) + return ( + (workInProgress = nextResource.tail), + (nextResource.rendering = workInProgress), + (nextResource.tail = workInProgress.sibling), + (nextResource.renderingStartTime = now()), + (workInProgress.sibling = null), + (current = suspenseStackCursor.current), + push( + suspenseStackCursor, + newProps ? (current & 1) | 2 : current & 1 + ), + workInProgress + ); + bubbleProperties(workInProgress); + return null; + case 22: + case 23: + return ( + popSuspenseHandler(workInProgress), + popHiddenContext(), + (newProps = null !== workInProgress.memoizedState), + null !== current + ? (null !== current.memoizedState) !== newProps && + (workInProgress.flags |= 8192) + : newProps && (workInProgress.flags |= 8192), + newProps + ? 0 !== (renderLanes & 536870912) && + 0 === (workInProgress.flags & 128) && + (bubbleProperties(workInProgress), + workInProgress.subtreeFlags & 6 && (workInProgress.flags |= 8192)) + : bubbleProperties(workInProgress), + (renderLanes = workInProgress.updateQueue), + null !== renderLanes && + scheduleRetryEffect(workInProgress, renderLanes.retryQueue), + (renderLanes = null), + null !== current && + null !== current.memoizedState && + null !== current.memoizedState.cachePool && + (renderLanes = current.memoizedState.cachePool.pool), + (newProps = null), + null !== workInProgress.memoizedState && + null !== workInProgress.memoizedState.cachePool && + (newProps = workInProgress.memoizedState.cachePool.pool), + newProps !== renderLanes && (workInProgress.flags |= 2048), + null !== current && pop(resumedCache), + null + ); + case 24: + return ( + (renderLanes = null), + null !== current && (renderLanes = current.memoizedState.cache), + workInProgress.memoizedState.cache !== renderLanes && + (workInProgress.flags |= 2048), + popProvider(CacheContext), + bubbleProperties(workInProgress), + null + ); + case 25: + return null; + } + throw Error(formatProdErrorMessage(156, workInProgress.tag)); + } + function unwindWork(current, workInProgress) { + popTreeContext(workInProgress); + switch (workInProgress.tag) { + case 1: + return ( + (current = workInProgress.flags), + current & 65536 + ? ((workInProgress.flags = (current & -65537) | 128), + workInProgress) + : null + ); + case 3: + return ( + popProvider(CacheContext), + popHostContainer(), + (current = workInProgress.flags), + 0 !== (current & 65536) && 0 === (current & 128) + ? ((workInProgress.flags = (current & -65537) | 128), + workInProgress) + : null + ); + case 26: + case 27: + case 5: + return popHostContext(workInProgress), null; + case 13: + popSuspenseHandler(workInProgress); + current = workInProgress.memoizedState; + if (null !== current && null !== current.dehydrated) { + if (null === workInProgress.alternate) + throw Error(formatProdErrorMessage(340)); + resetHydrationState(); + } + current = workInProgress.flags; + return current & 65536 + ? ((workInProgress.flags = (current & -65537) | 128), workInProgress) + : null; + case 19: + return pop(suspenseStackCursor), null; + case 4: + return popHostContainer(), null; + case 10: + return popProvider(workInProgress.type), null; + case 22: + case 23: + return ( + popSuspenseHandler(workInProgress), + popHiddenContext(), + null !== current && pop(resumedCache), + (current = workInProgress.flags), + current & 65536 + ? ((workInProgress.flags = (current & -65537) | 128), + workInProgress) + : null + ); + case 24: + return popProvider(CacheContext), null; + case 25: + return null; + default: + return null; + } + } + function unwindInterruptedWork(current, interruptedWork) { + popTreeContext(interruptedWork); + switch (interruptedWork.tag) { + case 3: + popProvider(CacheContext); + popHostContainer(); + break; + case 26: + case 27: + case 5: + popHostContext(interruptedWork); + break; + case 4: + popHostContainer(); + break; + case 13: + popSuspenseHandler(interruptedWork); + break; + case 19: + pop(suspenseStackCursor); + break; + case 10: + popProvider(interruptedWork.type); + break; + case 22: + case 23: + popSuspenseHandler(interruptedWork); + popHiddenContext(); + null !== current && pop(resumedCache); + break; + case 24: + popProvider(CacheContext); + } + } + function commitHookEffectListMount(flags, finishedWork) { + try { + var updateQueue = finishedWork.updateQueue, + lastEffect = null !== updateQueue ? updateQueue.lastEffect : null; + if (null !== lastEffect) { + var firstEffect = lastEffect.next; + updateQueue = firstEffect; + do { + if ((updateQueue.tag & flags) === flags) { + lastEffect = void 0; + var create = updateQueue.create, + inst = updateQueue.inst; + lastEffect = create(); + inst.destroy = lastEffect; + } + updateQueue = updateQueue.next; + } while (updateQueue !== firstEffect); + } + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + function commitHookEffectListUnmount( + flags, + finishedWork, + nearestMountedAncestor$jscomp$0 + ) { + try { + var updateQueue = finishedWork.updateQueue, + lastEffect = null !== updateQueue ? updateQueue.lastEffect : null; + if (null !== lastEffect) { + var firstEffect = lastEffect.next; + updateQueue = firstEffect; + do { + if ((updateQueue.tag & flags) === flags) { + var inst = updateQueue.inst, + destroy = inst.destroy; + if (void 0 !== destroy) { + inst.destroy = void 0; + lastEffect = finishedWork; + var nearestMountedAncestor = nearestMountedAncestor$jscomp$0; + try { + destroy(); + } catch (error) { + captureCommitPhaseError( + lastEffect, + nearestMountedAncestor, + error + ); + } + } + } + updateQueue = updateQueue.next; + } while (updateQueue !== firstEffect); + } + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + function commitClassCallbacks(finishedWork) { + var updateQueue = finishedWork.updateQueue; + if (null !== updateQueue) { + var instance = finishedWork.stateNode; + try { + commitCallbacks(updateQueue, instance); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + } + function safelyCallComponentWillUnmount( + current, + nearestMountedAncestor, + instance + ) { + instance.props = resolveClassComponentProps( + current.type, + current.memoizedProps + ); + instance.state = current.memoizedState; + try { + instance.componentWillUnmount(); + } catch (error) { + captureCommitPhaseError(current, nearestMountedAncestor, error); + } + } + function safelyAttachRef(current, nearestMountedAncestor) { + try { + var ref = current.ref; + if (null !== ref) { + var instance = current.stateNode; + switch (current.tag) { + case 26: + case 27: + case 5: + var instanceToUse = getPublicInstance(instance); + break; + default: + instanceToUse = instance; + } + "function" === typeof ref + ? (current.refCleanup = ref(instanceToUse)) + : (ref.current = instanceToUse); + } + } catch (error) { + captureCommitPhaseError(current, nearestMountedAncestor, error); + } + } + function safelyDetachRef(current, nearestMountedAncestor) { + var ref = current.ref, + refCleanup = current.refCleanup; + if (null !== ref) + if ("function" === typeof refCleanup) + try { + refCleanup(); + } catch (error) { + captureCommitPhaseError(current, nearestMountedAncestor, error); + } finally { + (current.refCleanup = null), + (current = current.alternate), + null != current && (current.refCleanup = null); + } + else if ("function" === typeof ref) + try { + ref(null); + } catch (error$126) { + captureCommitPhaseError(current, nearestMountedAncestor, error$126); + } + else ref.current = null; + } + function commitHostMount(finishedWork) { + var type = finishedWork.type, + props = finishedWork.memoizedProps, + instance = finishedWork.stateNode; + try { + commitMount(instance, type, props, finishedWork); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + function commitHostUpdate(finishedWork, newProps, oldProps) { + try { + commitUpdate( + finishedWork.stateNode, + finishedWork.type, + oldProps, + newProps, + finishedWork + ); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + function isHostParent(fiber) { + return ( + 5 === fiber.tag || + 3 === fiber.tag || + (supportsResources ? 26 === fiber.tag : !1) || + (supportsSingletons ? 27 === fiber.tag : !1) || + 4 === fiber.tag + ); + } + function getHostSibling(fiber) { + a: for (;;) { + for (; null === fiber.sibling; ) { + if (null === fiber.return || isHostParent(fiber.return)) return null; + fiber = fiber.return; + } + fiber.sibling.return = fiber.return; + for ( + fiber = fiber.sibling; + 5 !== fiber.tag && + 6 !== fiber.tag && + (supportsSingletons ? 27 !== fiber.tag : 1) && + 18 !== fiber.tag; + + ) { + if (fiber.flags & 2) continue a; + if (null === fiber.child || 4 === fiber.tag) continue a; + else (fiber.child.return = fiber), (fiber = fiber.child); + } + if (!(fiber.flags & 2)) return fiber.stateNode; + } + } + function insertOrAppendPlacementNodeIntoContainer(node, before, parent) { + var tag = node.tag; + if (5 === tag || 6 === tag) + (node = node.stateNode), + before + ? insertInContainerBefore(parent, node, before) + : appendChildToContainer(parent, node); + else if ( + !(4 === tag || (supportsSingletons && 27 === tag)) && + ((node = node.child), null !== node) + ) + for ( + insertOrAppendPlacementNodeIntoContainer(node, before, parent), + node = node.sibling; + null !== node; + + ) + insertOrAppendPlacementNodeIntoContainer(node, before, parent), + (node = node.sibling); + } + function insertOrAppendPlacementNode(node, before, parent) { + var tag = node.tag; + if (5 === tag || 6 === tag) + (node = node.stateNode), + before ? insertBefore(parent, node, before) : appendChild(parent, node); + else if ( + !(4 === tag || (supportsSingletons && 27 === tag)) && + ((node = node.child), null !== node) + ) + for ( + insertOrAppendPlacementNode(node, before, parent), node = node.sibling; + null !== node; + + ) + insertOrAppendPlacementNode(node, before, parent), + (node = node.sibling); + } + function commitHostPortalContainerChildren( + portal, + finishedWork, + pendingChildren + ) { + portal = portal.containerInfo; + try { + replaceContainerChildren(portal, pendingChildren); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + function commitBeforeMutationEffects(root, firstChild) { + prepareForCommit(root.containerInfo); + for (nextEffect = firstChild; null !== nextEffect; ) + if ( + ((root = nextEffect), + (firstChild = root.child), + 0 !== (root.subtreeFlags & 1028) && null !== firstChild) + ) + (firstChild.return = root), (nextEffect = firstChild); + else + for (; null !== nextEffect; ) { + root = nextEffect; + var current = root.alternate; + firstChild = root.flags; + switch (root.tag) { + case 0: + break; + case 11: + case 15: + break; + case 1: + if (0 !== (firstChild & 1024) && null !== current) { + firstChild = void 0; + var finishedWork = root, + prevProps = current.memoizedProps; + current = current.memoizedState; + var instance = finishedWork.stateNode; + try { + var resolvedPrevProps = resolveClassComponentProps( + finishedWork.type, + prevProps, + finishedWork.elementType === finishedWork.type + ); + firstChild = instance.getSnapshotBeforeUpdate( + resolvedPrevProps, + current + ); + instance.__reactInternalSnapshotBeforeUpdate = firstChild; + } catch (error) { + captureCommitPhaseError( + finishedWork, + finishedWork.return, + error + ); + } + } + break; + case 3: + 0 !== (firstChild & 1024) && + supportsMutation && + clearContainer(root.stateNode.containerInfo); + break; + case 5: + case 26: + case 27: + case 6: + case 4: + case 17: + break; + default: + if (0 !== (firstChild & 1024)) + throw Error(formatProdErrorMessage(163)); + } + firstChild = root.sibling; + if (null !== firstChild) { + firstChild.return = root.return; + nextEffect = firstChild; + break; + } + nextEffect = root.return; + } + resolvedPrevProps = shouldFireAfterActiveInstanceBlur; + shouldFireAfterActiveInstanceBlur = !1; + return resolvedPrevProps; + } + function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork) { + var flags = finishedWork.flags; + switch (finishedWork.tag) { + case 0: + case 11: + case 15: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + flags & 4 && commitHookEffectListMount(5, finishedWork); + break; + case 1: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + if (flags & 4) + if (((finishedRoot = finishedWork.stateNode), null === current)) + try { + finishedRoot.componentDidMount(); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + else { + var prevProps = resolveClassComponentProps( + finishedWork.type, + current.memoizedProps + ); + current = current.memoizedState; + try { + finishedRoot.componentDidUpdate( + prevProps, + current, + finishedRoot.__reactInternalSnapshotBeforeUpdate + ); + } catch (error$125) { + captureCommitPhaseError( + finishedWork, + finishedWork.return, + error$125 + ); + } + } + flags & 64 && commitClassCallbacks(finishedWork); + flags & 512 && safelyAttachRef(finishedWork, finishedWork.return); + break; + case 3: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + if ( + flags & 64 && + ((flags = finishedWork.updateQueue), null !== flags) + ) { + finishedRoot = null; + if (null !== finishedWork.child) + switch (finishedWork.child.tag) { + case 27: + case 5: + finishedRoot = getPublicInstance(finishedWork.child.stateNode); + break; + case 1: + finishedRoot = finishedWork.child.stateNode; + } + try { + commitCallbacks(flags, finishedRoot); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + break; + case 26: + if (supportsResources) { + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + flags & 512 && safelyAttachRef(finishedWork, finishedWork.return); + break; + } + case 27: + case 5: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + null === current && flags & 4 && commitHostMount(finishedWork); + flags & 512 && safelyAttachRef(finishedWork, finishedWork.return); + break; + case 12: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + break; + case 13: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + flags & 4 && + commitSuspenseHydrationCallbacks(finishedRoot, finishedWork); + break; + case 22: + prevProps = + null !== finishedWork.memoizedState || offscreenSubtreeIsHidden; + if (!prevProps) { + current = + (null !== current && null !== current.memoizedState) || + offscreenSubtreeWasHidden; + var prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden, + prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + offscreenSubtreeIsHidden = prevProps; + (offscreenSubtreeWasHidden = current) && + !prevOffscreenSubtreeWasHidden + ? recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + 0 !== (finishedWork.subtreeFlags & 8772) + ) + : recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden; + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + } + flags & 512 && + ("manual" === finishedWork.memoizedProps.mode + ? safelyAttachRef(finishedWork, finishedWork.return) + : safelyDetachRef(finishedWork, finishedWork.return)); + break; + default: + recursivelyTraverseLayoutEffects(finishedRoot, finishedWork); + } + } + function detachFiberAfterEffects(fiber) { + var alternate = fiber.alternate; + null !== alternate && + ((fiber.alternate = null), detachFiberAfterEffects(alternate)); + fiber.child = null; + fiber.deletions = null; + fiber.sibling = null; + 5 === fiber.tag && + ((alternate = fiber.stateNode), + null !== alternate && detachDeletedInstance(alternate)); + fiber.stateNode = null; + fiber.return = null; + fiber.dependencies = null; + fiber.memoizedProps = null; + fiber.memoizedState = null; + fiber.pendingProps = null; + fiber.stateNode = null; + fiber.updateQueue = null; + } + function recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + parent + ) { + for (parent = parent.child; null !== parent; ) + commitDeletionEffectsOnFiber( + finishedRoot, + nearestMountedAncestor, + parent + ), + (parent = parent.sibling); + } + function commitDeletionEffectsOnFiber( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ) { + if (injectedHook && "function" === typeof injectedHook.onCommitFiberUnmount) + try { + injectedHook.onCommitFiberUnmount(rendererID, deletedFiber); + } catch (err) {} + switch (deletedFiber.tag) { + case 26: + if (supportsResources) { + offscreenSubtreeWasHidden || + safelyDetachRef(deletedFiber, nearestMountedAncestor); + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + deletedFiber.memoizedState + ? releaseResource(deletedFiber.memoizedState) + : deletedFiber.stateNode && + unmountHoistable(deletedFiber.stateNode); + break; + } + case 27: + if (supportsSingletons) { + offscreenSubtreeWasHidden || + safelyDetachRef(deletedFiber, nearestMountedAncestor); + var prevHostParent = hostParent, + prevHostParentIsContainer = hostParentIsContainer; + hostParent = deletedFiber.stateNode; + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + releaseSingletonInstance(deletedFiber.stateNode); + hostParent = prevHostParent; + hostParentIsContainer = prevHostParentIsContainer; + break; + } + case 5: + offscreenSubtreeWasHidden || + safelyDetachRef(deletedFiber, nearestMountedAncestor); + case 6: + if (supportsMutation) { + if ( + ((prevHostParent = hostParent), + (prevHostParentIsContainer = hostParentIsContainer), + (hostParent = null), + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ), + (hostParent = prevHostParent), + (hostParentIsContainer = prevHostParentIsContainer), + null !== hostParent) + ) + if (hostParentIsContainer) + try { + removeChildFromContainer(hostParent, deletedFiber.stateNode); + } catch (error) { + captureCommitPhaseError( + deletedFiber, + nearestMountedAncestor, + error + ); + } + else + try { + removeChild(hostParent, deletedFiber.stateNode); + } catch (error) { + captureCommitPhaseError( + deletedFiber, + nearestMountedAncestor, + error + ); + } + } else + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + break; + case 18: + supportsMutation && + null !== hostParent && + (hostParentIsContainer + ? clearSuspenseBoundaryFromContainer( + hostParent, + deletedFiber.stateNode + ) + : clearSuspenseBoundary(hostParent, deletedFiber.stateNode)); + break; + case 4: + supportsMutation + ? ((prevHostParent = hostParent), + (prevHostParentIsContainer = hostParentIsContainer), + (hostParent = deletedFiber.stateNode.containerInfo), + (hostParentIsContainer = !0), + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ), + (hostParent = prevHostParent), + (hostParentIsContainer = prevHostParentIsContainer)) + : (supportsPersistence && + commitHostPortalContainerChildren( + deletedFiber.stateNode, + deletedFiber, + createContainerChildSet() + ), + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + )); + break; + case 0: + case 11: + case 14: + case 15: + offscreenSubtreeWasHidden || + commitHookEffectListUnmount(2, deletedFiber, nearestMountedAncestor); + offscreenSubtreeWasHidden || + commitHookEffectListUnmount(4, deletedFiber, nearestMountedAncestor); + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + break; + case 1: + offscreenSubtreeWasHidden || + (safelyDetachRef(deletedFiber, nearestMountedAncestor), + (prevHostParent = deletedFiber.stateNode), + "function" === typeof prevHostParent.componentWillUnmount && + safelyCallComponentWillUnmount( + deletedFiber, + nearestMountedAncestor, + prevHostParent + )); + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + break; + case 21: + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + break; + case 22: + offscreenSubtreeWasHidden || + safelyDetachRef(deletedFiber, nearestMountedAncestor); + offscreenSubtreeWasHidden = + (prevHostParent = offscreenSubtreeWasHidden) || + null !== deletedFiber.memoizedState; + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + offscreenSubtreeWasHidden = prevHostParent; + break; + default: + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber + ); + } + } + function commitSuspenseHydrationCallbacks(finishedRoot, finishedWork) { + if ( + supportsHydration && + null === finishedWork.memoizedState && + ((finishedRoot = finishedWork.alternate), + null !== finishedRoot && + ((finishedRoot = finishedRoot.memoizedState), + null !== finishedRoot && + ((finishedRoot = finishedRoot.dehydrated), null !== finishedRoot))) + ) + try { + commitHydratedSuspenseInstance(finishedRoot); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + function getRetryCache(finishedWork) { + switch (finishedWork.tag) { + case 13: + case 19: + var retryCache = finishedWork.stateNode; + null === retryCache && + (retryCache = finishedWork.stateNode = new PossiblyWeakSet()); + return retryCache; + case 22: + return ( + (finishedWork = finishedWork.stateNode), + (retryCache = finishedWork._retryCache), + null === retryCache && + (retryCache = finishedWork._retryCache = new PossiblyWeakSet()), + retryCache + ); + default: + throw Error(formatProdErrorMessage(435, finishedWork.tag)); + } + } + function attachSuspenseRetryListeners(finishedWork, wakeables) { + var retryCache = getRetryCache(finishedWork); + wakeables.forEach(function (wakeable) { + var retry = resolveRetryWakeable.bind(null, finishedWork, wakeable); + retryCache.has(wakeable) || + (retryCache.add(wakeable), wakeable.then(retry, retry)); + }); + } + function recursivelyTraverseMutationEffects(root$jscomp$0, parentFiber) { + var deletions = parentFiber.deletions; + if (null !== deletions) + for (var i = 0; i < deletions.length; i++) { + var childToDelete = deletions[i], + root = root$jscomp$0, + returnFiber = parentFiber; + if (supportsMutation) { + var parent = returnFiber; + a: for (; null !== parent; ) { + switch (parent.tag) { + case 27: + case 5: + hostParent = parent.stateNode; + hostParentIsContainer = !1; + break a; + case 3: + hostParent = parent.stateNode.containerInfo; + hostParentIsContainer = !0; + break a; + case 4: + hostParent = parent.stateNode.containerInfo; + hostParentIsContainer = !0; + break a; + } + parent = parent.return; + } + if (null === hostParent) throw Error(formatProdErrorMessage(160)); + commitDeletionEffectsOnFiber(root, returnFiber, childToDelete); + hostParent = null; + hostParentIsContainer = !1; + } else commitDeletionEffectsOnFiber(root, returnFiber, childToDelete); + root = childToDelete.alternate; + null !== root && (root.return = null); + childToDelete.return = null; + } + if (parentFiber.subtreeFlags & 13878) + for (parentFiber = parentFiber.child; null !== parentFiber; ) + commitMutationEffectsOnFiber(parentFiber, root$jscomp$0), + (parentFiber = parentFiber.sibling); + } + function commitMutationEffectsOnFiber(finishedWork, root) { + var current = finishedWork.alternate, + flags = finishedWork.flags; + switch (finishedWork.tag) { + case 0: + case 11: + case 14: + case 15: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + flags & 4 && + (commitHookEffectListUnmount(3, finishedWork, finishedWork.return), + commitHookEffectListMount(3, finishedWork), + commitHookEffectListUnmount(5, finishedWork, finishedWork.return)); + break; + case 1: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + flags & 512 && + (offscreenSubtreeWasHidden || + null === current || + safelyDetachRef(current, current.return)); + flags & 64 && + offscreenSubtreeIsHidden && + ((finishedWork = finishedWork.updateQueue), + null !== finishedWork && + ((flags = finishedWork.callbacks), + null !== flags && + ((current = finishedWork.shared.hiddenCallbacks), + (finishedWork.shared.hiddenCallbacks = + null === current ? flags : current.concat(flags))))); + break; + case 26: + if (supportsResources) { + var hoistableRoot = currentHoistableRoot; + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + flags & 512 && + (offscreenSubtreeWasHidden || + null === current || + safelyDetachRef(current, current.return)); + flags & 4 && + ((flags = null !== current ? current.memoizedState : null), + (root = finishedWork.memoizedState), + null === current + ? null === root + ? null === finishedWork.stateNode + ? (finishedWork.stateNode = hydrateHoistable( + hoistableRoot, + finishedWork.type, + finishedWork.memoizedProps, + finishedWork + )) + : mountHoistable( + hoistableRoot, + finishedWork.type, + finishedWork.stateNode + ) + : (finishedWork.stateNode = acquireResource( + hoistableRoot, + root, + finishedWork.memoizedProps + )) + : flags !== root + ? (null === flags + ? null !== current.stateNode && + unmountHoistable(current.stateNode) + : releaseResource(flags), + null === root + ? mountHoistable( + hoistableRoot, + finishedWork.type, + finishedWork.stateNode + ) + : acquireResource( + hoistableRoot, + root, + finishedWork.memoizedProps + )) + : null === root && + null !== finishedWork.stateNode && + commitHostUpdate( + finishedWork, + finishedWork.memoizedProps, + current.memoizedProps + )); + break; + } + case 27: + if ( + supportsSingletons && + flags & 4 && + null === finishedWork.alternate + ) { + hoistableRoot = finishedWork.stateNode; + var props = finishedWork.memoizedProps; + try { + clearSingleton(hoistableRoot), + acquireSingletonInstance( + finishedWork.type, + props, + hoistableRoot, + finishedWork + ); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + case 5: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + flags & 512 && + (offscreenSubtreeWasHidden || + null === current || + safelyDetachRef(current, current.return)); + if (supportsMutation) { + if (finishedWork.flags & 32) { + root = finishedWork.stateNode; + try { + resetTextContent(root); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + flags & 4 && + null != finishedWork.stateNode && + ((root = finishedWork.memoizedProps), + commitHostUpdate( + finishedWork, + root, + null !== current ? current.memoizedProps : root + )); + flags & 1024 && (needsFormReset = !0); + } + break; + case 6: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + if (flags & 4 && supportsMutation) { + if (null === finishedWork.stateNode) + throw Error(formatProdErrorMessage(162)); + flags = finishedWork.memoizedProps; + current = null !== current ? current.memoizedProps : flags; + root = finishedWork.stateNode; + try { + commitTextUpdate(root, current, flags); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + break; + case 3: + supportsResources + ? (prepareToCommitHoistables(), + (hoistableRoot = currentHoistableRoot), + (currentHoistableRoot = getHoistableRoot(root.containerInfo)), + recursivelyTraverseMutationEffects(root, finishedWork), + (currentHoistableRoot = hoistableRoot)) + : recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + if (flags & 4) { + if ( + supportsMutation && + supportsHydration && + null !== current && + current.memoizedState.isDehydrated + ) + try { + commitHydratedContainer(root.containerInfo); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + if (supportsPersistence) { + flags = root.containerInfo; + current = root.pendingChildren; + try { + replaceContainerChildren(flags, current); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + } + needsFormReset && + ((needsFormReset = !1), recursivelyResetForms(finishedWork)); + break; + case 4: + supportsResources + ? ((current = currentHoistableRoot), + (currentHoistableRoot = getHoistableRoot( + finishedWork.stateNode.containerInfo + )), + recursivelyTraverseMutationEffects(root, finishedWork), + commitReconciliationEffects(finishedWork), + (currentHoistableRoot = current)) + : (recursivelyTraverseMutationEffects(root, finishedWork), + commitReconciliationEffects(finishedWork)); + flags & 4 && + supportsPersistence && + commitHostPortalContainerChildren( + finishedWork.stateNode, + finishedWork, + finishedWork.stateNode.pendingChildren + ); + break; + case 12: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + break; + case 13: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + finishedWork.child.flags & 8192 && + (null !== finishedWork.memoizedState) !== + (null !== current && null !== current.memoizedState) && + (globalMostRecentFallbackTime = now()); + flags & 4 && + ((flags = finishedWork.updateQueue), + null !== flags && + ((finishedWork.updateQueue = null), + attachSuspenseRetryListeners(finishedWork, flags))); + break; + case 22: + flags & 512 && + (offscreenSubtreeWasHidden || + null === current || + safelyDetachRef(current, current.return)); + hoistableRoot = null !== finishedWork.memoizedState; + var wasHidden = null !== current && null !== current.memoizedState, + prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden, + prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + offscreenSubtreeIsHidden = + prevOffscreenSubtreeIsHidden || hoistableRoot; + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden; + recursivelyTraverseMutationEffects(root, finishedWork); + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden; + commitReconciliationEffects(finishedWork); + root = finishedWork.stateNode; + root._current = finishedWork; + root._visibility &= -3; + root._visibility |= root._pendingVisibility & 2; + if ( + flags & 8192 && + ((root._visibility = hoistableRoot + ? root._visibility & -2 + : root._visibility | 1), + hoistableRoot && + ((root = offscreenSubtreeIsHidden || offscreenSubtreeWasHidden), + null === current || + wasHidden || + root || + recursivelyTraverseDisappearLayoutEffects(finishedWork)), + supportsMutation && + (null === finishedWork.memoizedProps || + "manual" !== finishedWork.memoizedProps.mode)) + ) + a: if (((current = null), supportsMutation)) + for (root = finishedWork; ; ) { + if ( + 5 === root.tag || + (supportsResources && 26 === root.tag) || + (supportsSingletons && 27 === root.tag) + ) { + if (null === current) { + wasHidden = current = root; + try { + (props = wasHidden.stateNode), + hoistableRoot + ? hideInstance(props) + : unhideInstance( + wasHidden.stateNode, + wasHidden.memoizedProps + ); + } catch (error) { + captureCommitPhaseError(wasHidden, wasHidden.return, error); + } + } + } else if (6 === root.tag) { + if (null === current) { + wasHidden = root; + try { + var instance = wasHidden.stateNode; + hoistableRoot + ? hideTextInstance(instance) + : unhideTextInstance(instance, wasHidden.memoizedProps); + } catch (error) { + captureCommitPhaseError(wasHidden, wasHidden.return, error); + } + } + } else if ( + ((22 !== root.tag && 23 !== root.tag) || + null === root.memoizedState || + root === finishedWork) && + null !== root.child + ) { + root.child.return = root; + root = root.child; + continue; + } + if (root === finishedWork) break a; + for (; null === root.sibling; ) { + if (null === root.return || root.return === finishedWork) + break a; + current === root && (current = null); + root = root.return; + } + current === root && (current = null); + root.sibling.return = root.return; + root = root.sibling; + } + flags & 4 && + ((flags = finishedWork.updateQueue), + null !== flags && + ((current = flags.retryQueue), + null !== current && + ((flags.retryQueue = null), + attachSuspenseRetryListeners(finishedWork, current)))); + break; + case 19: + recursivelyTraverseMutationEffects(root, finishedWork); + commitReconciliationEffects(finishedWork); + flags & 4 && + ((flags = finishedWork.updateQueue), + null !== flags && + ((finishedWork.updateQueue = null), + attachSuspenseRetryListeners(finishedWork, flags))); + break; + case 21: + break; + default: + recursivelyTraverseMutationEffects(root, finishedWork), + commitReconciliationEffects(finishedWork); + } + } + function commitReconciliationEffects(finishedWork) { + var flags = finishedWork.flags; + if (flags & 2) { + try { + if ( + supportsMutation && + (!supportsSingletons || 27 !== finishedWork.tag) + ) { + a: { + for (var parent = finishedWork.return; null !== parent; ) { + if (isHostParent(parent)) { + var JSCompiler_inline_result = parent; + break a; + } + parent = parent.return; + } + throw Error(formatProdErrorMessage(160)); + } + switch (JSCompiler_inline_result.tag) { + case 27: + if (supportsSingletons) { + var parent$jscomp$0 = JSCompiler_inline_result.stateNode, + before = getHostSibling(finishedWork); + insertOrAppendPlacementNode( + finishedWork, + before, + parent$jscomp$0 + ); + break; + } + case 5: + var parent$127 = JSCompiler_inline_result.stateNode; + JSCompiler_inline_result.flags & 32 && + (resetTextContent(parent$127), + (JSCompiler_inline_result.flags &= -33)); + var before$128 = getHostSibling(finishedWork); + insertOrAppendPlacementNode(finishedWork, before$128, parent$127); + break; + case 3: + case 4: + var parent$129 = JSCompiler_inline_result.stateNode.containerInfo, + before$130 = getHostSibling(finishedWork); + insertOrAppendPlacementNodeIntoContainer( + finishedWork, + before$130, + parent$129 + ); + break; + default: + throw Error(formatProdErrorMessage(161)); + } + } + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + finishedWork.flags &= -3; + } + flags & 4096 && (finishedWork.flags &= -4097); + } + function recursivelyResetForms(parentFiber) { + if (parentFiber.subtreeFlags & 1024) + for (parentFiber = parentFiber.child; null !== parentFiber; ) { + var fiber = parentFiber; + recursivelyResetForms(fiber); + 5 === fiber.tag && + fiber.flags & 1024 && + resetFormInstance(fiber.stateNode); + parentFiber = parentFiber.sibling; + } + } + function recursivelyTraverseLayoutEffects(root, parentFiber) { + if (parentFiber.subtreeFlags & 8772) + for (parentFiber = parentFiber.child; null !== parentFiber; ) + commitLayoutEffectOnFiber(root, parentFiber.alternate, parentFiber), + (parentFiber = parentFiber.sibling); + } + function recursivelyTraverseDisappearLayoutEffects(parentFiber) { + for (parentFiber = parentFiber.child; null !== parentFiber; ) { + var finishedWork = parentFiber; + switch (finishedWork.tag) { + case 0: + case 11: + case 14: + case 15: + commitHookEffectListUnmount(4, finishedWork, finishedWork.return); + recursivelyTraverseDisappearLayoutEffects(finishedWork); + break; + case 1: + safelyDetachRef(finishedWork, finishedWork.return); + var instance = finishedWork.stateNode; + "function" === typeof instance.componentWillUnmount && + safelyCallComponentWillUnmount( + finishedWork, + finishedWork.return, + instance + ); + recursivelyTraverseDisappearLayoutEffects(finishedWork); + break; + case 26: + case 27: + case 5: + safelyDetachRef(finishedWork, finishedWork.return); + recursivelyTraverseDisappearLayoutEffects(finishedWork); + break; + case 22: + safelyDetachRef(finishedWork, finishedWork.return); + null === finishedWork.memoizedState && + recursivelyTraverseDisappearLayoutEffects(finishedWork); + break; + default: + recursivelyTraverseDisappearLayoutEffects(finishedWork); + } + parentFiber = parentFiber.sibling; + } + } + function recursivelyTraverseReappearLayoutEffects( + finishedRoot$jscomp$0, + parentFiber, + includeWorkInProgressEffects + ) { + includeWorkInProgressEffects = + includeWorkInProgressEffects && 0 !== (parentFiber.subtreeFlags & 8772); + for (parentFiber = parentFiber.child; null !== parentFiber; ) { + var current = parentFiber.alternate, + finishedRoot = finishedRoot$jscomp$0, + finishedWork = parentFiber, + flags = finishedWork.flags; + switch (finishedWork.tag) { + case 0: + case 11: + case 15: + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + commitHookEffectListMount(4, finishedWork); + break; + case 1: + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + current = finishedWork; + finishedRoot = current.stateNode; + if ("function" === typeof finishedRoot.componentDidMount) + try { + finishedRoot.componentDidMount(); + } catch (error) { + captureCommitPhaseError(current, current.return, error); + } + current = finishedWork; + finishedRoot = current.updateQueue; + if (null !== finishedRoot) { + var instance = current.stateNode; + try { + var hiddenCallbacks = finishedRoot.shared.hiddenCallbacks; + if (null !== hiddenCallbacks) + for ( + finishedRoot.shared.hiddenCallbacks = null, finishedRoot = 0; + finishedRoot < hiddenCallbacks.length; + finishedRoot++ + ) + callCallback(hiddenCallbacks[finishedRoot], instance); + } catch (error) { + captureCommitPhaseError(current, current.return, error); + } + } + includeWorkInProgressEffects && + flags & 64 && + commitClassCallbacks(finishedWork); + safelyAttachRef(finishedWork, finishedWork.return); + break; + case 26: + case 27: + case 5: + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + includeWorkInProgressEffects && + null === current && + flags & 4 && + commitHostMount(finishedWork); + safelyAttachRef(finishedWork, finishedWork.return); + break; + case 12: + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + break; + case 13: + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + includeWorkInProgressEffects && + flags & 4 && + commitSuspenseHydrationCallbacks(finishedRoot, finishedWork); + break; + case 22: + null === finishedWork.memoizedState && + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + safelyAttachRef(finishedWork, finishedWork.return); + break; + default: + recursivelyTraverseReappearLayoutEffects( + finishedRoot, + finishedWork, + includeWorkInProgressEffects + ); + } + parentFiber = parentFiber.sibling; + } + } + function commitOffscreenPassiveMountEffects(current, finishedWork) { + var previousCache = null; + null !== current && + null !== current.memoizedState && + null !== current.memoizedState.cachePool && + (previousCache = current.memoizedState.cachePool.pool); + current = null; + null !== finishedWork.memoizedState && + null !== finishedWork.memoizedState.cachePool && + (current = finishedWork.memoizedState.cachePool.pool); + current !== previousCache && + (null != current && current.refCount++, + null != previousCache && releaseCache(previousCache)); + } + function commitCachePassiveMountEffect(current, finishedWork) { + current = null; + null !== finishedWork.alternate && + (current = finishedWork.alternate.memoizedState.cache); + finishedWork = finishedWork.memoizedState.cache; + finishedWork !== current && + (finishedWork.refCount++, null != current && releaseCache(current)); + } + function recursivelyTraversePassiveMountEffects( + root, + parentFiber, + committedLanes, + committedTransitions + ) { + if (parentFiber.subtreeFlags & 10256) + for (parentFiber = parentFiber.child; null !== parentFiber; ) + commitPassiveMountOnFiber( + root, + parentFiber, + committedLanes, + committedTransitions + ), + (parentFiber = parentFiber.sibling); + } + function commitPassiveMountOnFiber( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ) { + var flags = finishedWork.flags; + switch (finishedWork.tag) { + case 0: + case 11: + case 15: + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ); + flags & 2048 && commitHookEffectListMount(9, finishedWork); + break; + case 3: + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ); + flags & 2048 && + ((finishedRoot = null), + null !== finishedWork.alternate && + (finishedRoot = finishedWork.alternate.memoizedState.cache), + (finishedWork = finishedWork.memoizedState.cache), + finishedWork !== finishedRoot && + (finishedWork.refCount++, + null != finishedRoot && releaseCache(finishedRoot))); + break; + case 12: + if (flags & 2048) { + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ); + finishedRoot = finishedWork.stateNode; + try { + var _finishedWork$memoize2 = finishedWork.memoizedProps, + id = _finishedWork$memoize2.id, + onPostCommit = _finishedWork$memoize2.onPostCommit; + "function" === typeof onPostCommit && + onPostCommit( + id, + null === finishedWork.alternate ? "mount" : "update", + finishedRoot.passiveEffectDuration, + -0 + ); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } else + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ); + break; + case 23: + break; + case 22: + _finishedWork$memoize2 = finishedWork.stateNode; + null !== finishedWork.memoizedState + ? _finishedWork$memoize2._visibility & 4 + ? recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ) + : recursivelyTraverseAtomicPassiveEffects( + finishedRoot, + finishedWork + ) + : _finishedWork$memoize2._visibility & 4 + ? recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ) + : ((_finishedWork$memoize2._visibility |= 4), + recursivelyTraverseReconnectPassiveEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + 0 !== (finishedWork.subtreeFlags & 10256) + )); + flags & 2048 && + commitOffscreenPassiveMountEffects( + finishedWork.alternate, + finishedWork + ); + break; + case 24: + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ); + flags & 2048 && + commitCachePassiveMountEffect(finishedWork.alternate, finishedWork); + break; + default: + recursivelyTraversePassiveMountEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions + ); + } + } + function recursivelyTraverseReconnectPassiveEffects( + finishedRoot$jscomp$0, + parentFiber, + committedLanes$jscomp$0, + committedTransitions$jscomp$0, + includeWorkInProgressEffects + ) { + includeWorkInProgressEffects = + includeWorkInProgressEffects && 0 !== (parentFiber.subtreeFlags & 10256); + for (parentFiber = parentFiber.child; null !== parentFiber; ) { + var finishedRoot = finishedRoot$jscomp$0, + finishedWork = parentFiber, + committedLanes = committedLanes$jscomp$0, + committedTransitions = committedTransitions$jscomp$0, + flags = finishedWork.flags; + switch (finishedWork.tag) { + case 0: + case 11: + case 15: + recursivelyTraverseReconnectPassiveEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + includeWorkInProgressEffects + ); + commitHookEffectListMount(8, finishedWork); + break; + case 23: + break; + case 22: + var instance = finishedWork.stateNode; + null !== finishedWork.memoizedState + ? instance._visibility & 4 + ? recursivelyTraverseReconnectPassiveEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + includeWorkInProgressEffects + ) + : recursivelyTraverseAtomicPassiveEffects( + finishedRoot, + finishedWork + ) + : ((instance._visibility |= 4), + recursivelyTraverseReconnectPassiveEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + includeWorkInProgressEffects + )); + includeWorkInProgressEffects && + flags & 2048 && + commitOffscreenPassiveMountEffects( + finishedWork.alternate, + finishedWork + ); + break; + case 24: + recursivelyTraverseReconnectPassiveEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + includeWorkInProgressEffects + ); + includeWorkInProgressEffects && + flags & 2048 && + commitCachePassiveMountEffect(finishedWork.alternate, finishedWork); + break; + default: + recursivelyTraverseReconnectPassiveEffects( + finishedRoot, + finishedWork, + committedLanes, + committedTransitions, + includeWorkInProgressEffects + ); + } + parentFiber = parentFiber.sibling; + } + } + function recursivelyTraverseAtomicPassiveEffects( + finishedRoot$jscomp$0, + parentFiber + ) { + if (parentFiber.subtreeFlags & 10256) + for (parentFiber = parentFiber.child; null !== parentFiber; ) { + var finishedRoot = finishedRoot$jscomp$0, + finishedWork = parentFiber, + flags = finishedWork.flags; + switch (finishedWork.tag) { + case 22: + recursivelyTraverseAtomicPassiveEffects(finishedRoot, finishedWork); + flags & 2048 && + commitOffscreenPassiveMountEffects( + finishedWork.alternate, + finishedWork + ); + break; + case 24: + recursivelyTraverseAtomicPassiveEffects(finishedRoot, finishedWork); + flags & 2048 && + commitCachePassiveMountEffect( + finishedWork.alternate, + finishedWork + ); + break; + default: + recursivelyTraverseAtomicPassiveEffects(finishedRoot, finishedWork); + } + parentFiber = parentFiber.sibling; + } + } + function recursivelyAccumulateSuspenseyCommit(parentFiber) { + if (parentFiber.subtreeFlags & suspenseyCommitFlag) + for (parentFiber = parentFiber.child; null !== parentFiber; ) + accumulateSuspenseyCommitOnFiber(parentFiber), + (parentFiber = parentFiber.sibling); + } + function accumulateSuspenseyCommitOnFiber(fiber) { + switch (fiber.tag) { + case 26: + recursivelyAccumulateSuspenseyCommit(fiber); + fiber.flags & suspenseyCommitFlag && + (null !== fiber.memoizedState + ? suspendResource( + currentHoistableRoot, + fiber.memoizedState, + fiber.memoizedProps + ) + : suspendInstance(fiber.type, fiber.memoizedProps)); + break; + case 5: + recursivelyAccumulateSuspenseyCommit(fiber); + fiber.flags & suspenseyCommitFlag && + suspendInstance(fiber.type, fiber.memoizedProps); + break; + case 3: + case 4: + if (supportsResources) { + var previousHoistableRoot = currentHoistableRoot; + currentHoistableRoot = getHoistableRoot( + fiber.stateNode.containerInfo + ); + recursivelyAccumulateSuspenseyCommit(fiber); + currentHoistableRoot = previousHoistableRoot; + } else recursivelyAccumulateSuspenseyCommit(fiber); + break; + case 22: + null === fiber.memoizedState && + ((previousHoistableRoot = fiber.alternate), + null !== previousHoistableRoot && + null !== previousHoistableRoot.memoizedState + ? ((previousHoistableRoot = suspenseyCommitFlag), + (suspenseyCommitFlag = 16777216), + recursivelyAccumulateSuspenseyCommit(fiber), + (suspenseyCommitFlag = previousHoistableRoot)) + : recursivelyAccumulateSuspenseyCommit(fiber)); + break; + default: + recursivelyAccumulateSuspenseyCommit(fiber); + } + } + function detachAlternateSiblings(parentFiber) { + var previousFiber = parentFiber.alternate; + if ( + null !== previousFiber && + ((parentFiber = previousFiber.child), null !== parentFiber) + ) { + previousFiber.child = null; + do + (previousFiber = parentFiber.sibling), + (parentFiber.sibling = null), + (parentFiber = previousFiber); + while (null !== parentFiber); + } + } + function recursivelyTraversePassiveUnmountEffects(parentFiber) { + var deletions = parentFiber.deletions; + if (0 !== (parentFiber.flags & 16)) { + if (null !== deletions) + for (var i = 0; i < deletions.length; i++) { + var childToDelete = deletions[i]; + nextEffect = childToDelete; + commitPassiveUnmountEffectsInsideOfDeletedTree_begin( + childToDelete, + parentFiber + ); + } + detachAlternateSiblings(parentFiber); + } + if (parentFiber.subtreeFlags & 10256) + for (parentFiber = parentFiber.child; null !== parentFiber; ) + commitPassiveUnmountOnFiber(parentFiber), + (parentFiber = parentFiber.sibling); + } + function commitPassiveUnmountOnFiber(finishedWork) { + switch (finishedWork.tag) { + case 0: + case 11: + case 15: + recursivelyTraversePassiveUnmountEffects(finishedWork); + finishedWork.flags & 2048 && + commitHookEffectListUnmount(9, finishedWork, finishedWork.return); + break; + case 3: + recursivelyTraversePassiveUnmountEffects(finishedWork); + break; + case 12: + recursivelyTraversePassiveUnmountEffects(finishedWork); + break; + case 22: + var instance = finishedWork.stateNode; + null !== finishedWork.memoizedState && + instance._visibility & 4 && + (null === finishedWork.return || 13 !== finishedWork.return.tag) + ? ((instance._visibility &= -5), + recursivelyTraverseDisconnectPassiveEffects(finishedWork)) + : recursivelyTraversePassiveUnmountEffects(finishedWork); + break; + default: + recursivelyTraversePassiveUnmountEffects(finishedWork); + } + } + function recursivelyTraverseDisconnectPassiveEffects(parentFiber) { + var deletions = parentFiber.deletions; + if (0 !== (parentFiber.flags & 16)) { + if (null !== deletions) + for (var i = 0; i < deletions.length; i++) { + var childToDelete = deletions[i]; + nextEffect = childToDelete; + commitPassiveUnmountEffectsInsideOfDeletedTree_begin( + childToDelete, + parentFiber + ); + } + detachAlternateSiblings(parentFiber); + } + for (parentFiber = parentFiber.child; null !== parentFiber; ) { + deletions = parentFiber; + switch (deletions.tag) { + case 0: + case 11: + case 15: + commitHookEffectListUnmount(8, deletions, deletions.return); + recursivelyTraverseDisconnectPassiveEffects(deletions); + break; + case 22: + i = deletions.stateNode; + i._visibility & 4 && + ((i._visibility &= -5), + recursivelyTraverseDisconnectPassiveEffects(deletions)); + break; + default: + recursivelyTraverseDisconnectPassiveEffects(deletions); + } + parentFiber = parentFiber.sibling; + } + } + function commitPassiveUnmountEffectsInsideOfDeletedTree_begin( + deletedSubtreeRoot, + nearestMountedAncestor + ) { + for (; null !== nextEffect; ) { + var fiber = nextEffect; + switch (fiber.tag) { + case 0: + case 11: + case 15: + commitHookEffectListUnmount(8, fiber, nearestMountedAncestor); + break; + case 23: + case 22: + if ( + null !== fiber.memoizedState && + null !== fiber.memoizedState.cachePool + ) { + var cache = fiber.memoizedState.cachePool.pool; + null != cache && cache.refCount++; + } + break; + case 24: + releaseCache(fiber.memoizedState.cache); + } + cache = fiber.child; + if (null !== cache) (cache.return = fiber), (nextEffect = cache); + else + a: for (fiber = deletedSubtreeRoot; null !== nextEffect; ) { + cache = nextEffect; + var sibling = cache.sibling, + returnFiber = cache.return; + detachFiberAfterEffects(cache); + if (cache === fiber) { + nextEffect = null; + break a; + } + if (null !== sibling) { + sibling.return = returnFiber; + nextEffect = sibling; + break a; + } + nextEffect = returnFiber; + } + } + } + function findFiberRootForHostRoot(hostRoot) { + var maybeFiber = getInstanceFromNode(hostRoot); + if (null != maybeFiber) { + if ("string" !== typeof maybeFiber.memoizedProps["data-testname"]) + throw Error(formatProdErrorMessage(364)); + return maybeFiber; + } + hostRoot = findFiberRoot(hostRoot); + if (null === hostRoot) throw Error(formatProdErrorMessage(362)); + return hostRoot.stateNode.current; + } + function matchSelector(fiber$jscomp$0, selector) { + var tag = fiber$jscomp$0.tag; + switch (selector.$$typeof) { + case COMPONENT_TYPE: + if (fiber$jscomp$0.type === selector.value) return !0; + break; + case HAS_PSEUDO_CLASS_TYPE: + a: { + selector = selector.value; + fiber$jscomp$0 = [fiber$jscomp$0, 0]; + for (tag = 0; tag < fiber$jscomp$0.length; ) { + var fiber = fiber$jscomp$0[tag++], + tag$jscomp$0 = fiber.tag, + selectorIndex = fiber$jscomp$0[tag++], + selector$jscomp$0 = selector[selectorIndex]; + if ( + (5 !== tag$jscomp$0 && + 26 !== tag$jscomp$0 && + 27 !== tag$jscomp$0) || + !isHiddenSubtree(fiber) + ) { + for ( + ; + null != selector$jscomp$0 && + matchSelector(fiber, selector$jscomp$0); + + ) + selectorIndex++, (selector$jscomp$0 = selector[selectorIndex]); + if (selectorIndex === selector.length) { + selector = !0; + break a; + } else + for (fiber = fiber.child; null !== fiber; ) + fiber$jscomp$0.push(fiber, selectorIndex), + (fiber = fiber.sibling); + } + } + selector = !1; + } + return selector; + case ROLE_TYPE: + if ( + (5 === tag || 26 === tag || 27 === tag) && + matchAccessibilityRole(fiber$jscomp$0.stateNode, selector.value) + ) + return !0; + break; + case TEXT_TYPE: + if (5 === tag || 6 === tag || 26 === tag || 27 === tag) + if ( + ((fiber$jscomp$0 = getTextContent(fiber$jscomp$0)), + null !== fiber$jscomp$0 && + 0 <= fiber$jscomp$0.indexOf(selector.value)) + ) + return !0; + break; + case TEST_NAME_TYPE: + if (5 === tag || 26 === tag || 27 === tag) + if ( + ((fiber$jscomp$0 = fiber$jscomp$0.memoizedProps["data-testname"]), + "string" === typeof fiber$jscomp$0 && + fiber$jscomp$0.toLowerCase() === selector.value.toLowerCase()) + ) + return !0; + break; + default: + throw Error(formatProdErrorMessage(365)); + } + return !1; + } + function selectorToString(selector) { + switch (selector.$$typeof) { + case COMPONENT_TYPE: + return ( + "<" + (getComponentNameFromType(selector.value) || "Unknown") + ">" + ); + case HAS_PSEUDO_CLASS_TYPE: + return ":has(" + (selectorToString(selector) || "") + ")"; + case ROLE_TYPE: + return '[role="' + selector.value + '"]'; + case TEXT_TYPE: + return '"' + selector.value + '"'; + case TEST_NAME_TYPE: + return '[data-testname="' + selector.value + '"]'; + default: + throw Error(formatProdErrorMessage(365)); + } + } + function findPaths(root, selectors) { + var matchingFibers = []; + root = [root, 0]; + for (var index = 0; index < root.length; ) { + var fiber = root[index++], + tag = fiber.tag, + selectorIndex = root[index++], + selector = selectors[selectorIndex]; + if ((5 !== tag && 26 !== tag && 27 !== tag) || !isHiddenSubtree(fiber)) { + for (; null != selector && matchSelector(fiber, selector); ) + selectorIndex++, (selector = selectors[selectorIndex]); + if (selectorIndex === selectors.length) matchingFibers.push(fiber); + else + for (fiber = fiber.child; null !== fiber; ) + root.push(fiber, selectorIndex), (fiber = fiber.sibling); + } + } + return matchingFibers; + } + function findAllNodes(hostRoot, selectors) { + if (!supportsTestSelectors) throw Error(formatProdErrorMessage(363)); + hostRoot = findFiberRootForHostRoot(hostRoot); + hostRoot = findPaths(hostRoot, selectors); + selectors = []; + hostRoot = Array.from(hostRoot); + for (var index = 0; index < hostRoot.length; ) { + var node = hostRoot[index++], + tag = node.tag; + if (5 === tag || 26 === tag || 27 === tag) + isHiddenSubtree(node) || selectors.push(node.stateNode); + else + for (node = node.child; null !== node; ) + hostRoot.push(node), (node = node.sibling); + } + return selectors; + } + function requestUpdateLane() { + if (0 !== (executionContext & 2) && 0 !== workInProgressRootRenderLanes) + return workInProgressRootRenderLanes & -workInProgressRootRenderLanes; + if (null !== ReactSharedInternals.T) { + var actionScopeLane = currentEntangledLane; + return 0 !== actionScopeLane ? actionScopeLane : requestTransitionLane(); + } + return resolveUpdatePriority(); + } + function requestDeferredLane() { + 0 === workInProgressDeferredLane && + (workInProgressDeferredLane = + 0 === (workInProgressRootRenderLanes & 536870912) || isHydrating + ? claimNextTransitionLane() + : 536870912); + var suspenseHandler = suspenseHandlerStackCursor.current; + null !== suspenseHandler && (suspenseHandler.flags |= 32); + return workInProgressDeferredLane; + } + function scheduleUpdateOnFiber(root, fiber, lane) { + if ( + (root === workInProgressRoot && 2 === workInProgressSuspendedReason) || + null !== root.cancelPendingCommit + ) + prepareFreshStack(root, 0), + markRootSuspended( + root, + workInProgressRootRenderLanes, + workInProgressDeferredLane, + !1 + ); + markRootUpdated$1(root, lane); + if (0 === (executionContext & 2) || root !== workInProgressRoot) + root === workInProgressRoot && + (0 === (executionContext & 2) && + (workInProgressRootInterleavedUpdatedLanes |= lane), + 4 === workInProgressRootExitStatus && + markRootSuspended( + root, + workInProgressRootRenderLanes, + workInProgressDeferredLane, + !1 + )), + ensureRootIsScheduled(root); + } + function performWorkOnRoot(root$jscomp$0, lanes, forceSync) { + if (0 !== (executionContext & 6)) throw Error(formatProdErrorMessage(327)); + var shouldTimeSlice = + (!forceSync && + 0 === (lanes & 60) && + 0 === (lanes & root$jscomp$0.expiredLanes)) || + checkIfRootIsPrerendering(root$jscomp$0, lanes), + exitStatus = shouldTimeSlice + ? renderRootConcurrent(root$jscomp$0, lanes) + : renderRootSync(root$jscomp$0, lanes, !0), + renderWasConcurrent = shouldTimeSlice; + do { + if (0 === exitStatus) { + workInProgressRootIsPrerendering && + !shouldTimeSlice && + markRootSuspended(root$jscomp$0, lanes, 0, !1); + break; + } else if (6 === exitStatus) + markRootSuspended( + root$jscomp$0, + lanes, + 0, + !workInProgressRootDidSkipSuspendedSiblings + ); + else { + forceSync = root$jscomp$0.current.alternate; + if ( + renderWasConcurrent && + !isRenderConsistentWithExternalStores(forceSync) + ) { + exitStatus = renderRootSync(root$jscomp$0, lanes, !1); + renderWasConcurrent = !1; + continue; + } + if (2 === exitStatus) { + renderWasConcurrent = lanes; + if (root$jscomp$0.errorRecoveryDisabledLanes & renderWasConcurrent) + var JSCompiler_inline_result = 0; + else + (JSCompiler_inline_result = + root$jscomp$0.pendingLanes & -536870913), + (JSCompiler_inline_result = + 0 !== JSCompiler_inline_result + ? JSCompiler_inline_result + : JSCompiler_inline_result & 536870912 + ? 536870912 + : 0); + if (0 !== JSCompiler_inline_result) { + lanes = JSCompiler_inline_result; + a: { + var root = root$jscomp$0; + exitStatus = workInProgressRootConcurrentErrors; + var wasRootDehydrated = + supportsHydration && root.current.memoizedState.isDehydrated; + wasRootDehydrated && + (prepareFreshStack(root, JSCompiler_inline_result).flags |= + 256); + JSCompiler_inline_result = renderRootSync( + root, + JSCompiler_inline_result, + !1 + ); + if (2 !== JSCompiler_inline_result) { + if ( + workInProgressRootDidAttachPingListener && + !wasRootDehydrated + ) { + root.errorRecoveryDisabledLanes |= renderWasConcurrent; + workInProgressRootInterleavedUpdatedLanes |= + renderWasConcurrent; + exitStatus = 4; + break a; + } + renderWasConcurrent = workInProgressRootRecoverableErrors; + workInProgressRootRecoverableErrors = exitStatus; + null !== renderWasConcurrent && + queueRecoverableErrors(renderWasConcurrent); + } + exitStatus = JSCompiler_inline_result; + } + renderWasConcurrent = !1; + if (2 !== exitStatus) continue; + } + } + if (1 === exitStatus) { + prepareFreshStack(root$jscomp$0, 0); + markRootSuspended(root$jscomp$0, lanes, 0, !0); + break; + } + a: { + shouldTimeSlice = root$jscomp$0; + switch (exitStatus) { + case 0: + case 1: + throw Error(formatProdErrorMessage(345)); + case 4: + if ((lanes & 4194176) === lanes) { + markRootSuspended( + shouldTimeSlice, + lanes, + workInProgressDeferredLane, + !workInProgressRootDidSkipSuspendedSiblings + ); + break a; + } + break; + case 2: + workInProgressRootRecoverableErrors = null; + break; + case 3: + case 5: + break; + default: + throw Error(formatProdErrorMessage(329)); + } + shouldTimeSlice.finishedWork = forceSync; + shouldTimeSlice.finishedLanes = lanes; + if ( + (lanes & 62914560) === lanes && + ((renderWasConcurrent = globalMostRecentFallbackTime + 300 - now()), + 10 < renderWasConcurrent) + ) { + markRootSuspended( + shouldTimeSlice, + lanes, + workInProgressDeferredLane, + !workInProgressRootDidSkipSuspendedSiblings + ); + if (0 !== getNextLanes(shouldTimeSlice, 0)) break a; + shouldTimeSlice.timeoutHandle = scheduleTimeout( + commitRootWhenReady.bind( + null, + shouldTimeSlice, + forceSync, + workInProgressRootRecoverableErrors, + workInProgressTransitions, + workInProgressRootDidIncludeRecursiveRenderUpdate, + lanes, + workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, + workInProgressRootDidSkipSuspendedSiblings, + 2, + -0, + 0 + ), + renderWasConcurrent + ); + break a; + } + commitRootWhenReady( + shouldTimeSlice, + forceSync, + workInProgressRootRecoverableErrors, + workInProgressTransitions, + workInProgressRootDidIncludeRecursiveRenderUpdate, + lanes, + workInProgressDeferredLane, + workInProgressRootInterleavedUpdatedLanes, + workInProgressSuspendedRetryLanes, + workInProgressRootDidSkipSuspendedSiblings, + 0, + -0, + 0 + ); + } + } + break; + } while (1); + ensureRootIsScheduled(root$jscomp$0); + } + function queueRecoverableErrors(errors) { + null === workInProgressRootRecoverableErrors + ? (workInProgressRootRecoverableErrors = errors) + : workInProgressRootRecoverableErrors.push.apply( + workInProgressRootRecoverableErrors, + errors + ); + } + function commitRootWhenReady( + root, + finishedWork, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + lanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + didSkipSuspendedSiblings, + suspendedCommitReason, + completedRenderStartTime, + completedRenderEndTime + ) { + var subtreeFlags = finishedWork.subtreeFlags; + if (subtreeFlags & 8192 || 16785408 === (subtreeFlags & 16785408)) + if ( + (startSuspendingCommit(), + accumulateSuspenseyCommitOnFiber(finishedWork), + (finishedWork = waitForCommitToBeReady()), + null !== finishedWork) + ) { + root.cancelPendingCommit = finishedWork( + commitRoot.bind( + null, + root, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + 1, + completedRenderStartTime, + completedRenderEndTime + ) + ); + markRootSuspended(root, lanes, spawnedLane, !didSkipSuspendedSiblings); + return; + } + commitRoot( + root, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + suspendedCommitReason, + completedRenderStartTime, + completedRenderEndTime + ); + } + function isRenderConsistentWithExternalStores(finishedWork) { + for (var node = finishedWork; ; ) { + var tag = node.tag; + if ( + (0 === tag || 11 === tag || 15 === tag) && + node.flags & 16384 && + ((tag = node.updateQueue), + null !== tag && ((tag = tag.stores), null !== tag)) + ) + for (var i = 0; i < tag.length; i++) { + var check = tag[i], + getSnapshot = check.getSnapshot; + check = check.value; + try { + if (!objectIs(getSnapshot(), check)) return !1; + } catch (error) { + return !1; + } + } + tag = node.child; + if (node.subtreeFlags & 16384 && null !== tag) + (tag.return = node), (node = tag); + else { + if (node === finishedWork) break; + for (; null === node.sibling; ) { + if (null === node.return || node.return === finishedWork) return !0; + node = node.return; + } + node.sibling.return = node.return; + node = node.sibling; + } + } + return !0; + } + function markRootSuspended( + root, + suspendedLanes, + spawnedLane, + didAttemptEntireTree + ) { + suspendedLanes &= ~workInProgressRootPingedLanes; + suspendedLanes &= ~workInProgressRootInterleavedUpdatedLanes; + root.suspendedLanes |= suspendedLanes; + root.pingedLanes &= ~suspendedLanes; + didAttemptEntireTree && (root.warmLanes |= suspendedLanes); + didAttemptEntireTree = root.expirationTimes; + for (var lanes = suspendedLanes; 0 < lanes; ) { + var index$6 = 31 - clz32(lanes), + lane = 1 << index$6; + didAttemptEntireTree[index$6] = -1; + lanes &= ~lane; + } + 0 !== spawnedLane && + markSpawnedDeferredLane(root, spawnedLane, suspendedLanes); + } + function flushSyncWork() { + return 0 === (executionContext & 6) + ? (flushSyncWorkAcrossRoots_impl(0, !1), !1) + : !0; + } + function resetWorkInProgressStack() { + if (null !== workInProgress) { + if (0 === workInProgressSuspendedReason) + var interruptedWork = workInProgress.return; + else + (interruptedWork = workInProgress), + (lastContextDependency = currentlyRenderingFiber = null), + resetHooksOnUnwind(interruptedWork), + (thenableState$1 = null), + (thenableIndexCounter$1 = 0), + (interruptedWork = workInProgress); + for (; null !== interruptedWork; ) + unwindInterruptedWork(interruptedWork.alternate, interruptedWork), + (interruptedWork = interruptedWork.return); + workInProgress = null; + } + } + function prepareFreshStack(root, lanes) { + root.finishedWork = null; + root.finishedLanes = 0; + var timeoutHandle = root.timeoutHandle; + timeoutHandle !== noTimeout && + ((root.timeoutHandle = noTimeout), cancelTimeout(timeoutHandle)); + timeoutHandle = root.cancelPendingCommit; + null !== timeoutHandle && + ((root.cancelPendingCommit = null), timeoutHandle()); + resetWorkInProgressStack(); + workInProgressRoot = root; + workInProgress = timeoutHandle = createWorkInProgress(root.current, null); + workInProgressRootRenderLanes = lanes; + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + workInProgressRootDidSkipSuspendedSiblings = !1; + workInProgressRootIsPrerendering = checkIfRootIsPrerendering(root, lanes); + workInProgressRootDidAttachPingListener = !1; + workInProgressSuspendedRetryLanes = + workInProgressDeferredLane = + workInProgressRootPingedLanes = + workInProgressRootInterleavedUpdatedLanes = + workInProgressRootSkippedLanes = + workInProgressRootExitStatus = + 0; + workInProgressRootRecoverableErrors = workInProgressRootConcurrentErrors = + null; + workInProgressRootDidIncludeRecursiveRenderUpdate = !1; + 0 !== (lanes & 8) && (lanes |= lanes & 32); + var allEntangledLanes = root.entangledLanes; + if (0 !== allEntangledLanes) + for ( + root = root.entanglements, allEntangledLanes &= lanes; + 0 < allEntangledLanes; + + ) { + var index$4 = 31 - clz32(allEntangledLanes), + lane = 1 << index$4; + lanes |= root[index$4]; + allEntangledLanes &= ~lane; + } + entangledRenderLanes = lanes; + finishQueueingConcurrentUpdates(); + return timeoutHandle; + } + function handleThrow(root, thrownValue) { + currentlyRenderingFiber$1 = null; + ReactSharedInternals.H = ContextOnlyDispatcher; + thrownValue === SuspenseException + ? ((thrownValue = getSuspendedThenable()), + (workInProgressSuspendedReason = 3)) + : thrownValue === SuspenseyCommitException + ? ((thrownValue = getSuspendedThenable()), + (workInProgressSuspendedReason = 4)) + : (workInProgressSuspendedReason = + thrownValue === SelectiveHydrationException + ? 8 + : null !== thrownValue && + "object" === typeof thrownValue && + "function" === typeof thrownValue.then + ? 6 + : 1); + workInProgressThrownValue = thrownValue; + null === workInProgress && + ((workInProgressRootExitStatus = 1), + logUncaughtError( + root, + createCapturedValueAtFiber(thrownValue, root.current) + )); + } + function shouldRemainOnPreviousScreen() { + var handler = suspenseHandlerStackCursor.current; + return null === handler + ? !0 + : (workInProgressRootRenderLanes & 4194176) === + workInProgressRootRenderLanes + ? null === shellBoundary + ? !0 + : !1 + : (workInProgressRootRenderLanes & 62914560) === + workInProgressRootRenderLanes || + 0 !== (workInProgressRootRenderLanes & 536870912) + ? handler === shellBoundary + : !1; + } + function pushDispatcher() { + var prevDispatcher = ReactSharedInternals.H; + ReactSharedInternals.H = ContextOnlyDispatcher; + return null === prevDispatcher ? ContextOnlyDispatcher : prevDispatcher; + } + function pushAsyncDispatcher() { + var prevAsyncDispatcher = ReactSharedInternals.A; + ReactSharedInternals.A = DefaultAsyncDispatcher; + return prevAsyncDispatcher; + } + function renderDidSuspendDelayIfPossible() { + workInProgressRootExitStatus = 4; + workInProgressRootDidSkipSuspendedSiblings || + ((workInProgressRootRenderLanes & 4194176) !== + workInProgressRootRenderLanes && + null !== suspenseHandlerStackCursor.current) || + (workInProgressRootIsPrerendering = !0); + (0 === (workInProgressRootSkippedLanes & 134217727) && + 0 === (workInProgressRootInterleavedUpdatedLanes & 134217727)) || + null === workInProgressRoot || + markRootSuspended( + workInProgressRoot, + workInProgressRootRenderLanes, + workInProgressDeferredLane, + !1 + ); + } + function renderRootSync(root, lanes, shouldYieldForPrerendering) { + var prevExecutionContext = executionContext; + executionContext |= 2; + var prevDispatcher = pushDispatcher(), + prevAsyncDispatcher = pushAsyncDispatcher(); + if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) + (workInProgressTransitions = null), prepareFreshStack(root, lanes); + lanes = !1; + var exitStatus = workInProgressRootExitStatus; + a: do + try { + if (0 !== workInProgressSuspendedReason && null !== workInProgress) { + var unitOfWork = workInProgress, + thrownValue = workInProgressThrownValue; + switch (workInProgressSuspendedReason) { + case 8: + resetWorkInProgressStack(); + exitStatus = 6; + break a; + case 3: + case 2: + case 6: + null === suspenseHandlerStackCursor.current && (lanes = !0); + var reason = workInProgressSuspendedReason; + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue, reason); + if ( + shouldYieldForPrerendering && + workInProgressRootIsPrerendering + ) { + exitStatus = 0; + break a; + } + break; + default: + (reason = workInProgressSuspendedReason), + (workInProgressSuspendedReason = 0), + (workInProgressThrownValue = null), + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue, reason); + } + } + workLoopSync(); + exitStatus = workInProgressRootExitStatus; + break; + } catch (thrownValue$151) { + handleThrow(root, thrownValue$151); + } + while (1); + lanes && root.shellSuspendCounter++; + lastContextDependency = currentlyRenderingFiber = null; + executionContext = prevExecutionContext; + ReactSharedInternals.H = prevDispatcher; + ReactSharedInternals.A = prevAsyncDispatcher; + null === workInProgress && + ((workInProgressRoot = null), + (workInProgressRootRenderLanes = 0), + finishQueueingConcurrentUpdates()); + return exitStatus; + } + function workLoopSync() { + for (; null !== workInProgress; ) performUnitOfWork(workInProgress); + } + function renderRootConcurrent(root, lanes) { + var prevExecutionContext = executionContext; + executionContext |= 2; + var prevDispatcher = pushDispatcher(), + prevAsyncDispatcher = pushAsyncDispatcher(); + workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes + ? ((workInProgressTransitions = null), + (workInProgressRootRenderTargetTime = now() + 500), + prepareFreshStack(root, lanes)) + : (workInProgressRootIsPrerendering = checkIfRootIsPrerendering( + root, + lanes + )); + a: do + try { + if (0 !== workInProgressSuspendedReason && null !== workInProgress) { + lanes = workInProgress; + var thrownValue = workInProgressThrownValue; + b: switch (workInProgressSuspendedReason) { + case 1: + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + throwAndUnwindWorkLoop(root, lanes, thrownValue, 1); + break; + case 2: + if (isThenableResolved(thrownValue)) { + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + replaySuspendedUnitOfWork(lanes); + break; + } + lanes = function () { + 2 === workInProgressSuspendedReason && + workInProgressRoot === root && + (workInProgressSuspendedReason = 7); + ensureRootIsScheduled(root); + }; + thrownValue.then(lanes, lanes); + break a; + case 3: + workInProgressSuspendedReason = 7; + break a; + case 4: + workInProgressSuspendedReason = 5; + break a; + case 7: + isThenableResolved(thrownValue) + ? ((workInProgressSuspendedReason = 0), + (workInProgressThrownValue = null), + replaySuspendedUnitOfWork(lanes)) + : ((workInProgressSuspendedReason = 0), + (workInProgressThrownValue = null), + throwAndUnwindWorkLoop(root, lanes, thrownValue, 7)); + break; + case 5: + var resource = null; + switch (workInProgress.tag) { + case 26: + resource = workInProgress.memoizedState; + case 5: + case 27: + var hostFiber = workInProgress, + type = hostFiber.type, + props = hostFiber.pendingProps; + if ( + resource + ? preloadResource(resource) + : preloadInstance(type, props) + ) { + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + var sibling = hostFiber.sibling; + if (null !== sibling) workInProgress = sibling; + else { + var returnFiber = hostFiber.return; + null !== returnFiber + ? ((workInProgress = returnFiber), + completeUnitOfWork(returnFiber)) + : (workInProgress = null); + } + break b; + } + } + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + throwAndUnwindWorkLoop(root, lanes, thrownValue, 5); + break; + case 6: + workInProgressSuspendedReason = 0; + workInProgressThrownValue = null; + throwAndUnwindWorkLoop(root, lanes, thrownValue, 6); + break; + case 8: + resetWorkInProgressStack(); + workInProgressRootExitStatus = 6; + break a; + default: + throw Error(formatProdErrorMessage(462)); + } + } + workLoopConcurrent(); + break; + } catch (thrownValue$153) { + handleThrow(root, thrownValue$153); + } + while (1); + lastContextDependency = currentlyRenderingFiber = null; + ReactSharedInternals.H = prevDispatcher; + ReactSharedInternals.A = prevAsyncDispatcher; + executionContext = prevExecutionContext; + if (null !== workInProgress) return 0; + workInProgressRoot = null; + workInProgressRootRenderLanes = 0; + finishQueueingConcurrentUpdates(); + return workInProgressRootExitStatus; + } + function workLoopConcurrent() { + for (; null !== workInProgress && !shouldYield(); ) + performUnitOfWork(workInProgress); + } + function performUnitOfWork(unitOfWork) { + var next = beginWork( + unitOfWork.alternate, + unitOfWork, + entangledRenderLanes + ); + unitOfWork.memoizedProps = unitOfWork.pendingProps; + null === next ? completeUnitOfWork(unitOfWork) : (workInProgress = next); + } + function replaySuspendedUnitOfWork(unitOfWork) { + var next = unitOfWork; + var current = next.alternate; + switch (next.tag) { + case 15: + case 0: + next = replayFunctionComponent( + current, + next, + next.pendingProps, + next.type, + void 0, + workInProgressRootRenderLanes + ); + break; + case 11: + next = replayFunctionComponent( + current, + next, + next.pendingProps, + next.type.render, + next.ref, + workInProgressRootRenderLanes + ); + break; + case 5: + resetHooksOnUnwind(next); + default: + unwindInterruptedWork(current, next), + (next = workInProgress = + resetWorkInProgress(next, entangledRenderLanes)), + (next = beginWork(current, next, entangledRenderLanes)); + } + unitOfWork.memoizedProps = unitOfWork.pendingProps; + null === next ? completeUnitOfWork(unitOfWork) : (workInProgress = next); + } + function throwAndUnwindWorkLoop( + root, + unitOfWork, + thrownValue, + suspendedReason + ) { + lastContextDependency = currentlyRenderingFiber = null; + resetHooksOnUnwind(unitOfWork); + thenableState$1 = null; + thenableIndexCounter$1 = 0; + var returnFiber = unitOfWork.return; + try { + if ( + throwException( + root, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes + ) + ) { + workInProgressRootExitStatus = 1; + logUncaughtError( + root, + createCapturedValueAtFiber(thrownValue, root.current) + ); + workInProgress = null; + return; + } + } catch (error) { + if (null !== returnFiber) throw ((workInProgress = returnFiber), error); + workInProgressRootExitStatus = 1; + logUncaughtError( + root, + createCapturedValueAtFiber(thrownValue, root.current) + ); + workInProgress = null; + return; + } + if (unitOfWork.flags & 32768) { + if (isHydrating || 1 === suspendedReason) root = !0; + else if ( + workInProgressRootIsPrerendering || + 0 !== (workInProgressRootRenderLanes & 536870912) + ) + root = !1; + else if ( + ((workInProgressRootDidSkipSuspendedSiblings = root = !0), + 2 === suspendedReason || 3 === suspendedReason || 6 === suspendedReason) + ) + (suspendedReason = suspenseHandlerStackCursor.current), + null !== suspendedReason && + 13 === suspendedReason.tag && + (suspendedReason.flags |= 16384); + unwindUnitOfWork(unitOfWork, root); + } else completeUnitOfWork(unitOfWork); + } + function completeUnitOfWork(unitOfWork) { + var completedWork = unitOfWork; + do { + if (0 !== (completedWork.flags & 32768)) { + unwindUnitOfWork( + completedWork, + workInProgressRootDidSkipSuspendedSiblings + ); + return; + } + unitOfWork = completedWork.return; + var next = completeWork( + completedWork.alternate, + completedWork, + entangledRenderLanes + ); + if (null !== next) { + workInProgress = next; + return; + } + completedWork = completedWork.sibling; + if (null !== completedWork) { + workInProgress = completedWork; + return; + } + workInProgress = completedWork = unitOfWork; + } while (null !== completedWork); + 0 === workInProgressRootExitStatus && (workInProgressRootExitStatus = 5); + } + function unwindUnitOfWork(unitOfWork, skipSiblings) { + do { + var next = unwindWork(unitOfWork.alternate, unitOfWork); + if (null !== next) { + next.flags &= 32767; + workInProgress = next; + return; + } + next = unitOfWork.return; + null !== next && + ((next.flags |= 32768), + (next.subtreeFlags = 0), + (next.deletions = null)); + if ( + !skipSiblings && + ((unitOfWork = unitOfWork.sibling), null !== unitOfWork) + ) { + workInProgress = unitOfWork; + return; + } + workInProgress = unitOfWork = next; + } while (null !== unitOfWork); + workInProgressRootExitStatus = 6; + workInProgress = null; + } + function commitRoot( + root, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + suspendedCommitReason, + completedRenderStartTime, + completedRenderEndTime + ) { + var prevTransition = ReactSharedInternals.T, + previousUpdateLanePriority = getCurrentUpdatePriority(); + try { + setCurrentUpdatePriority(2), + (ReactSharedInternals.T = null), + commitRootImpl( + root, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + previousUpdateLanePriority, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + suspendedCommitReason, + completedRenderStartTime, + completedRenderEndTime + ); + } finally { + (ReactSharedInternals.T = prevTransition), + setCurrentUpdatePriority(previousUpdateLanePriority); + } + } + function commitRootImpl( + root, + recoverableErrors, + transitions, + didIncludeRenderPhaseUpdate, + renderPriorityLevel, + spawnedLane, + updatedLanes, + suspendedRetryLanes + ) { + do flushPassiveEffects(); + while (null !== rootWithPendingPassiveEffects); + if (0 !== (executionContext & 6)) throw Error(formatProdErrorMessage(327)); + var finishedWork = root.finishedWork; + didIncludeRenderPhaseUpdate = root.finishedLanes; + if (null === finishedWork) return null; + root.finishedWork = null; + root.finishedLanes = 0; + if (finishedWork === root.current) throw Error(formatProdErrorMessage(177)); + root.callbackNode = null; + root.callbackPriority = 0; + root.cancelPendingCommit = null; + var remainingLanes = finishedWork.lanes | finishedWork.childLanes; + remainingLanes |= concurrentlyUpdatedLanes; + markRootFinished( + root, + didIncludeRenderPhaseUpdate, + remainingLanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes + ); + root === workInProgressRoot && + ((workInProgress = workInProgressRoot = null), + (workInProgressRootRenderLanes = 0)); + (0 === (finishedWork.subtreeFlags & 10256) && + 0 === (finishedWork.flags & 10256)) || + rootDoesHavePassiveEffects || + ((rootDoesHavePassiveEffects = !0), + (pendingPassiveEffectsRemainingLanes = remainingLanes), + (pendingPassiveTransitions = transitions), + scheduleCallback(NormalPriority$1, function () { + flushPassiveEffects(!0); + return null; + })); + transitions = 0 !== (finishedWork.flags & 15990); + 0 !== (finishedWork.subtreeFlags & 15990) || transitions + ? ((transitions = ReactSharedInternals.T), + (ReactSharedInternals.T = null), + (spawnedLane = getCurrentUpdatePriority()), + setCurrentUpdatePriority(2), + (updatedLanes = executionContext), + (executionContext |= 4), + commitBeforeMutationEffects(root, finishedWork), + commitMutationEffectsOnFiber(finishedWork, root), + resetAfterCommit(root.containerInfo), + (root.current = finishedWork), + commitLayoutEffectOnFiber(root, finishedWork.alternate, finishedWork), + requestPaint(), + (executionContext = updatedLanes), + setCurrentUpdatePriority(spawnedLane), + (ReactSharedInternals.T = transitions)) + : (root.current = finishedWork); + rootDoesHavePassiveEffects + ? ((rootDoesHavePassiveEffects = !1), + (rootWithPendingPassiveEffects = root), + (pendingPassiveEffectsLanes = didIncludeRenderPhaseUpdate)) + : releaseRootPooledCache(root, remainingLanes); + remainingLanes = root.pendingLanes; + 0 === remainingLanes && (legacyErrorBoundariesThatAlreadyFailed = null); + onCommitRoot(finishedWork.stateNode, renderPriorityLevel); + ensureRootIsScheduled(root); + if (null !== recoverableErrors) + for ( + renderPriorityLevel = root.onRecoverableError, finishedWork = 0; + finishedWork < recoverableErrors.length; + finishedWork++ + ) + (remainingLanes = recoverableErrors[finishedWork]), + renderPriorityLevel(remainingLanes.value, { + componentStack: remainingLanes.stack + }); + 0 !== (pendingPassiveEffectsLanes & 3) && flushPassiveEffects(); + remainingLanes = root.pendingLanes; + 0 !== (didIncludeRenderPhaseUpdate & 4194218) && 0 !== (remainingLanes & 42) + ? root === rootWithNestedUpdates + ? nestedUpdateCount++ + : ((nestedUpdateCount = 0), (rootWithNestedUpdates = root)) + : (nestedUpdateCount = 0); + flushSyncWorkAcrossRoots_impl(0, !1); + return null; + } + function releaseRootPooledCache(root, remainingLanes) { + 0 === (root.pooledCacheLanes &= remainingLanes) && + ((remainingLanes = root.pooledCache), + null != remainingLanes && + ((root.pooledCache = null), releaseCache(remainingLanes))); + } + function flushPassiveEffects() { + if (null !== rootWithPendingPassiveEffects) { + var root = rootWithPendingPassiveEffects, + remainingLanes = pendingPassiveEffectsRemainingLanes; + pendingPassiveEffectsRemainingLanes = 0; + var renderPriority = lanesToEventPriority(pendingPassiveEffectsLanes), + priority = 32 > renderPriority ? 32 : renderPriority; + renderPriority = ReactSharedInternals.T; + var previousPriority = getCurrentUpdatePriority(); + try { + setCurrentUpdatePriority(priority); + ReactSharedInternals.T = null; + if (null === rootWithPendingPassiveEffects) + var JSCompiler_inline_result = !1; + else { + priority = pendingPassiveTransitions; + pendingPassiveTransitions = null; + var root$jscomp$0 = rootWithPendingPassiveEffects, + lanes = pendingPassiveEffectsLanes; + rootWithPendingPassiveEffects = null; + pendingPassiveEffectsLanes = 0; + if (0 !== (executionContext & 6)) + throw Error(formatProdErrorMessage(331)); + var prevExecutionContext = executionContext; + executionContext |= 4; + commitPassiveUnmountOnFiber(root$jscomp$0.current); + commitPassiveMountOnFiber( + root$jscomp$0, + root$jscomp$0.current, + lanes, + priority + ); + executionContext = prevExecutionContext; + flushSyncWorkAcrossRoots_impl(0, !1); + if ( + injectedHook && + "function" === typeof injectedHook.onPostCommitFiberRoot + ) + try { + injectedHook.onPostCommitFiberRoot(rendererID, root$jscomp$0); + } catch (err) {} + JSCompiler_inline_result = !0; + } + return JSCompiler_inline_result; + } finally { + setCurrentUpdatePriority(previousPriority), + (ReactSharedInternals.T = renderPriority), + releaseRootPooledCache(root, remainingLanes); + } + } + return !1; + } + function captureCommitPhaseErrorOnRoot(rootFiber, sourceFiber, error) { + sourceFiber = createCapturedValueAtFiber(error, sourceFiber); + sourceFiber = createRootErrorUpdate(rootFiber.stateNode, sourceFiber, 2); + rootFiber = enqueueUpdate(rootFiber, sourceFiber, 2); + null !== rootFiber && + (markRootUpdated$1(rootFiber, 2), ensureRootIsScheduled(rootFiber)); + } + function captureCommitPhaseError(sourceFiber, nearestMountedAncestor, error) { + if (3 === sourceFiber.tag) + captureCommitPhaseErrorOnRoot(sourceFiber, sourceFiber, error); + else + for (; null !== nearestMountedAncestor; ) { + if (3 === nearestMountedAncestor.tag) { + captureCommitPhaseErrorOnRoot( + nearestMountedAncestor, + sourceFiber, + error + ); + break; + } else if (1 === nearestMountedAncestor.tag) { + var instance = nearestMountedAncestor.stateNode; + if ( + "function" === + typeof nearestMountedAncestor.type.getDerivedStateFromError || + ("function" === typeof instance.componentDidCatch && + (null === legacyErrorBoundariesThatAlreadyFailed || + !legacyErrorBoundariesThatAlreadyFailed.has(instance))) + ) { + sourceFiber = createCapturedValueAtFiber(error, sourceFiber); + error = createClassErrorUpdate(2); + instance = enqueueUpdate(nearestMountedAncestor, error, 2); + null !== instance && + (initializeClassErrorUpdate( + error, + instance, + nearestMountedAncestor, + sourceFiber + ), + markRootUpdated$1(instance, 2), + ensureRootIsScheduled(instance)); + break; + } + } + nearestMountedAncestor = nearestMountedAncestor.return; + } + } + function attachPingListener(root, wakeable, lanes) { + var pingCache = root.pingCache; + if (null === pingCache) { + pingCache = root.pingCache = new PossiblyWeakMap(); + var threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else + (threadIDs = pingCache.get(wakeable)), + void 0 === threadIDs && + ((threadIDs = new Set()), pingCache.set(wakeable, threadIDs)); + threadIDs.has(lanes) || + ((workInProgressRootDidAttachPingListener = !0), + threadIDs.add(lanes), + (root = pingSuspendedRoot.bind(null, root, wakeable, lanes)), + wakeable.then(root, root)); + } + function pingSuspendedRoot(root, wakeable, pingedLanes) { + var pingCache = root.pingCache; + null !== pingCache && pingCache.delete(wakeable); + root.pingedLanes |= root.suspendedLanes & pingedLanes; + root.warmLanes &= ~pingedLanes; + workInProgressRoot === root && + (workInProgressRootRenderLanes & pingedLanes) === pingedLanes && + (4 === workInProgressRootExitStatus || + (3 === workInProgressRootExitStatus && + (workInProgressRootRenderLanes & 62914560) === + workInProgressRootRenderLanes && + 300 > now() - globalMostRecentFallbackTime) + ? 0 === (executionContext & 2) && prepareFreshStack(root, 0) + : (workInProgressRootPingedLanes |= pingedLanes), + workInProgressSuspendedRetryLanes === workInProgressRootRenderLanes && + (workInProgressSuspendedRetryLanes = 0)); + ensureRootIsScheduled(root); + } + function retryTimedOutBoundary(boundaryFiber, retryLane) { + 0 === retryLane && (retryLane = claimNextRetryLane()); + boundaryFiber = enqueueConcurrentRenderForLane(boundaryFiber, retryLane); + null !== boundaryFiber && + (markRootUpdated$1(boundaryFiber, retryLane), + ensureRootIsScheduled(boundaryFiber)); + } + function retryDehydratedSuspenseBoundary(boundaryFiber) { + var suspenseState = boundaryFiber.memoizedState, + retryLane = 0; + null !== suspenseState && (retryLane = suspenseState.retryLane); + retryTimedOutBoundary(boundaryFiber, retryLane); + } + function resolveRetryWakeable(boundaryFiber, wakeable) { + var retryLane = 0; + switch (boundaryFiber.tag) { + case 13: + var retryCache = boundaryFiber.stateNode; + var suspenseState = boundaryFiber.memoizedState; + null !== suspenseState && (retryLane = suspenseState.retryLane); + break; + case 19: + retryCache = boundaryFiber.stateNode; + break; + case 22: + retryCache = boundaryFiber.stateNode._retryCache; + break; + default: + throw Error(formatProdErrorMessage(314)); + } + null !== retryCache && retryCache.delete(wakeable); + retryTimedOutBoundary(boundaryFiber, retryLane); + } + function scheduleCallback(priorityLevel, callback) { + return scheduleCallback$3(priorityLevel, callback); + } + function FiberNode(tag, pendingProps, key, mode) { + this.tag = tag; + this.key = key; + this.sibling = + this.child = + this.return = + this.stateNode = + this.type = + this.elementType = + null; + this.index = 0; + this.refCleanup = this.ref = null; + this.pendingProps = pendingProps; + this.dependencies = + this.memoizedState = + this.updateQueue = + this.memoizedProps = + null; + this.mode = mode; + this.subtreeFlags = this.flags = 0; + this.deletions = null; + this.childLanes = this.lanes = 0; + this.alternate = null; + } + function shouldConstruct(Component) { + Component = Component.prototype; + return !(!Component || !Component.isReactComponent); + } + function createWorkInProgress(current, pendingProps) { + var workInProgress = current.alternate; + null === workInProgress + ? ((workInProgress = createFiber( + current.tag, + pendingProps, + current.key, + current.mode + )), + (workInProgress.elementType = current.elementType), + (workInProgress.type = current.type), + (workInProgress.stateNode = current.stateNode), + (workInProgress.alternate = current), + (current.alternate = workInProgress)) + : ((workInProgress.pendingProps = pendingProps), + (workInProgress.type = current.type), + (workInProgress.flags = 0), + (workInProgress.subtreeFlags = 0), + (workInProgress.deletions = null)); + workInProgress.flags = current.flags & 31457280; + workInProgress.childLanes = current.childLanes; + workInProgress.lanes = current.lanes; + workInProgress.child = current.child; + workInProgress.memoizedProps = current.memoizedProps; + workInProgress.memoizedState = current.memoizedState; + workInProgress.updateQueue = current.updateQueue; + pendingProps = current.dependencies; + workInProgress.dependencies = + null === pendingProps + ? null + : { + lanes: pendingProps.lanes, + firstContext: pendingProps.firstContext + }; + workInProgress.sibling = current.sibling; + workInProgress.index = current.index; + workInProgress.ref = current.ref; + workInProgress.refCleanup = current.refCleanup; + return workInProgress; + } + function resetWorkInProgress(workInProgress, renderLanes) { + workInProgress.flags &= 31457282; + var current = workInProgress.alternate; + null === current + ? ((workInProgress.childLanes = 0), + (workInProgress.lanes = renderLanes), + (workInProgress.child = null), + (workInProgress.subtreeFlags = 0), + (workInProgress.memoizedProps = null), + (workInProgress.memoizedState = null), + (workInProgress.updateQueue = null), + (workInProgress.dependencies = null), + (workInProgress.stateNode = null)) + : ((workInProgress.childLanes = current.childLanes), + (workInProgress.lanes = current.lanes), + (workInProgress.child = current.child), + (workInProgress.subtreeFlags = 0), + (workInProgress.deletions = null), + (workInProgress.memoizedProps = current.memoizedProps), + (workInProgress.memoizedState = current.memoizedState), + (workInProgress.updateQueue = current.updateQueue), + (workInProgress.type = current.type), + (renderLanes = current.dependencies), + (workInProgress.dependencies = + null === renderLanes + ? null + : { + lanes: renderLanes.lanes, + firstContext: renderLanes.firstContext + })); + return workInProgress; + } + function createFiberFromTypeAndProps( + type, + key, + pendingProps, + owner, + mode, + lanes + ) { + var fiberTag = 0; + owner = type; + if ("function" === typeof type) shouldConstruct(type) && (fiberTag = 1); + else if ("string" === typeof type) + fiberTag = + supportsResources && supportsSingletons + ? isHostHoistableType(type, pendingProps, contextStackCursor.current) + ? 26 + : isHostSingletonType(type) + ? 27 + : 5 + : supportsResources + ? isHostHoistableType( + type, + pendingProps, + contextStackCursor.current + ) + ? 26 + : 5 + : supportsSingletons + ? isHostSingletonType(type) + ? 27 + : 5 + : 5; + else + a: switch (type) { + case REACT_FRAGMENT_TYPE: + return createFiberFromFragment( + pendingProps.children, + mode, + lanes, + key + ); + case REACT_STRICT_MODE_TYPE: + fiberTag = 8; + mode |= 24; + break; + case REACT_PROFILER_TYPE: + return ( + (type = createFiber(12, pendingProps, key, mode | 2)), + (type.elementType = REACT_PROFILER_TYPE), + (type.lanes = lanes), + type + ); + case REACT_SUSPENSE_TYPE: + return ( + (type = createFiber(13, pendingProps, key, mode)), + (type.elementType = REACT_SUSPENSE_TYPE), + (type.lanes = lanes), + type + ); + case REACT_SUSPENSE_LIST_TYPE: + return ( + (type = createFiber(19, pendingProps, key, mode)), + (type.elementType = REACT_SUSPENSE_LIST_TYPE), + (type.lanes = lanes), + type + ); + case REACT_OFFSCREEN_TYPE: + return createFiberFromOffscreen(pendingProps, mode, lanes, key); + default: + if ("object" === typeof type && null !== type) + switch (type.$$typeof) { + case REACT_PROVIDER_TYPE: + case REACT_CONTEXT_TYPE: + fiberTag = 10; + break a; + case REACT_CONSUMER_TYPE: + fiberTag = 9; + break a; + case REACT_FORWARD_REF_TYPE: + fiberTag = 11; + break a; + case REACT_MEMO_TYPE: + fiberTag = 14; + break a; + case REACT_LAZY_TYPE: + fiberTag = 16; + owner = null; + break a; + } + fiberTag = 29; + pendingProps = Error( + formatProdErrorMessage( + 130, + null === type ? "null" : typeof type, + "" + ) + ); + owner = null; + } + key = createFiber(fiberTag, pendingProps, key, mode); + key.elementType = type; + key.type = owner; + key.lanes = lanes; + return key; + } + function createFiberFromFragment(elements, mode, lanes, key) { + elements = createFiber(7, elements, key, mode); + elements.lanes = lanes; + return elements; + } + function createFiberFromOffscreen(pendingProps, mode, lanes, key) { + pendingProps = createFiber(22, pendingProps, key, mode); + pendingProps.elementType = REACT_OFFSCREEN_TYPE; + pendingProps.lanes = lanes; + var primaryChildInstance = { + _visibility: 1, + _pendingVisibility: 1, + _pendingMarkers: null, + _retryCache: null, + _transitions: null, + _current: null, + detach: function () { + var fiber = primaryChildInstance._current; + if (null === fiber) throw Error(formatProdErrorMessage(456)); + if (0 === (primaryChildInstance._pendingVisibility & 2)) { + var root = enqueueConcurrentRenderForLane(fiber, 2); + null !== root && + ((primaryChildInstance._pendingVisibility |= 2), + scheduleUpdateOnFiber(root, fiber, 2)); + } + }, + attach: function () { + var fiber = primaryChildInstance._current; + if (null === fiber) throw Error(formatProdErrorMessage(456)); + if (0 !== (primaryChildInstance._pendingVisibility & 2)) { + var root = enqueueConcurrentRenderForLane(fiber, 2); + null !== root && + ((primaryChildInstance._pendingVisibility &= -3), + scheduleUpdateOnFiber(root, fiber, 2)); + } + } + }; + pendingProps.stateNode = primaryChildInstance; + return pendingProps; + } + function createFiberFromText(content, mode, lanes) { + content = createFiber(6, content, null, mode); + content.lanes = lanes; + return content; + } + function createFiberFromPortal(portal, mode, lanes) { + mode = createFiber( + 4, + null !== portal.children ? portal.children : [], + portal.key, + mode + ); + mode.lanes = lanes; + mode.stateNode = { + containerInfo: portal.containerInfo, + pendingChildren: null, + implementation: portal.implementation + }; + return mode; + } + function FiberRootNode( + containerInfo, + tag, + hydrate, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + formState + ) { + this.tag = 1; + this.containerInfo = containerInfo; + this.finishedWork = + this.pingCache = + this.current = + this.pendingChildren = + null; + this.timeoutHandle = noTimeout; + this.callbackNode = + this.next = + this.pendingContext = + this.context = + this.cancelPendingCommit = + null; + this.callbackPriority = 0; + this.expirationTimes = createLaneMap(-1); + this.entangledLanes = + this.shellSuspendCounter = + this.errorRecoveryDisabledLanes = + this.finishedLanes = + this.expiredLanes = + this.warmLanes = + this.pingedLanes = + this.suspendedLanes = + this.pendingLanes = + 0; + this.entanglements = createLaneMap(0); + this.hiddenUpdates = createLaneMap(null); + this.identifierPrefix = identifierPrefix; + this.onUncaughtError = onUncaughtError; + this.onCaughtError = onCaughtError; + this.onRecoverableError = onRecoverableError; + this.pooledCache = null; + this.pooledCacheLanes = 0; + this.formState = formState; + this.incompleteTransitions = new Map(); + } + function createFiberRoot( + containerInfo, + tag, + hydrate, + initialChildren, + hydrationCallbacks, + isStrictMode, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + transitionCallbacks, + formState + ) { + containerInfo = new FiberRootNode( + containerInfo, + tag, + hydrate, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + formState + ); + tag = 1; + !0 === isStrictMode && (tag |= 24); + isStrictMode = createFiber(3, null, null, tag); + containerInfo.current = isStrictMode; + isStrictMode.stateNode = containerInfo; + tag = createCache(); + tag.refCount++; + containerInfo.pooledCache = tag; + tag.refCount++; + isStrictMode.memoizedState = { + element: initialChildren, + isDehydrated: hydrate, + cache: tag + }; + initializeUpdateQueue(isStrictMode); + return containerInfo; + } + function getContextForSubtree(parentComponent) { + if (!parentComponent) return emptyContextObject; + parentComponent = emptyContextObject; + return parentComponent; + } + function findHostInstance(component) { + var fiber = component._reactInternals; + if (void 0 === fiber) { + if ("function" === typeof component.render) + throw Error(formatProdErrorMessage(188)); + component = Object.keys(component).join(","); + throw Error(formatProdErrorMessage(268, component)); + } + component = findCurrentFiberUsingSlowPath(fiber); + component = null !== component ? findCurrentHostFiberImpl(component) : null; + return null === component ? null : getPublicInstance(component.stateNode); + } + function updateContainerImpl( + rootFiber, + lane, + element, + container, + parentComponent, + callback + ) { + parentComponent = getContextForSubtree(parentComponent); + null === container.context + ? (container.context = parentComponent) + : (container.pendingContext = parentComponent); + container = createUpdate(lane); + container.payload = { element: element }; + callback = void 0 === callback ? null : callback; + null !== callback && (container.callback = callback); + element = enqueueUpdate(rootFiber, container, lane); + null !== element && + (scheduleUpdateOnFiber(element, rootFiber, lane), + entangleTransitions(element, rootFiber, lane)); + } + function markRetryLaneImpl(fiber, retryLane) { + fiber = fiber.memoizedState; + if (null !== fiber && null !== fiber.dehydrated) { + var a = fiber.retryLane; + fiber.retryLane = 0 !== a && a < retryLane ? a : retryLane; + } + } + function markRetryLaneIfNotHydrated(fiber, retryLane) { + markRetryLaneImpl(fiber, retryLane); + (fiber = fiber.alternate) && markRetryLaneImpl(fiber, retryLane); + } + var exports = {}; + ("use strict"); + var React = require("react"), + Scheduler = require("scheduler"), + assign = Object.assign, + REACT_LEGACY_ELEMENT_TYPE = Symbol.for("react.element"), + REACT_ELEMENT_TYPE = Symbol.for("react.transitional.element"), + REACT_PORTAL_TYPE = Symbol.for("react.portal"), + REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"), + REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode"), + REACT_PROFILER_TYPE = Symbol.for("react.profiler"), + REACT_PROVIDER_TYPE = Symbol.for("react.provider"), + REACT_CONSUMER_TYPE = Symbol.for("react.consumer"), + REACT_CONTEXT_TYPE = Symbol.for("react.context"), + REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"), + REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"), + REACT_SUSPENSE_LIST_TYPE = Symbol.for("react.suspense_list"), + REACT_MEMO_TYPE = Symbol.for("react.memo"), + REACT_LAZY_TYPE = Symbol.for("react.lazy"); + Symbol.for("react.scope"); + Symbol.for("react.debug_trace_mode"); + var REACT_OFFSCREEN_TYPE = Symbol.for("react.offscreen"); + Symbol.for("react.legacy_hidden"); + Symbol.for("react.tracing_marker"); + var REACT_MEMO_CACHE_SENTINEL = Symbol.for("react.memo_cache_sentinel"), + MAYBE_ITERATOR_SYMBOL = Symbol.iterator, + REACT_CLIENT_REFERENCE = Symbol.for("react.client.reference"), + ReactSharedInternals = + React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, + prefix, + suffix, + reentry = !1, + isArrayImpl = Array.isArray, + rendererVersion = $$$config.rendererVersion, + rendererPackageName = $$$config.rendererPackageName, + extraDevToolsConfig = $$$config.extraDevToolsConfig, + getPublicInstance = $$$config.getPublicInstance, + getRootHostContext = $$$config.getRootHostContext, + getChildHostContext = $$$config.getChildHostContext, + prepareForCommit = $$$config.prepareForCommit, + resetAfterCommit = $$$config.resetAfterCommit, + createInstance = $$$config.createInstance, + appendInitialChild = $$$config.appendInitialChild, + finalizeInitialChildren = $$$config.finalizeInitialChildren, + shouldSetTextContent = $$$config.shouldSetTextContent, + createTextInstance = $$$config.createTextInstance, + scheduleTimeout = $$$config.scheduleTimeout, + cancelTimeout = $$$config.cancelTimeout, + noTimeout = $$$config.noTimeout, + isPrimaryRenderer = $$$config.isPrimaryRenderer; + $$$config.warnsIfNotActing; + var supportsMutation = $$$config.supportsMutation, + supportsPersistence = $$$config.supportsPersistence, + supportsHydration = $$$config.supportsHydration, + getInstanceFromNode = $$$config.getInstanceFromNode; + $$$config.beforeActiveInstanceBlur; + $$$config.afterActiveInstanceBlur; + var preparePortalMount = $$$config.preparePortalMount; + $$$config.prepareScopeUpdate; + $$$config.getInstanceFromScope; + var setCurrentUpdatePriority = $$$config.setCurrentUpdatePriority, + getCurrentUpdatePriority = $$$config.getCurrentUpdatePriority, + resolveUpdatePriority = $$$config.resolveUpdatePriority; + $$$config.resolveEventType; + $$$config.resolveEventTimeStamp; + var shouldAttemptEagerTransition = $$$config.shouldAttemptEagerTransition, + detachDeletedInstance = $$$config.detachDeletedInstance; + $$$config.requestPostPaintCallback; + var maySuspendCommit = $$$config.maySuspendCommit, + preloadInstance = $$$config.preloadInstance, + startSuspendingCommit = $$$config.startSuspendingCommit, + suspendInstance = $$$config.suspendInstance, + waitForCommitToBeReady = $$$config.waitForCommitToBeReady, + NotPendingTransition = $$$config.NotPendingTransition, + HostTransitionContext = $$$config.HostTransitionContext, + resetFormInstance = $$$config.resetFormInstance; + $$$config.bindToConsole; + var supportsMicrotasks = $$$config.supportsMicrotasks, + scheduleMicrotask = $$$config.scheduleMicrotask, + supportsTestSelectors = $$$config.supportsTestSelectors, + findFiberRoot = $$$config.findFiberRoot, + getBoundingRect = $$$config.getBoundingRect, + getTextContent = $$$config.getTextContent, + isHiddenSubtree = $$$config.isHiddenSubtree, + matchAccessibilityRole = $$$config.matchAccessibilityRole, + setFocusIfFocusable = $$$config.setFocusIfFocusable, + setupIntersectionObserver = $$$config.setupIntersectionObserver, + appendChild = $$$config.appendChild, + appendChildToContainer = $$$config.appendChildToContainer, + commitTextUpdate = $$$config.commitTextUpdate, + commitMount = $$$config.commitMount, + commitUpdate = $$$config.commitUpdate, + insertBefore = $$$config.insertBefore, + insertInContainerBefore = $$$config.insertInContainerBefore, + removeChild = $$$config.removeChild, + removeChildFromContainer = $$$config.removeChildFromContainer, + resetTextContent = $$$config.resetTextContent, + hideInstance = $$$config.hideInstance, + hideTextInstance = $$$config.hideTextInstance, + unhideInstance = $$$config.unhideInstance, + unhideTextInstance = $$$config.unhideTextInstance, + clearContainer = $$$config.clearContainer, + cloneInstance = $$$config.cloneInstance, + createContainerChildSet = $$$config.createContainerChildSet, + appendChildToContainerChildSet = $$$config.appendChildToContainerChildSet, + finalizeContainerChildren = $$$config.finalizeContainerChildren, + replaceContainerChildren = $$$config.replaceContainerChildren, + cloneHiddenInstance = $$$config.cloneHiddenInstance, + cloneHiddenTextInstance = $$$config.cloneHiddenTextInstance, + isSuspenseInstancePending = $$$config.isSuspenseInstancePending, + isSuspenseInstanceFallback = $$$config.isSuspenseInstanceFallback, + getSuspenseInstanceFallbackErrorDetails = + $$$config.getSuspenseInstanceFallbackErrorDetails, + registerSuspenseInstanceRetry = $$$config.registerSuspenseInstanceRetry, + canHydrateFormStateMarker = $$$config.canHydrateFormStateMarker, + isFormStateMarkerMatching = $$$config.isFormStateMarkerMatching, + getNextHydratableSibling = $$$config.getNextHydratableSibling, + getFirstHydratableChild = $$$config.getFirstHydratableChild, + getFirstHydratableChildWithinContainer = + $$$config.getFirstHydratableChildWithinContainer, + getFirstHydratableChildWithinSuspenseInstance = + $$$config.getFirstHydratableChildWithinSuspenseInstance, + canHydrateInstance = $$$config.canHydrateInstance, + canHydrateTextInstance = $$$config.canHydrateTextInstance, + canHydrateSuspenseInstance = $$$config.canHydrateSuspenseInstance, + hydrateInstance = $$$config.hydrateInstance, + hydrateTextInstance = $$$config.hydrateTextInstance, + hydrateSuspenseInstance = $$$config.hydrateSuspenseInstance, + getNextHydratableInstanceAfterSuspenseInstance = + $$$config.getNextHydratableInstanceAfterSuspenseInstance, + commitHydratedContainer = $$$config.commitHydratedContainer, + commitHydratedSuspenseInstance = $$$config.commitHydratedSuspenseInstance, + clearSuspenseBoundary = $$$config.clearSuspenseBoundary, + clearSuspenseBoundaryFromContainer = + $$$config.clearSuspenseBoundaryFromContainer, + shouldDeleteUnhydratedTailInstances = + $$$config.shouldDeleteUnhydratedTailInstances; + $$$config.diffHydratedPropsForDevWarnings; + $$$config.diffHydratedTextForDevWarnings; + $$$config.describeHydratableInstanceForDevWarnings; + var validateHydratableInstance = $$$config.validateHydratableInstance, + validateHydratableTextInstance = $$$config.validateHydratableTextInstance, + supportsResources = $$$config.supportsResources, + isHostHoistableType = $$$config.isHostHoistableType, + getHoistableRoot = $$$config.getHoistableRoot, + getResource = $$$config.getResource, + acquireResource = $$$config.acquireResource, + releaseResource = $$$config.releaseResource, + hydrateHoistable = $$$config.hydrateHoistable, + mountHoistable = $$$config.mountHoistable, + unmountHoistable = $$$config.unmountHoistable, + createHoistableInstance = $$$config.createHoistableInstance, + prepareToCommitHoistables = $$$config.prepareToCommitHoistables, + mayResourceSuspendCommit = $$$config.mayResourceSuspendCommit, + preloadResource = $$$config.preloadResource, + suspendResource = $$$config.suspendResource, + supportsSingletons = $$$config.supportsSingletons, + resolveSingletonInstance = $$$config.resolveSingletonInstance, + clearSingleton = $$$config.clearSingleton, + acquireSingletonInstance = $$$config.acquireSingletonInstance, + releaseSingletonInstance = $$$config.releaseSingletonInstance, + isHostSingletonType = $$$config.isHostSingletonType, + valueStack = [], + index$jscomp$0 = -1, + emptyContextObject = {}, + clz32 = Math.clz32 ? Math.clz32 : clz32Fallback, + log$1 = Math.log, + LN2 = Math.LN2, + nextTransitionLane = 128, + nextRetryLane = 4194304, + scheduleCallback$3 = Scheduler.unstable_scheduleCallback, + cancelCallback$1 = Scheduler.unstable_cancelCallback, + shouldYield = Scheduler.unstable_shouldYield, + requestPaint = Scheduler.unstable_requestPaint, + now = Scheduler.unstable_now, + ImmediatePriority = Scheduler.unstable_ImmediatePriority, + UserBlockingPriority = Scheduler.unstable_UserBlockingPriority, + NormalPriority$1 = Scheduler.unstable_NormalPriority, + IdlePriority = Scheduler.unstable_IdlePriority, + log = Scheduler.log, + unstable_setDisableYieldValue = Scheduler.unstable_setDisableYieldValue, + rendererID = null, + injectedHook = null, + objectIs = "function" === typeof Object.is ? Object.is : is, + CapturedStacks = new WeakMap(), + forkStack = [], + forkStackIndex = 0, + treeForkProvider = null, + treeForkCount = 0, + idStack = [], + idStackIndex = 0, + treeContextProvider = null, + treeContextId = 1, + treeContextOverflow = "", + contextStackCursor = createCursor(null), + contextFiberStackCursor = createCursor(null), + rootInstanceStackCursor = createCursor(null), + hostTransitionProviderCursor = createCursor(null), + hydrationParentFiber = null, + nextHydratableInstance = null, + isHydrating = !1, + hydrationErrors = null, + rootOrSingletonContext = !1, + HydrationMismatchException = Error(formatProdErrorMessage(519)), + concurrentQueues = [], + concurrentQueuesIndex = 0, + concurrentlyUpdatedLanes = 0, + firstScheduledRoot = null, + lastScheduledRoot = null, + didScheduleMicrotask = !1, + mightHavePendingSyncWork = !1, + isFlushingWork = !1, + currentEventTransitionLane = 0, + currentEntangledListeners = null, + currentEntangledPendingCount = 0, + currentEntangledLane = 0, + currentEntangledActionThenable = null, + hasForceUpdate = !1, + didReadFromEntangledAsyncAction = !1, + hasOwnProperty = Object.prototype.hasOwnProperty, + SuspenseException = Error(formatProdErrorMessage(460)), + SuspenseyCommitException = Error(formatProdErrorMessage(474)), + noopSuspenseyCommitThenable = { then: function () {} }, + suspendedThenable = null, + thenableState$1 = null, + thenableIndexCounter$1 = 0, + reconcileChildFibers = createChildReconciler(!0), + mountChildFibers = createChildReconciler(!1), + currentTreeHiddenStackCursor = createCursor(null), + prevEntangledRenderLanesCursor = createCursor(0), + suspenseHandlerStackCursor = createCursor(null), + shellBoundary = null, + suspenseStackCursor = createCursor(0), + renderLanes = 0, + currentlyRenderingFiber$1 = null, + currentHook = null, + workInProgressHook = null, + didScheduleRenderPhaseUpdate = !1, + didScheduleRenderPhaseUpdateDuringThisPass = !1, + shouldDoubleInvokeUserFnsInHooksDEV = !1, + localIdCounter = 0, + thenableIndexCounter = 0, + thenableState = null, + globalClientIdCounter = 0; + var createFunctionComponentUpdateQueue = function () { + return { lastEffect: null, events: null, stores: null, memoCache: null }; + }; + var ContextOnlyDispatcher = { + readContext: readContext, + use: use, + useCallback: throwInvalidHookError, + useContext: throwInvalidHookError, + useEffect: throwInvalidHookError, + useImperativeHandle: throwInvalidHookError, + useLayoutEffect: throwInvalidHookError, + useInsertionEffect: throwInvalidHookError, + useMemo: throwInvalidHookError, + useReducer: throwInvalidHookError, + useRef: throwInvalidHookError, + useState: throwInvalidHookError, + useDebugValue: throwInvalidHookError, + useDeferredValue: throwInvalidHookError, + useTransition: throwInvalidHookError, + useSyncExternalStore: throwInvalidHookError, + useId: throwInvalidHookError + }; + ContextOnlyDispatcher.useCacheRefresh = throwInvalidHookError; + ContextOnlyDispatcher.useMemoCache = throwInvalidHookError; + ContextOnlyDispatcher.useHostTransitionStatus = throwInvalidHookError; + ContextOnlyDispatcher.useFormState = throwInvalidHookError; + ContextOnlyDispatcher.useActionState = throwInvalidHookError; + ContextOnlyDispatcher.useOptimistic = throwInvalidHookError; + var HooksDispatcherOnMount = { + readContext: readContext, + use: use, + useCallback: function (callback, deps) { + mountWorkInProgressHook().memoizedState = [ + callback, + void 0 === deps ? null : deps + ]; + return callback; + }, + useContext: readContext, + useEffect: mountEffect, + useImperativeHandle: function (ref, create, deps) { + deps = null !== deps && void 0 !== deps ? deps.concat([ref]) : null; + mountEffectImpl( + 4194308, + 4, + imperativeHandleEffect.bind(null, create, ref), + deps + ); + }, + useLayoutEffect: function (create, deps) { + return mountEffectImpl(4194308, 4, create, deps); + }, + useInsertionEffect: function (create, deps) { + mountEffectImpl(4, 2, create, deps); + }, + useMemo: function (nextCreate, deps) { + var hook = mountWorkInProgressHook(); + deps = void 0 === deps ? null : deps; + var nextValue = nextCreate(); + if (shouldDoubleInvokeUserFnsInHooksDEV) { + setIsStrictModeForDevtools(!0); + try { + nextCreate(); + } finally { + setIsStrictModeForDevtools(!1); + } + } + hook.memoizedState = [nextValue, deps]; + return nextValue; + }, + useReducer: function (reducer, initialArg, init) { + var hook = mountWorkInProgressHook(); + if (void 0 !== init) { + var initialState = init(initialArg); + if (shouldDoubleInvokeUserFnsInHooksDEV) { + setIsStrictModeForDevtools(!0); + try { + init(initialArg); + } finally { + setIsStrictModeForDevtools(!1); + } + } + } else initialState = initialArg; + hook.memoizedState = hook.baseState = initialState; + reducer = { + pending: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: reducer, + lastRenderedState: initialState + }; + hook.queue = reducer; + reducer = reducer.dispatch = dispatchReducerAction.bind( + null, + currentlyRenderingFiber$1, + reducer + ); + return [hook.memoizedState, reducer]; + }, + useRef: function (initialValue) { + var hook = mountWorkInProgressHook(); + initialValue = { current: initialValue }; + return (hook.memoizedState = initialValue); + }, + useState: function (initialState) { + initialState = mountStateImpl(initialState); + var queue = initialState.queue, + dispatch = dispatchSetState.bind( + null, + currentlyRenderingFiber$1, + queue + ); + queue.dispatch = dispatch; + return [initialState.memoizedState, dispatch]; + }, + useDebugValue: mountDebugValue, + useDeferredValue: function (value, initialValue) { + var hook = mountWorkInProgressHook(); + return mountDeferredValueImpl(hook, value, initialValue); + }, + useTransition: function () { + var stateHook = mountStateImpl(!1); + stateHook = startTransition.bind( + null, + currentlyRenderingFiber$1, + stateHook.queue, + !0, + !1 + ); + mountWorkInProgressHook().memoizedState = stateHook; + return [!1, stateHook]; + }, + useSyncExternalStore: function (subscribe, getSnapshot, getServerSnapshot) { + var fiber = currentlyRenderingFiber$1, + hook = mountWorkInProgressHook(); + if (isHydrating) { + if (void 0 === getServerSnapshot) + throw Error(formatProdErrorMessage(407)); + getServerSnapshot = getServerSnapshot(); + } else { + getServerSnapshot = getSnapshot(); + if (null === workInProgressRoot) + throw Error(formatProdErrorMessage(349)); + 0 !== (workInProgressRootRenderLanes & 60) || + pushStoreConsistencyCheck(fiber, getSnapshot, getServerSnapshot); + } + hook.memoizedState = getServerSnapshot; + var inst = { value: getServerSnapshot, getSnapshot: getSnapshot }; + hook.queue = inst; + mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [ + subscribe + ]); + fiber.flags |= 2048; + pushEffect( + 9, + updateStoreInstance.bind( + null, + fiber, + inst, + getServerSnapshot, + getSnapshot + ), + { destroy: void 0 }, + null + ); + return getServerSnapshot; + }, + useId: function () { + var hook = mountWorkInProgressHook(), + identifierPrefix = workInProgressRoot.identifierPrefix; + if (isHydrating) { + var JSCompiler_inline_result = treeContextOverflow; + var idWithLeadingBit = treeContextId; + JSCompiler_inline_result = + ( + idWithLeadingBit & ~(1 << (32 - clz32(idWithLeadingBit) - 1)) + ).toString(32) + JSCompiler_inline_result; + identifierPrefix = + ":" + identifierPrefix + "R" + JSCompiler_inline_result; + JSCompiler_inline_result = localIdCounter++; + 0 < JSCompiler_inline_result && + (identifierPrefix += "H" + JSCompiler_inline_result.toString(32)); + identifierPrefix += ":"; + } else + (JSCompiler_inline_result = globalClientIdCounter++), + (identifierPrefix = + ":" + + identifierPrefix + + "r" + + JSCompiler_inline_result.toString(32) + + ":"); + return (hook.memoizedState = identifierPrefix); + }, + useCacheRefresh: function () { + return (mountWorkInProgressHook().memoizedState = refreshCache.bind( + null, + currentlyRenderingFiber$1 + )); + } + }; + HooksDispatcherOnMount.useMemoCache = useMemoCache; + HooksDispatcherOnMount.useHostTransitionStatus = useHostTransitionStatus; + HooksDispatcherOnMount.useFormState = mountActionState; + HooksDispatcherOnMount.useActionState = mountActionState; + HooksDispatcherOnMount.useOptimistic = function (passthrough) { + var hook = mountWorkInProgressHook(); + hook.memoizedState = hook.baseState = passthrough; + var queue = { + pending: null, + lanes: 0, + dispatch: null, + lastRenderedReducer: null, + lastRenderedState: null + }; + hook.queue = queue; + hook = dispatchOptimisticSetState.bind( + null, + currentlyRenderingFiber$1, + !0, + queue + ); + queue.dispatch = hook; + return [passthrough, hook]; + }; + var HooksDispatcherOnUpdate = { + readContext: readContext, + use: use, + useCallback: updateCallback, + useContext: readContext, + useEffect: updateEffect, + useImperativeHandle: updateImperativeHandle, + useInsertionEffect: updateInsertionEffect, + useLayoutEffect: updateLayoutEffect, + useMemo: updateMemo, + useReducer: updateReducer, + useRef: updateRef, + useState: function () { + return updateReducer(basicStateReducer); + }, + useDebugValue: mountDebugValue, + useDeferredValue: function (value, initialValue) { + var hook = updateWorkInProgressHook(); + return updateDeferredValueImpl( + hook, + currentHook.memoizedState, + value, + initialValue + ); + }, + useTransition: function () { + var booleanOrThenable = updateReducer(basicStateReducer)[0], + start = updateWorkInProgressHook().memoizedState; + return [ + "boolean" === typeof booleanOrThenable + ? booleanOrThenable + : useThenable(booleanOrThenable), + start + ]; + }, + useSyncExternalStore: updateSyncExternalStore, + useId: updateId + }; + HooksDispatcherOnUpdate.useCacheRefresh = updateRefresh; + HooksDispatcherOnUpdate.useMemoCache = useMemoCache; + HooksDispatcherOnUpdate.useHostTransitionStatus = useHostTransitionStatus; + HooksDispatcherOnUpdate.useFormState = updateActionState; + HooksDispatcherOnUpdate.useActionState = updateActionState; + HooksDispatcherOnUpdate.useOptimistic = function (passthrough, reducer) { + var hook = updateWorkInProgressHook(); + return updateOptimisticImpl(hook, currentHook, passthrough, reducer); + }; + var HooksDispatcherOnRerender = { + readContext: readContext, + use: use, + useCallback: updateCallback, + useContext: readContext, + useEffect: updateEffect, + useImperativeHandle: updateImperativeHandle, + useInsertionEffect: updateInsertionEffect, + useLayoutEffect: updateLayoutEffect, + useMemo: updateMemo, + useReducer: rerenderReducer, + useRef: updateRef, + useState: function () { + return rerenderReducer(basicStateReducer); + }, + useDebugValue: mountDebugValue, + useDeferredValue: function (value, initialValue) { + var hook = updateWorkInProgressHook(); + return null === currentHook + ? mountDeferredValueImpl(hook, value, initialValue) + : updateDeferredValueImpl( + hook, + currentHook.memoizedState, + value, + initialValue + ); + }, + useTransition: function () { + var booleanOrThenable = rerenderReducer(basicStateReducer)[0], + start = updateWorkInProgressHook().memoizedState; + return [ + "boolean" === typeof booleanOrThenable + ? booleanOrThenable + : useThenable(booleanOrThenable), + start + ]; + }, + useSyncExternalStore: updateSyncExternalStore, + useId: updateId + }; + HooksDispatcherOnRerender.useCacheRefresh = updateRefresh; + HooksDispatcherOnRerender.useMemoCache = useMemoCache; + HooksDispatcherOnRerender.useHostTransitionStatus = useHostTransitionStatus; + HooksDispatcherOnRerender.useFormState = rerenderActionState; + HooksDispatcherOnRerender.useActionState = rerenderActionState; + HooksDispatcherOnRerender.useOptimistic = function (passthrough, reducer) { + var hook = updateWorkInProgressHook(); + if (null !== currentHook) + return updateOptimisticImpl(hook, currentHook, passthrough, reducer); + hook.baseState = passthrough; + return [passthrough, hook.queue.dispatch]; + }; + var classComponentUpdater = { + isMounted: function (component) { + return (component = component._reactInternals) + ? getNearestMountedFiber(component) === component + : !1; + }, + enqueueSetState: function (inst, payload, callback) { + inst = inst._reactInternals; + var lane = requestUpdateLane(), + update = createUpdate(lane); + update.payload = payload; + void 0 !== callback && + null !== callback && + (update.callback = callback); + payload = enqueueUpdate(inst, update, lane); + null !== payload && + (scheduleUpdateOnFiber(payload, inst, lane), + entangleTransitions(payload, inst, lane)); + }, + enqueueReplaceState: function (inst, payload, callback) { + inst = inst._reactInternals; + var lane = requestUpdateLane(), + update = createUpdate(lane); + update.tag = 1; + update.payload = payload; + void 0 !== callback && + null !== callback && + (update.callback = callback); + payload = enqueueUpdate(inst, update, lane); + null !== payload && + (scheduleUpdateOnFiber(payload, inst, lane), + entangleTransitions(payload, inst, lane)); + }, + enqueueForceUpdate: function (inst, callback) { + inst = inst._reactInternals; + var lane = requestUpdateLane(), + update = createUpdate(lane); + update.tag = 2; + void 0 !== callback && + null !== callback && + (update.callback = callback); + callback = enqueueUpdate(inst, update, lane); + null !== callback && + (scheduleUpdateOnFiber(callback, inst, lane), + entangleTransitions(callback, inst, lane)); + } + }, + reportGlobalError = + "function" === typeof reportError + ? reportError + : function (error) { + if ( + "object" === typeof window && + "function" === typeof window.ErrorEvent + ) { + var event = new window.ErrorEvent("error", { + bubbles: !0, + cancelable: !0, + message: + "object" === typeof error && + null !== error && + "string" === typeof error.message + ? String(error.message) + : String(error), + error: error + }); + if (!window.dispatchEvent(event)) return; + } else if ( + "object" === typeof process && + "function" === typeof process.emit + ) { + process.emit("uncaughtException", error); + return; + } + console.error(error); + }, + SelectiveHydrationException = Error(formatProdErrorMessage(461)), + didReceiveUpdate = !1, + SUSPENDED_MARKER = { dehydrated: null, treeContext: null, retryLane: 0 }, + valueCursor = createCursor(null), + currentlyRenderingFiber = null, + lastContextDependency = null, + AbortControllerLocal = + "undefined" !== typeof AbortController + ? AbortController + : function () { + var listeners = [], + signal = (this.signal = { + aborted: !1, + addEventListener: function (type, listener) { + listeners.push(listener); + } + }); + this.abort = function () { + signal.aborted = !0; + listeners.forEach(function (listener) { + return listener(); + }); + }; + }, + scheduleCallback$1 = Scheduler.unstable_scheduleCallback, + NormalPriority = Scheduler.unstable_NormalPriority, + CacheContext = { + $$typeof: REACT_CONTEXT_TYPE, + Consumer: null, + Provider: null, + _currentValue: null, + _currentValue2: null, + _threadCount: 0 + }, + prevOnStartTransitionFinish = ReactSharedInternals.S; + ReactSharedInternals.S = function (transition, returnValue) { + "object" === typeof returnValue && + null !== returnValue && + "function" === typeof returnValue.then && + entangleAsyncAction(transition, returnValue); + null !== prevOnStartTransitionFinish && + prevOnStartTransitionFinish(transition, returnValue); + }; + var resumedCache = createCursor(null), + offscreenSubtreeIsHidden = !1, + offscreenSubtreeWasHidden = !1, + needsFormReset = !1, + PossiblyWeakSet = "function" === typeof WeakSet ? WeakSet : Set, + nextEffect = null, + shouldFireAfterActiveInstanceBlur = !1, + hostParent = null, + hostParentIsContainer = !1, + currentHoistableRoot = null, + suspenseyCommitFlag = 8192, + DefaultAsyncDispatcher = { + getCacheForType: function (resourceType) { + var cache = readContext(CacheContext), + cacheForType = cache.data.get(resourceType); + void 0 === cacheForType && + ((cacheForType = resourceType()), + cache.data.set(resourceType, cacheForType)); + return cacheForType; + } + }, + COMPONENT_TYPE = 0, + HAS_PSEUDO_CLASS_TYPE = 1, + ROLE_TYPE = 2, + TEST_NAME_TYPE = 3, + TEXT_TYPE = 4; + if ("function" === typeof Symbol && Symbol.for) { + var symbolFor = Symbol.for; + COMPONENT_TYPE = symbolFor("selector.component"); + HAS_PSEUDO_CLASS_TYPE = symbolFor("selector.has_pseudo_class"); + ROLE_TYPE = symbolFor("selector.role"); + TEST_NAME_TYPE = symbolFor("selector.test_id"); + TEXT_TYPE = symbolFor("selector.text"); + } + var PossiblyWeakMap = "function" === typeof WeakMap ? WeakMap : Map, + executionContext = 0, + workInProgressRoot = null, + workInProgress = null, + workInProgressRootRenderLanes = 0, + workInProgressSuspendedReason = 0, + workInProgressThrownValue = null, + workInProgressRootDidSkipSuspendedSiblings = !1, + workInProgressRootIsPrerendering = !1, + workInProgressRootDidAttachPingListener = !1, + entangledRenderLanes = 0, + workInProgressRootExitStatus = 0, + workInProgressRootSkippedLanes = 0, + workInProgressRootInterleavedUpdatedLanes = 0, + workInProgressRootPingedLanes = 0, + workInProgressDeferredLane = 0, + workInProgressSuspendedRetryLanes = 0, + workInProgressRootConcurrentErrors = null, + workInProgressRootRecoverableErrors = null, + workInProgressRootDidIncludeRecursiveRenderUpdate = !1, + globalMostRecentFallbackTime = 0, + workInProgressRootRenderTargetTime = Infinity, + workInProgressTransitions = null, + legacyErrorBoundariesThatAlreadyFailed = null, + rootDoesHavePassiveEffects = !1, + rootWithPendingPassiveEffects = null, + pendingPassiveEffectsLanes = 0, + pendingPassiveEffectsRemainingLanes = 0, + pendingPassiveTransitions = null, + nestedUpdateCount = 0, + rootWithNestedUpdates = null; + exports.attemptContinuousHydration = function (fiber) { + if (13 === fiber.tag) { + var root = enqueueConcurrentRenderForLane(fiber, 67108864); + null !== root && scheduleUpdateOnFiber(root, fiber, 67108864); + markRetryLaneIfNotHydrated(fiber, 67108864); + } + }; + exports.attemptHydrationAtCurrentPriority = function (fiber) { + if (13 === fiber.tag) { + var lane = requestUpdateLane(), + root = enqueueConcurrentRenderForLane(fiber, lane); + null !== root && scheduleUpdateOnFiber(root, fiber, lane); + markRetryLaneIfNotHydrated(fiber, lane); + } + }; + exports.attemptSynchronousHydration = function (fiber) { + switch (fiber.tag) { + case 3: + fiber = fiber.stateNode; + if (fiber.current.memoizedState.isDehydrated) { + var lanes = getHighestPriorityLanes(fiber.pendingLanes); + if (0 !== lanes) { + fiber.pendingLanes |= 2; + for (fiber.entangledLanes |= 2; lanes; ) { + var lane = 1 << (31 - clz32(lanes)); + fiber.entanglements[1] |= lane; + lanes &= ~lane; + } + ensureRootIsScheduled(fiber); + 0 === (executionContext & 6) && + ((workInProgressRootRenderTargetTime = now() + 500), + flushSyncWorkAcrossRoots_impl(0, !1)); + } + } + break; + case 13: + (lanes = enqueueConcurrentRenderForLane(fiber, 2)), + null !== lanes && scheduleUpdateOnFiber(lanes, fiber, 2), + flushSyncWork(), + markRetryLaneIfNotHydrated(fiber, 2); + } + }; + exports.batchedUpdates = function (fn, a) { + return fn(a); + }; + exports.createComponentSelector = function (component) { + return { $$typeof: COMPONENT_TYPE, value: component }; + }; + exports.createContainer = function ( + containerInfo, + tag, + hydrationCallbacks, + isStrictMode, + concurrentUpdatesByDefaultOverride, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + transitionCallbacks + ) { + return createFiberRoot( + containerInfo, + tag, + !1, + null, + hydrationCallbacks, + isStrictMode, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + transitionCallbacks, + null + ); + }; + exports.createHasPseudoClassSelector = function (selectors) { + return { $$typeof: HAS_PSEUDO_CLASS_TYPE, value: selectors }; + }; + exports.createHydrationContainer = function ( + initialChildren, + callback, + containerInfo, + tag, + hydrationCallbacks, + isStrictMode, + concurrentUpdatesByDefaultOverride, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + transitionCallbacks, + formState + ) { + initialChildren = createFiberRoot( + containerInfo, + tag, + !0, + initialChildren, + hydrationCallbacks, + isStrictMode, + identifierPrefix, + onUncaughtError, + onCaughtError, + onRecoverableError, + transitionCallbacks, + formState + ); + initialChildren.context = getContextForSubtree(null); + containerInfo = initialChildren.current; + tag = requestUpdateLane(); + hydrationCallbacks = createUpdate(tag); + hydrationCallbacks.callback = + void 0 !== callback && null !== callback ? callback : null; + enqueueUpdate(containerInfo, hydrationCallbacks, tag); + initialChildren.current.lanes = tag; + markRootUpdated$1(initialChildren, tag); + ensureRootIsScheduled(initialChildren); + return initialChildren; + }; + exports.createPortal = function (children, containerInfo, implementation) { + var key = + 3 < arguments.length && void 0 !== arguments[3] ? arguments[3] : null; + return { + $$typeof: REACT_PORTAL_TYPE, + key: null == key ? null : "" + key, + children: children, + containerInfo: containerInfo, + implementation: implementation + }; + }; + exports.createRoleSelector = function (role) { + return { $$typeof: ROLE_TYPE, value: role }; + }; + exports.createTestNameSelector = function (id) { + return { $$typeof: TEST_NAME_TYPE, value: id }; + }; + exports.createTextSelector = function (text) { + return { $$typeof: TEXT_TYPE, value: text }; + }; + exports.defaultOnCaughtError = function (error) { + console.error(error); + }; + exports.defaultOnRecoverableError = function (error) { + reportGlobalError(error); + }; + exports.defaultOnUncaughtError = function (error) { + reportGlobalError(error); + }; + exports.deferredUpdates = function (fn) { + var prevTransition = ReactSharedInternals.T, + previousPriority = getCurrentUpdatePriority(); + try { + return ( + setCurrentUpdatePriority(32), (ReactSharedInternals.T = null), fn() + ); + } finally { + setCurrentUpdatePriority(previousPriority), + (ReactSharedInternals.T = prevTransition); + } + }; + exports.discreteUpdates = function (fn, a, b, c, d) { + var prevTransition = ReactSharedInternals.T, + previousPriority = getCurrentUpdatePriority(); + try { + return ( + setCurrentUpdatePriority(2), + (ReactSharedInternals.T = null), + fn(a, b, c, d) + ); + } finally { + setCurrentUpdatePriority(previousPriority), + (ReactSharedInternals.T = prevTransition), + 0 === executionContext && + (workInProgressRootRenderTargetTime = now() + 500); + } + }; + exports.findAllNodes = findAllNodes; + exports.findBoundingRects = function (hostRoot, selectors) { + if (!supportsTestSelectors) throw Error(formatProdErrorMessage(363)); + selectors = findAllNodes(hostRoot, selectors); + hostRoot = []; + for (var i = 0; i < selectors.length; i++) + hostRoot.push(getBoundingRect(selectors[i])); + for (selectors = hostRoot.length - 1; 0 < selectors; selectors--) { + i = hostRoot[selectors]; + for ( + var targetLeft = i.x, + targetRight = targetLeft + i.width, + targetTop = i.y, + targetBottom = targetTop + i.height, + j = selectors - 1; + 0 <= j; + j-- + ) + if (selectors !== j) { + var otherRect = hostRoot[j], + otherLeft = otherRect.x, + otherRight = otherLeft + otherRect.width, + otherTop = otherRect.y, + otherBottom = otherTop + otherRect.height; + if ( + targetLeft >= otherLeft && + targetTop >= otherTop && + targetRight <= otherRight && + targetBottom <= otherBottom + ) { + hostRoot.splice(selectors, 1); + break; + } else if ( + !( + targetLeft !== otherLeft || + i.width !== otherRect.width || + otherBottom < targetTop || + otherTop > targetBottom + ) + ) { + otherTop > targetTop && + ((otherRect.height += otherTop - targetTop), + (otherRect.y = targetTop)); + otherBottom < targetBottom && + (otherRect.height = targetBottom - otherTop); + hostRoot.splice(selectors, 1); + break; + } else if ( + !( + targetTop !== otherTop || + i.height !== otherRect.height || + otherRight < targetLeft || + otherLeft > targetRight + ) + ) { + otherLeft > targetLeft && + ((otherRect.width += otherLeft - targetLeft), + (otherRect.x = targetLeft)); + otherRight < targetRight && + (otherRect.width = targetRight - otherLeft); + hostRoot.splice(selectors, 1); + break; + } + } + } + return hostRoot; + }; + exports.findHostInstance = findHostInstance; + exports.findHostInstanceWithNoPortals = function (fiber) { + fiber = findCurrentFiberUsingSlowPath(fiber); + fiber = + null !== fiber ? findCurrentHostFiberWithNoPortalsImpl(fiber) : null; + return null === fiber ? null : getPublicInstance(fiber.stateNode); + }; + exports.findHostInstanceWithWarning = function (component) { + return findHostInstance(component); + }; + exports.flushPassiveEffects = flushPassiveEffects; + exports.flushSyncFromReconciler = function (fn) { + var prevExecutionContext = executionContext; + executionContext |= 1; + var prevTransition = ReactSharedInternals.T, + previousPriority = getCurrentUpdatePriority(); + try { + if ((setCurrentUpdatePriority(2), (ReactSharedInternals.T = null), fn)) + return fn(); + } finally { + setCurrentUpdatePriority(previousPriority), + (ReactSharedInternals.T = prevTransition), + (executionContext = prevExecutionContext), + 0 === (executionContext & 6) && flushSyncWorkAcrossRoots_impl(0, !1); + } + }; + exports.flushSyncWork = flushSyncWork; + exports.focusWithin = function (hostRoot, selectors) { + if (!supportsTestSelectors) throw Error(formatProdErrorMessage(363)); + hostRoot = findFiberRootForHostRoot(hostRoot); + selectors = findPaths(hostRoot, selectors); + selectors = Array.from(selectors); + for (hostRoot = 0; hostRoot < selectors.length; ) { + var fiber = selectors[hostRoot++], + tag = fiber.tag; + if (!isHiddenSubtree(fiber)) { + if ( + (5 === tag || 26 === tag || 27 === tag) && + setFocusIfFocusable(fiber.stateNode) + ) + return !0; + for (fiber = fiber.child; null !== fiber; ) + selectors.push(fiber), (fiber = fiber.sibling); + } + } + return !1; + }; + exports.getFindAllNodesFailureDescription = function (hostRoot, selectors) { + if (!supportsTestSelectors) throw Error(formatProdErrorMessage(363)); + var maxSelectorIndex = 0, + matchedNames = []; + hostRoot = [findFiberRootForHostRoot(hostRoot), 0]; + for (var index = 0; index < hostRoot.length; ) { + var fiber = hostRoot[index++], + tag = fiber.tag, + selectorIndex = hostRoot[index++], + selector = selectors[selectorIndex]; + if ((5 !== tag && 26 !== tag && 27 !== tag) || !isHiddenSubtree(fiber)) + if ( + (matchSelector(fiber, selector) && + (matchedNames.push(selectorToString(selector)), + selectorIndex++, + selectorIndex > maxSelectorIndex && + (maxSelectorIndex = selectorIndex)), + selectorIndex < selectors.length) + ) + for (fiber = fiber.child; null !== fiber; ) + hostRoot.push(fiber, selectorIndex), (fiber = fiber.sibling); + } + if (maxSelectorIndex < selectors.length) { + for ( + hostRoot = []; + maxSelectorIndex < selectors.length; + maxSelectorIndex++ + ) + hostRoot.push(selectorToString(selectors[maxSelectorIndex])); + return ( + "findAllNodes was able to match part of the selector:\n " + + (matchedNames.join(" > ") + + "\n\nNo matching component was found for:\n ") + + hostRoot.join(" > ") + ); + } + return null; + }; + exports.getPublicRootInstance = function (container) { + container = container.current; + if (!container.child) return null; + switch (container.child.tag) { + case 27: + case 5: + return getPublicInstance(container.child.stateNode); + default: + return container.child.stateNode; + } + }; + exports.injectIntoDevTools = function () { + var internals = { + bundleType: 0, + version: rendererVersion, + rendererPackageName: rendererPackageName, + currentDispatcherRef: ReactSharedInternals, + findFiberByHostInstance: getInstanceFromNode, + reconcilerVersion: "19.0.0" + }; + null !== extraDevToolsConfig && + (internals.rendererConfig = extraDevToolsConfig); + if ("undefined" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) internals = !1; + else { + var hook = __REACT_DEVTOOLS_GLOBAL_HOOK__; + if (hook.isDisabled || !hook.supportsFiber) internals = !0; + else { + try { + (rendererID = hook.inject(internals)), (injectedHook = hook); + } catch (err) {} + internals = hook.checkDCE ? !0 : !1; + } + } + return internals; + }; + exports.isAlreadyRendering = function () { + return !1; + }; + exports.observeVisibleRects = function ( + hostRoot, + selectors, + callback, + options + ) { + if (!supportsTestSelectors) throw Error(formatProdErrorMessage(363)); + hostRoot = findAllNodes(hostRoot, selectors); + var disconnect = setupIntersectionObserver( + hostRoot, + callback, + options + ).disconnect; + return { + disconnect: function () { + disconnect(); + } + }; + }; + exports.shouldError = function () { + return null; + }; + exports.shouldSuspend = function () { + return !1; + }; + exports.startHostTransition = function ( + formFiber, + pendingState, + action, + formData + ) { + if (5 !== formFiber.tag) throw Error(formatProdErrorMessage(476)); + var queue = ensureFormComponentIsStateful(formFiber).queue; + startTransition( + formFiber, + queue, + pendingState, + NotPendingTransition, + null === action + ? noop + : function () { + var resetStateQueue = + ensureFormComponentIsStateful(formFiber).next.queue; + dispatchSetStateInternal( + formFiber, + resetStateQueue, + {}, + requestUpdateLane() + ); + return action(formData); + } + ); + }; + exports.updateContainer = function ( + element, + container, + parentComponent, + callback + ) { + var current = container.current, + lane = requestUpdateLane(); + updateContainerImpl( + current, + lane, + element, + container, + parentComponent, + callback + ); + return lane; + }; + exports.updateContainerSync = function ( + element, + container, + parentComponent, + callback + ) { + 0 === container.tag && flushPassiveEffects(); + updateContainerImpl( + container.current, + 2, + element, + container, + parentComponent, + callback + ); + return 2; + }; + return exports; +}; +module.exports.default = module.exports; +Object.defineProperty(module.exports, "__esModule", { value: !0 }); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/constants.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/constants.js new file mode 100644 index 0000000..b7a5675 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/constants.js @@ -0,0 +1 @@ +module.exports = require("./cjs/react-reconciler-constants.production.js"); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/index.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/index.js new file mode 100644 index 0000000..7175fff --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/index.js @@ -0,0 +1 @@ +module.exports = require("./cjs/react-reconciler.production.js"); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/package.json b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/package.json new file mode 100644 index 0000000..8103c7c --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react-reconciler/package.json @@ -0,0 +1 @@ +{"name":"react-reconciler","version":"0.31.0","main":"index.js"} diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/cjs/react.production.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/cjs/react.production.js new file mode 100644 index 0000000..b54e690 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/cjs/react.production.js @@ -0,0 +1,542 @@ +/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +"use strict"; +var REACT_ELEMENT_TYPE = Symbol.for("react.transitional.element"), + REACT_PORTAL_TYPE = Symbol.for("react.portal"), + REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"), + REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode"), + REACT_PROFILER_TYPE = Symbol.for("react.profiler"), + REACT_CONSUMER_TYPE = Symbol.for("react.consumer"), + REACT_CONTEXT_TYPE = Symbol.for("react.context"), + REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"), + REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"), + REACT_MEMO_TYPE = Symbol.for("react.memo"), + REACT_LAZY_TYPE = Symbol.for("react.lazy"), + REACT_ACTIVITY_TYPE = Symbol.for("react.activity"), + MAYBE_ITERATOR_SYMBOL = Symbol.iterator; +function getIteratorFn(maybeIterable) { + if (null === maybeIterable || "object" !== typeof maybeIterable) return null; + maybeIterable = + (MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL]) || + maybeIterable["@@iterator"]; + return "function" === typeof maybeIterable ? maybeIterable : null; +} +var ReactNoopUpdateQueue = { + isMounted: function () { + return !1; + }, + enqueueForceUpdate: function () {}, + enqueueReplaceState: function () {}, + enqueueSetState: function () {} + }, + assign = Object.assign, + emptyObject = {}; +function Component(props, context, updater) { + this.props = props; + this.context = context; + this.refs = emptyObject; + this.updater = updater || ReactNoopUpdateQueue; +} +Component.prototype.isReactComponent = {}; +Component.prototype.setState = function (partialState, callback) { + if ( + "object" !== typeof partialState && + "function" !== typeof partialState && + null != partialState + ) + throw Error( + "takes an object of state variables to update or a function which returns an object of state variables." + ); + this.updater.enqueueSetState(this, partialState, callback, "setState"); +}; +Component.prototype.forceUpdate = function (callback) { + this.updater.enqueueForceUpdate(this, callback, "forceUpdate"); +}; +function ComponentDummy() {} +ComponentDummy.prototype = Component.prototype; +function PureComponent(props, context, updater) { + this.props = props; + this.context = context; + this.refs = emptyObject; + this.updater = updater || ReactNoopUpdateQueue; +} +var pureComponentPrototype = (PureComponent.prototype = new ComponentDummy()); +pureComponentPrototype.constructor = PureComponent; +assign(pureComponentPrototype, Component.prototype); +pureComponentPrototype.isPureReactComponent = !0; +var isArrayImpl = Array.isArray; +function noop() {} +var ReactSharedInternals = { H: null, A: null, T: null, S: null }, + hasOwnProperty = Object.prototype.hasOwnProperty; +function ReactElement(type, key, props) { + var refProp = props.ref; + return { + $$typeof: REACT_ELEMENT_TYPE, + type: type, + key: key, + ref: void 0 !== refProp ? refProp : null, + props: props + }; +} +function cloneAndReplaceKey(oldElement, newKey) { + return ReactElement(oldElement.type, newKey, oldElement.props); +} +function isValidElement(object) { + return ( + "object" === typeof object && + null !== object && + object.$$typeof === REACT_ELEMENT_TYPE + ); +} +function escape(key) { + var escaperLookup = { "=": "=0", ":": "=2" }; + return ( + "$" + + key.replace(/[=:]/g, function (match) { + return escaperLookup[match]; + }) + ); +} +var userProvidedKeyEscapeRegex = /\/+/g; +function getElementKey(element, index) { + return "object" === typeof element && null !== element && null != element.key + ? escape("" + element.key) + : index.toString(36); +} +function resolveThenable(thenable) { + switch (thenable.status) { + case "fulfilled": + return thenable.value; + case "rejected": + throw thenable.reason; + default: + switch ( + ("string" === typeof thenable.status + ? thenable.then(noop, noop) + : ((thenable.status = "pending"), + thenable.then( + function (fulfilledValue) { + "pending" === thenable.status && + ((thenable.status = "fulfilled"), + (thenable.value = fulfilledValue)); + }, + function (error) { + "pending" === thenable.status && + ((thenable.status = "rejected"), (thenable.reason = error)); + } + )), + thenable.status) + ) { + case "fulfilled": + return thenable.value; + case "rejected": + throw thenable.reason; + } + } + throw thenable; +} +function mapIntoArray(children, array, escapedPrefix, nameSoFar, callback) { + var type = typeof children; + if ("undefined" === type || "boolean" === type) children = null; + var invokeCallback = !1; + if (null === children) invokeCallback = !0; + else + switch (type) { + case "bigint": + case "string": + case "number": + invokeCallback = !0; + break; + case "object": + switch (children.$$typeof) { + case REACT_ELEMENT_TYPE: + case REACT_PORTAL_TYPE: + invokeCallback = !0; + break; + case REACT_LAZY_TYPE: + return ( + (invokeCallback = children._init), + mapIntoArray( + invokeCallback(children._payload), + array, + escapedPrefix, + nameSoFar, + callback + ) + ); + } + } + if (invokeCallback) + return ( + (callback = callback(children)), + (invokeCallback = + "" === nameSoFar ? "." + getElementKey(children, 0) : nameSoFar), + isArrayImpl(callback) + ? ((escapedPrefix = ""), + null != invokeCallback && + (escapedPrefix = + invokeCallback.replace(userProvidedKeyEscapeRegex, "$&/") + "/"), + mapIntoArray(callback, array, escapedPrefix, "", function (c) { + return c; + })) + : null != callback && + (isValidElement(callback) && + (callback = cloneAndReplaceKey( + callback, + escapedPrefix + + (null == callback.key || + (children && children.key === callback.key) + ? "" + : ("" + callback.key).replace( + userProvidedKeyEscapeRegex, + "$&/" + ) + "/") + + invokeCallback + )), + array.push(callback)), + 1 + ); + invokeCallback = 0; + var nextNamePrefix = "" === nameSoFar ? "." : nameSoFar + ":"; + if (isArrayImpl(children)) + for (var i = 0; i < children.length; i++) + (nameSoFar = children[i]), + (type = nextNamePrefix + getElementKey(nameSoFar, i)), + (invokeCallback += mapIntoArray( + nameSoFar, + array, + escapedPrefix, + type, + callback + )); + else if (((i = getIteratorFn(children)), "function" === typeof i)) + for ( + children = i.call(children), i = 0; + !(nameSoFar = children.next()).done; + + ) + (nameSoFar = nameSoFar.value), + (type = nextNamePrefix + getElementKey(nameSoFar, i++)), + (invokeCallback += mapIntoArray( + nameSoFar, + array, + escapedPrefix, + type, + callback + )); + else if ("object" === type) { + if ("function" === typeof children.then) + return mapIntoArray( + resolveThenable(children), + array, + escapedPrefix, + nameSoFar, + callback + ); + array = String(children); + throw Error( + "Objects are not valid as a React child (found: " + + ("[object Object]" === array + ? "object with keys {" + Object.keys(children).join(", ") + "}" + : array) + + "). If you meant to render a collection of children, use an array instead." + ); + } + return invokeCallback; +} +function mapChildren(children, func, context) { + if (null == children) return children; + var result = [], + count = 0; + mapIntoArray(children, result, "", "", function (child) { + return func.call(context, child, count++); + }); + return result; +} +function lazyInitializer(payload) { + if (-1 === payload._status) { + var ctor = payload._result; + ctor = ctor(); + ctor.then( + function (moduleObject) { + if (0 === payload._status || -1 === payload._status) + (payload._status = 1), (payload._result = moduleObject); + }, + function (error) { + if (0 === payload._status || -1 === payload._status) + (payload._status = 2), (payload._result = error); + } + ); + -1 === payload._status && ((payload._status = 0), (payload._result = ctor)); + } + if (1 === payload._status) return payload._result.default; + throw payload._result; +} +var reportGlobalError = + "function" === typeof reportError + ? reportError + : function (error) { + if ( + "object" === typeof window && + "function" === typeof window.ErrorEvent + ) { + var event = new window.ErrorEvent("error", { + bubbles: !0, + cancelable: !0, + message: + "object" === typeof error && + null !== error && + "string" === typeof error.message + ? String(error.message) + : String(error), + error: error + }); + if (!window.dispatchEvent(event)) return; + } else if ( + "object" === typeof process && + "function" === typeof process.emit + ) { + process.emit("uncaughtException", error); + return; + } + console.error(error); + }, + Children = { + map: mapChildren, + forEach: function (children, forEachFunc, forEachContext) { + mapChildren( + children, + function () { + forEachFunc.apply(this, arguments); + }, + forEachContext + ); + }, + count: function (children) { + var n = 0; + mapChildren(children, function () { + n++; + }); + return n; + }, + toArray: function (children) { + return ( + mapChildren(children, function (child) { + return child; + }) || [] + ); + }, + only: function (children) { + if (!isValidElement(children)) + throw Error( + "React.Children.only expected to receive a single React element child." + ); + return children; + } + }; +exports.Activity = REACT_ACTIVITY_TYPE; +exports.Children = Children; +exports.Component = Component; +exports.Fragment = REACT_FRAGMENT_TYPE; +exports.Profiler = REACT_PROFILER_TYPE; +exports.PureComponent = PureComponent; +exports.StrictMode = REACT_STRICT_MODE_TYPE; +exports.Suspense = REACT_SUSPENSE_TYPE; +exports.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = + ReactSharedInternals; +exports.__COMPILER_RUNTIME = { + __proto__: null, + c: function (size) { + return ReactSharedInternals.H.useMemoCache(size); + } +}; +exports.cache = function (fn) { + return function () { + return fn.apply(null, arguments); + }; +}; +exports.cacheSignal = function () { + return null; +}; +exports.cloneElement = function (element, config, children) { + if (null === element || void 0 === element) + throw Error( + "The argument must be a React element, but you passed " + element + "." + ); + var props = assign({}, element.props), + key = element.key; + if (null != config) + for (propName in (void 0 !== config.key && (key = "" + config.key), config)) + !hasOwnProperty.call(config, propName) || + "key" === propName || + "__self" === propName || + "__source" === propName || + ("ref" === propName && void 0 === config.ref) || + (props[propName] = config[propName]); + var propName = arguments.length - 2; + if (1 === propName) props.children = children; + else if (1 < propName) { + for (var childArray = Array(propName), i = 0; i < propName; i++) + childArray[i] = arguments[i + 2]; + props.children = childArray; + } + return ReactElement(element.type, key, props); +}; +exports.createContext = function (defaultValue) { + defaultValue = { + $$typeof: REACT_CONTEXT_TYPE, + _currentValue: defaultValue, + _currentValue2: defaultValue, + _threadCount: 0, + Provider: null, + Consumer: null + }; + defaultValue.Provider = defaultValue; + defaultValue.Consumer = { + $$typeof: REACT_CONSUMER_TYPE, + _context: defaultValue + }; + return defaultValue; +}; +exports.createElement = function (type, config, children) { + var propName, + props = {}, + key = null; + if (null != config) + for (propName in (void 0 !== config.key && (key = "" + config.key), config)) + hasOwnProperty.call(config, propName) && + "key" !== propName && + "__self" !== propName && + "__source" !== propName && + (props[propName] = config[propName]); + var childrenLength = arguments.length - 2; + if (1 === childrenLength) props.children = children; + else if (1 < childrenLength) { + for (var childArray = Array(childrenLength), i = 0; i < childrenLength; i++) + childArray[i] = arguments[i + 2]; + props.children = childArray; + } + if (type && type.defaultProps) + for (propName in ((childrenLength = type.defaultProps), childrenLength)) + void 0 === props[propName] && + (props[propName] = childrenLength[propName]); + return ReactElement(type, key, props); +}; +exports.createRef = function () { + return { current: null }; +}; +exports.forwardRef = function (render) { + return { $$typeof: REACT_FORWARD_REF_TYPE, render: render }; +}; +exports.isValidElement = isValidElement; +exports.lazy = function (ctor) { + return { + $$typeof: REACT_LAZY_TYPE, + _payload: { _status: -1, _result: ctor }, + _init: lazyInitializer + }; +}; +exports.memo = function (type, compare) { + return { + $$typeof: REACT_MEMO_TYPE, + type: type, + compare: void 0 === compare ? null : compare + }; +}; +exports.startTransition = function (scope) { + var prevTransition = ReactSharedInternals.T, + currentTransition = {}; + ReactSharedInternals.T = currentTransition; + try { + var returnValue = scope(), + onStartTransitionFinish = ReactSharedInternals.S; + null !== onStartTransitionFinish && + onStartTransitionFinish(currentTransition, returnValue); + "object" === typeof returnValue && + null !== returnValue && + "function" === typeof returnValue.then && + returnValue.then(noop, reportGlobalError); + } catch (error) { + reportGlobalError(error); + } finally { + null !== prevTransition && + null !== currentTransition.types && + (prevTransition.types = currentTransition.types), + (ReactSharedInternals.T = prevTransition); + } +}; +exports.unstable_useCacheRefresh = function () { + return ReactSharedInternals.H.useCacheRefresh(); +}; +exports.use = function (usable) { + return ReactSharedInternals.H.use(usable); +}; +exports.useActionState = function (action, initialState, permalink) { + return ReactSharedInternals.H.useActionState(action, initialState, permalink); +}; +exports.useCallback = function (callback, deps) { + return ReactSharedInternals.H.useCallback(callback, deps); +}; +exports.useContext = function (Context) { + return ReactSharedInternals.H.useContext(Context); +}; +exports.useDebugValue = function () {}; +exports.useDeferredValue = function (value, initialValue) { + return ReactSharedInternals.H.useDeferredValue(value, initialValue); +}; +exports.useEffect = function (create, deps) { + return ReactSharedInternals.H.useEffect(create, deps); +}; +exports.useEffectEvent = function (callback) { + return ReactSharedInternals.H.useEffectEvent(callback); +}; +exports.useId = function () { + return ReactSharedInternals.H.useId(); +}; +exports.useImperativeHandle = function (ref, create, deps) { + return ReactSharedInternals.H.useImperativeHandle(ref, create, deps); +}; +exports.useInsertionEffect = function (create, deps) { + return ReactSharedInternals.H.useInsertionEffect(create, deps); +}; +exports.useLayoutEffect = function (create, deps) { + return ReactSharedInternals.H.useLayoutEffect(create, deps); +}; +exports.useMemo = function (create, deps) { + return ReactSharedInternals.H.useMemo(create, deps); +}; +exports.useOptimistic = function (passthrough, reducer) { + return ReactSharedInternals.H.useOptimistic(passthrough, reducer); +}; +exports.useReducer = function (reducer, initialArg, init) { + return ReactSharedInternals.H.useReducer(reducer, initialArg, init); +}; +exports.useRef = function (initialValue) { + return ReactSharedInternals.H.useRef(initialValue); +}; +exports.useState = function (initialState) { + return ReactSharedInternals.H.useState(initialState); +}; +exports.useSyncExternalStore = function ( + subscribe, + getSnapshot, + getServerSnapshot +) { + return ReactSharedInternals.H.useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot + ); +}; +exports.useTransition = function () { + return ReactSharedInternals.H.useTransition(); +}; +exports.version = "19.2.8"; diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/index.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/index.js new file mode 100644 index 0000000..40fa574 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/index.js @@ -0,0 +1 @@ +module.exports = require("./cjs/react.production.js"); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/package.json b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/package.json new file mode 100644 index 0000000..ed26335 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/react/package.json @@ -0,0 +1 @@ +{"name":"react","version":"19.0.0","main":"index.js"} diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/cjs/scheduler.production.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/cjs/scheduler.production.js new file mode 100644 index 0000000..ce4ea12 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/cjs/scheduler.production.js @@ -0,0 +1,341 @@ +/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +"use strict"; +function push(heap, node) { + var index = heap.length; + heap.push(node); + a: for (; 0 < index; ) { + var parentIndex = (index - 1) >>> 1, + parent = heap[parentIndex]; + if (0 < compare(parent, node)) + (heap[parentIndex] = node), (heap[index] = parent), (index = parentIndex); + else break a; + } +} +function peek(heap) { + return 0 === heap.length ? null : heap[0]; +} +function pop(heap) { + if (0 === heap.length) return null; + var first = heap[0], + last = heap.pop(); + if (last !== first) { + heap[0] = last; + a: for ( + var index = 0, length = heap.length, halfLength = length >>> 1; + index < halfLength; + + ) { + var leftIndex = 2 * (index + 1) - 1, + left = heap[leftIndex], + rightIndex = leftIndex + 1, + right = heap[rightIndex]; + if (0 > compare(left, last)) + rightIndex < length && 0 > compare(right, left) + ? ((heap[index] = right), + (heap[rightIndex] = last), + (index = rightIndex)) + : ((heap[index] = left), + (heap[leftIndex] = last), + (index = leftIndex)); + else if (rightIndex < length && 0 > compare(right, last)) + (heap[index] = right), (heap[rightIndex] = last), (index = rightIndex); + else break a; + } + } + return first; +} +function compare(a, b) { + var diff = a.sortIndex - b.sortIndex; + return 0 !== diff ? diff : a.id - b.id; +} +exports.unstable_now = void 0; +if ("object" === typeof performance && "function" === typeof performance.now) { + var localPerformance = performance; + exports.unstable_now = function () { + return localPerformance.now(); + }; +} else { + var localDate = Date, + initialTime = localDate.now(); + exports.unstable_now = function () { + return localDate.now() - initialTime; + }; +} +var taskQueue = [], + timerQueue = [], + taskIdCounter = 1, + currentTask = null, + currentPriorityLevel = 3, + isPerformingWork = !1, + isHostCallbackScheduled = !1, + isHostTimeoutScheduled = !1, + localSetTimeout = "function" === typeof setTimeout ? setTimeout : null, + localClearTimeout = "function" === typeof clearTimeout ? clearTimeout : null, + localSetImmediate = "undefined" !== typeof setImmediate ? setImmediate : null; +function advanceTimers(currentTime) { + for (var timer = peek(timerQueue); null !== timer; ) { + if (null === timer.callback) pop(timerQueue); + else if (timer.startTime <= currentTime) + pop(timerQueue), + (timer.sortIndex = timer.expirationTime), + push(taskQueue, timer); + else break; + timer = peek(timerQueue); + } +} +function handleTimeout(currentTime) { + isHostTimeoutScheduled = !1; + advanceTimers(currentTime); + if (!isHostCallbackScheduled) + if (null !== peek(taskQueue)) + (isHostCallbackScheduled = !0), requestHostCallback(); + else { + var firstTimer = peek(timerQueue); + null !== firstTimer && + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } +} +var isMessageLoopRunning = !1, + taskTimeoutID = -1, + frameInterval = 5, + startTime = -1; +function shouldYieldToHost() { + return exports.unstable_now() - startTime < frameInterval ? !1 : !0; +} +function performWorkUntilDeadline() { + if (isMessageLoopRunning) { + var currentTime = exports.unstable_now(); + startTime = currentTime; + var hasMoreWork = !0; + try { + a: { + isHostCallbackScheduled = !1; + isHostTimeoutScheduled && + ((isHostTimeoutScheduled = !1), + localClearTimeout(taskTimeoutID), + (taskTimeoutID = -1)); + isPerformingWork = !0; + var previousPriorityLevel = currentPriorityLevel; + try { + b: { + advanceTimers(currentTime); + for ( + currentTask = peek(taskQueue); + null !== currentTask && + !( + currentTask.expirationTime > currentTime && shouldYieldToHost() + ); + + ) { + var callback = currentTask.callback; + if ("function" === typeof callback) { + currentTask.callback = null; + currentPriorityLevel = currentTask.priorityLevel; + var continuationCallback = callback( + currentTask.expirationTime <= currentTime + ); + currentTime = exports.unstable_now(); + if ("function" === typeof continuationCallback) { + currentTask.callback = continuationCallback; + advanceTimers(currentTime); + hasMoreWork = !0; + break b; + } + currentTask === peek(taskQueue) && pop(taskQueue); + advanceTimers(currentTime); + } else pop(taskQueue); + currentTask = peek(taskQueue); + } + if (null !== currentTask) hasMoreWork = !0; + else { + var firstTimer = peek(timerQueue); + null !== firstTimer && + requestHostTimeout( + handleTimeout, + firstTimer.startTime - currentTime + ); + hasMoreWork = !1; + } + } + break a; + } finally { + (currentTask = null), + (currentPriorityLevel = previousPriorityLevel), + (isPerformingWork = !1); + } + hasMoreWork = void 0; + } + } finally { + hasMoreWork + ? schedulePerformWorkUntilDeadline() + : (isMessageLoopRunning = !1); + } + } +} +var schedulePerformWorkUntilDeadline; +if ("function" === typeof localSetImmediate) + schedulePerformWorkUntilDeadline = function () { + localSetImmediate(performWorkUntilDeadline); + }; +else if ("undefined" !== typeof MessageChannel) { + var channel = new MessageChannel(), + port = channel.port2; + channel.port1.onmessage = performWorkUntilDeadline; + schedulePerformWorkUntilDeadline = function () { + port.postMessage(null); + }; +} else + schedulePerformWorkUntilDeadline = function () { + localSetTimeout(performWorkUntilDeadline, 0); + }; +function requestHostCallback() { + isMessageLoopRunning || + ((isMessageLoopRunning = !0), schedulePerformWorkUntilDeadline()); +} +function requestHostTimeout(callback, ms) { + taskTimeoutID = localSetTimeout(function () { + callback(exports.unstable_now()); + }, ms); +} +exports.unstable_IdlePriority = 5; +exports.unstable_ImmediatePriority = 1; +exports.unstable_LowPriority = 4; +exports.unstable_NormalPriority = 3; +exports.unstable_Profiling = null; +exports.unstable_UserBlockingPriority = 2; +exports.unstable_cancelCallback = function (task) { + task.callback = null; +}; +exports.unstable_continueExecution = function () { + isHostCallbackScheduled || + isPerformingWork || + ((isHostCallbackScheduled = !0), requestHostCallback()); +}; +exports.unstable_forceFrameRate = function (fps) { + 0 > fps || 125 < fps + ? console.error( + "forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported" + ) + : (frameInterval = 0 < fps ? Math.floor(1e3 / fps) : 5); +}; +exports.unstable_getCurrentPriorityLevel = function () { + return currentPriorityLevel; +}; +exports.unstable_getFirstCallbackNode = function () { + return peek(taskQueue); +}; +exports.unstable_next = function (eventHandler) { + switch (currentPriorityLevel) { + case 1: + case 2: + case 3: + var priorityLevel = 3; + break; + default: + priorityLevel = currentPriorityLevel; + } + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } +}; +exports.unstable_pauseExecution = function () {}; +exports.unstable_requestPaint = function () {}; +exports.unstable_runWithPriority = function (priorityLevel, eventHandler) { + switch (priorityLevel) { + case 1: + case 2: + case 3: + case 4: + case 5: + break; + default: + priorityLevel = 3; + } + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } +}; +exports.unstable_scheduleCallback = function ( + priorityLevel, + callback, + options +) { + var currentTime = exports.unstable_now(); + "object" === typeof options && null !== options + ? ((options = options.delay), + (options = + "number" === typeof options && 0 < options + ? currentTime + options + : currentTime)) + : (options = currentTime); + switch (priorityLevel) { + case 1: + var timeout = -1; + break; + case 2: + timeout = 250; + break; + case 5: + timeout = 1073741823; + break; + case 4: + timeout = 1e4; + break; + default: + timeout = 5e3; + } + timeout = options + timeout; + priorityLevel = { + id: taskIdCounter++, + callback: callback, + priorityLevel: priorityLevel, + startTime: options, + expirationTime: timeout, + sortIndex: -1 + }; + options > currentTime + ? ((priorityLevel.sortIndex = options), + push(timerQueue, priorityLevel), + null === peek(taskQueue) && + priorityLevel === peek(timerQueue) && + (isHostTimeoutScheduled + ? (localClearTimeout(taskTimeoutID), (taskTimeoutID = -1)) + : (isHostTimeoutScheduled = !0), + requestHostTimeout(handleTimeout, options - currentTime))) + : ((priorityLevel.sortIndex = timeout), + push(taskQueue, priorityLevel), + isHostCallbackScheduled || + isPerformingWork || + ((isHostCallbackScheduled = !0), requestHostCallback())); + return priorityLevel; +}; +exports.unstable_shouldYield = shouldYieldToHost; +exports.unstable_wrapCallback = function (callback) { + var parentPriorityLevel = currentPriorityLevel; + return function () { + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = parentPriorityLevel; + try { + return callback.apply(this, arguments); + } finally { + currentPriorityLevel = previousPriorityLevel; + } + }; +}; diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/index.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/index.js new file mode 100644 index 0000000..37d36b8 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/index.js @@ -0,0 +1 @@ +module.exports = require("./cjs/scheduler.production.js"); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/package.json b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/package.json new file mode 100644 index 0000000..b9e4a4b --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/React/node_modules/scheduler/package.json @@ -0,0 +1 @@ +{"name":"scheduler","version":"0.25.0","main":"index.js"} diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/VanillaJs/harness.js b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/VanillaJs/harness.js new file mode 100644 index 0000000..cd31280 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/Assets/VanillaJs/harness.js @@ -0,0 +1,90 @@ +const { Solution } = require("./solution"); + +function createElement(type) { + const node = { + type, + props: {}, + children: [], + listeners: {}, + appendChild(child) { + node.children.push(child); + return child; + }, + setAttribute(name, value) { + node.props[name] = value; + }, + removeAttribute(name) { + delete node.props[name]; + }, + addEventListener(eventType, handler) { + node.listeners[eventType] = handler; + }, + set textContent(value) { + node.children = [{ type: "#text", text: String(value) }]; + }, + get textContent() { + return node.children.map((c) => (c.type === "#text" ? c.text : "")).join(""); + }, + set className(value) { + node.props.class = value; + }, + get className() { + return node.props.class || ""; + }, + }; + return node; +} + +global.document = { + createElement, + createTextNode: (text) => ({ type: "#text", text: String(text) }), +}; + +function serialize(node) { + if (node.type === "#text") return node.text; + + const props = {}; + for (const [key, value] of Object.entries(node.props || {})) { + if (typeof value === "function") continue; + props[key] = value; + } + + return { type: node.type, props, children: node.children.map(serialize) }; +} + +function findByTestId(node, testId) { + if (node.props && node.props["data-testid"] === testId) return node; + for (const child of node.children) { + if (child.type === "#text") continue; + const found = findByTestId(child, testId); + if (found) return found; + } + return null; +} + +const ACTION_HANDLERS = { + click: (node) => node.listeners.click && node.listeners.click(), + change: (node, action) => + node.listeners.change && node.listeners.change({ target: { value: action.value } }), +}; + +function main() { + const raw = require("fs").readFileSync(0, "utf8"); + const { props = {}, actions = [] } = JSON.parse(raw || "{}"); + + const container = createElement("div"); + Solution(container, props); + + for (const action of actions) { + const target = findByTestId(container, action.testId); + if (!target) throw new Error(`No element with data-testid="${action.testId}" found`); + const handler = ACTION_HANDLERS[action.type]; + if (!handler) throw new Error(`Unsupported action type "${action.type}"`); + handler(target, action); + } + + const output = container.children.map(serialize); + process.stdout.write(JSON.stringify(output.length === 1 ? output[0] : output)); +} + +main(); diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/BatchCodeTemplateStrategyResolver.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/BatchCodeTemplateStrategyResolver.cs new file mode 100644 index 0000000..80e25d8 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/BatchCodeTemplateStrategyResolver.cs @@ -0,0 +1,20 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class BatchCodeTemplateStrategyResolver(IEnumerable strategies) + : IBatchCodeTemplateStrategyResolver +{ + public IBatchCodeTemplateStrategy Resolve(string languageName) + { + var strategy = strategies.FirstOrDefault(s => + s.LanguageName.Equals(languageName, StringComparison.OrdinalIgnoreCase) + ); + + return strategy is null + ? throw new InvalidOperationException( + $"No batch code template strategy registered for language '{languageName}'." + ) + : strategy; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/CodeTemplateStrategyResolver.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/CodeTemplateStrategyResolver.cs new file mode 100644 index 0000000..fbe43ae --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/CodeTemplateStrategyResolver.cs @@ -0,0 +1,20 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class CodeTemplateStrategyResolver(IEnumerable strategies) + : ICodeTemplateStrategyResolver +{ + public ICodeTemplateStrategy Resolve(string languageName) + { + var strategy = strategies.FirstOrDefault(s => + s.LanguageName.Equals(languageName, StringComparison.OrdinalIgnoreCase) + ); + + return strategy is null + ? throw new InvalidOperationException( + $"No code template strategy registered for language '{languageName}'." + ) + : strategy; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/CppCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/CppCodeTemplateStrategy.cs new file mode 100644 index 0000000..09a6f2b --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/CppCodeTemplateStrategy.cs @@ -0,0 +1,144 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class CppCodeTemplateStrategy : ICodeTemplateStrategy +{ + private static readonly Dictionary TypesByValueType = new() + { + ["integer"] = ("int", "parseInt"), + ["double"] = ("double", "parseDouble"), + ["boolean"] = ("bool", "parseBool"), + ["string"] = ("string", "parseString"), + ["integer_array"] = ("vector", "parseIntArray"), + }; + + public string LanguageName => "C++"; + + public string BuildStdin(IReadOnlyList inputs) => string.Join("\n", inputs.Select(i => i.Value)); + + public string Render(CodeTemplateContext context) + { + List argDeclarations = []; + List argNames = []; + + for (int i = 0; i < context.Inputs.Count; i++) + { + string valueType = context.Inputs[i].ValueType; + + if (!TypesByValueType.TryGetValue(valueType, out (string CppType, string ParseFn) mapped)) + throw new NotSupportedException($"Unsupported C++ value type '{valueType}'."); + + string argName = $"arg{i}"; + argDeclarations.Add($" {mapped.CppType} {argName} = {mapped.ParseFn}(__readLine());"); + argNames.Add(argName); + } + + string argsSection = string.Join("\n", argDeclarations); + string callArgs = string.Join(", ", argNames); + + return $$""" + #include + using namespace std; + + {{context.UserCode}} + + static string __readLine() { + string line; + std::getline(cin, line); + return line; + } + + static string __trim(const string& raw) { + size_t start = raw.find_first_not_of(" \t\r\n"); + if (start == string::npos) return ""; + size_t end = raw.find_last_not_of(" \t\r\n"); + return raw.substr(start, end - start + 1); + } + + static int parseInt(const string& raw) { return stoi(__trim(raw)); } + static double parseDouble(const string& raw) { return stod(__trim(raw)); } + + static bool parseBool(const string& raw) { + string s = __trim(raw); + for (char& c : s) c = static_cast(tolower(c)); + return s == "true"; + } + + static string parseString(const string& raw) { + string s = __trim(raw); + if (s.size() >= 2 && s.front() == '"' && s.back() == '"') { + string inner = s.substr(1, s.size() - 2); + string result; + for (size_t i = 0; i < inner.size(); i++) { + if (inner[i] == '\\' && i + 1 < inner.size()) { + result += inner[++i]; + } else { + result += inner[i]; + } + } + return result; + } + return s; + } + + static vector parseIntArray(const string& raw) { + string s = __trim(raw); + vector result; + if (s.size() < 2) return result; + s = s.substr(1, s.size() - 2); + stringstream ss(s); + string item; + while (getline(ss, item, ',')) { + string trimmed = __trim(item); + if (trimmed.empty()) continue; + result.push_back(stoi(trimmed)); + } + return result; + } + + static string toJson(int v) { return to_string(v); } + static string toJson(long long v) { return to_string(v); } + static string toJson(bool v) { return v ? "true" : "false"; } + + static string toJson(double v) { + ostringstream oss; + oss << setprecision(15) << v; + return oss.str(); + } + + static string toJson(const string& v) { + string result = "\""; + for (char c : v) { + if (c == '"' || c == '\\') result += '\\'; + result += c; + } + return result + "\""; + } + + template + static string toJson(const vector& v) { + string result = "["; + for (size_t i = 0; i < v.size(); i++) { + if (i > 0) result += ","; + result += toJson(v[i]); + } + return result + "]"; + } + + static string toOutputString(const string& v) { return v; } + + template + static string toOutputString(const T& v) { return toJson(v); } + + int main() { + {{argsSection}} + + Solution solution; + auto result = solution.{{context.FunctionName}}({{callArgs}}); + cout << toOutputString(result); + return 0; + } + """; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/EsbuildJsxTranspiler.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/EsbuildJsxTranspiler.cs new file mode 100644 index 0000000..503e709 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/EsbuildJsxTranspiler.cs @@ -0,0 +1,73 @@ +using StackDuel.Application.Configuration; +using StackDuel.Application.ExecutionEngine; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +public sealed class JsxTranspileException(string message) : Exception(message); + +internal sealed class EsbuildJsxTranspiler(JsxTranspilerOptions options) : IJsxTranspiler +{ + public string Transpile(string source) + { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + // Prefer the native esbuild.exe vendored alongside the app (Tools/esbuild.exe) — + // deployed Windows hosts (e.g. Azure App Service) have no Node/npm available to + // provide a global "esbuild", so this is the only copy that's guaranteed present. + string? vendoredPath = isWindows ? Path.Combine(AppContext.BaseDirectory, "Tools", "esbuild.exe") : null; + bool useVendored = vendoredPath is not null && File.Exists(vendoredPath); + + // A bare "esbuild" resolves to esbuild.cmd when installed via `npm install -g` + // on Windows, and CreateProcess (what Process.Start calls with + // UseShellExecute=false) cannot launch .cmd files directly — only cmd.exe's own + // PATHEXT-aware search can. Routing through cmd.exe /c on Windows only affects + // resolution, not behavior: it still runs whatever EsbuildPath points to, + // including an absolute path to esbuild.exe. The vendored copy is a native .exe, + // so it skips this entirely and launches directly. + bool needsCmdWrapper = isWindows && !useVendored; + string esbuildPath = useVendored ? vendoredPath! : options.EsbuildPath; + + ProcessStartInfo startInfo = new() + { + FileName = needsCmdWrapper ? "cmd.exe" : esbuildPath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + if (needsCmdWrapper) + { + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add(esbuildPath); + } + startInfo.ArgumentList.Add("--loader=jsx"); + startInfo.ArgumentList.Add("--jsx=transform"); + startInfo.ArgumentList.Add("--jsx-factory=React.createElement"); + + using Process process = new() { StartInfo = startInfo }; + process.Start(); + + process.StandardInput.Write(source); + process.StandardInput.Close(); + + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + + if (!process.WaitForExit(options.TimeoutSeconds * 1000)) + { + process.Kill(entireProcessTree: true); + throw new JsxTranspileException("JSX transpilation timed out."); + } + + if (process.ExitCode != 0) + throw new JsxTranspileException(stderr); + + return stdout; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/JavaCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/JavaCodeTemplateStrategy.cs new file mode 100644 index 0000000..65590f3 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/JavaCodeTemplateStrategy.cs @@ -0,0 +1,243 @@ +using StackDuel.Application.ExecutionEngine; +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed partial class JavaCodeTemplateStrategy : ICodeTemplateStrategy +{ + private static readonly Dictionary TypesByValueType = new() + { + ["integer"] = ("int", "parseInt"), + ["double"] = ("double", "parseDouble"), + ["boolean"] = ("boolean", "parseBoolean"), + ["string"] = ("String", "parseString"), + ["integer_array"] = ("int[]", "parseIntArray"), + }; + + private static readonly Dictionary CommonImportsByClassName = new() + { + ["List"] = "java.util.List", + ["ArrayList"] = "java.util.ArrayList", + ["LinkedList"] = "java.util.LinkedList", + ["Map"] = "java.util.Map", + ["HashMap"] = "java.util.HashMap", + ["TreeMap"] = "java.util.TreeMap", + ["LinkedHashMap"] = "java.util.LinkedHashMap", + ["Set"] = "java.util.Set", + ["HashSet"] = "java.util.HashSet", + ["TreeSet"] = "java.util.TreeSet", + ["LinkedHashSet"] = "java.util.LinkedHashSet", + ["Deque"] = "java.util.Deque", + ["ArrayDeque"] = "java.util.ArrayDeque", + ["Queue"] = "java.util.Queue", + ["PriorityQueue"] = "java.util.PriorityQueue", + ["Stack"] = "java.util.Stack", + ["Collections"] = "java.util.Collections", + ["Arrays"] = "java.util.Arrays", + ["Comparator"] = "java.util.Comparator", + ["Iterator"] = "java.util.Iterator", + ["Optional"] = "java.util.Optional", + ["Scanner"] = "java.util.Scanner", + }; + + public string LanguageName => "Java"; + + public string Render(CodeTemplateContext context) => string.Empty; + + public string BuildStdin(IReadOnlyList inputs) => string.Join("\n", inputs.Select(i => i.Value)); + + public byte[]? BuildAdditionalFiles(CodeTemplateContext context) + { + using MemoryStream stream = new(); + using (ZipArchive archive = new(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "Solution.java", EnsureCommonImports(context.UserCode)); + AddEntry(archive, "Main.java", RenderMain(context)); + AddEntry(archive, "compile", "#!/bin/bash\n/usr/local/jdk17/bin/javac *.java\n"); + AddEntry(archive, "run", "#!/bin/bash\n/usr/local/jdk17/bin/java Main\n"); + } + + return stream.ToArray(); + } + + private static string EnsureCommonImports(string sourceCode) + { + HashSet importedTypes = [.. ImportLineRegex().Matches(sourceCode).Select(m => m.Groups[1].Value)]; + + if (importedTypes.Contains("java.util.*")) + return sourceCode; + + List missingImports = []; + + foreach ((string className, string fullyQualifiedName) in CommonImportsByClassName) + { + if (importedTypes.Contains(fullyQualifiedName)) + continue; + + if (Regex.IsMatch(sourceCode, $@"\b{Regex.Escape(className)}\b", RegexOptions.None, TimeSpan.FromSeconds(1))) + missingImports.Add(fullyQualifiedName); + } + + if (missingImports.Count == 0) + return sourceCode; + + string importBlock = string.Join("\n", missingImports.Select(i => $"import {i};")); + return $"{importBlock}\n\n{sourceCode}"; + } + + [GeneratedRegex(@"^\s*import\s+([\w.*]+)\s*;", RegexOptions.Multiline)] + private static partial Regex ImportLineRegex(); + + private static string RenderMain(CodeTemplateContext context) + { + List argDeclarations = []; + List argNames = []; + + for (int i = 0; i < context.Inputs.Count; i++) + { + string valueType = context.Inputs[i].ValueType; + + if (!TypesByValueType.TryGetValue(valueType, out (string JavaType, string ParseMethod) mapped)) + throw new NotSupportedException($"Unsupported Java value type '{valueType}'."); + + string argName = $"arg{i}"; + argDeclarations.Add($" {mapped.JavaType} {argName} = {mapped.ParseMethod}(__in.readLine());"); + argNames.Add(argName); + } + + string argsSection = string.Join("\n", argDeclarations); + string callArgs = string.Join(", ", argNames); + + return $$""" + import java.io.BufferedReader; + import java.io.InputStreamReader; + import java.util.List; + + public class Main { + public static void main(String[] args) throws Exception { + BufferedReader __in = new BufferedReader(new InputStreamReader(System.in)); + {{argsSection}} + + Solution solution = new Solution(); + var result = solution.{{context.FunctionName}}({{callArgs}}); + System.out.print(toOutputString(result)); + } + + static int parseInt(String raw) { + return Integer.parseInt(raw.trim()); + } + + static double parseDouble(String raw) { + return Double.parseDouble(raw.trim()); + } + + static boolean parseBoolean(String raw) { + return Boolean.parseBoolean(raw.trim()); + } + + static String parseString(String raw) { + String s = raw == null ? "" : raw.trim(); + if (s.length() >= 2 && s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"') { + return s.substring(1, s.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); + } + return s; + } + + static int[] parseIntArray(String raw) { + String s = raw.trim(); + s = s.substring(1, s.length() - 1).trim(); + if (s.isEmpty()) { + return new int[0]; + } + String[] parts = s.split(","); + int[] result = new int[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = Integer.parseInt(parts[i].trim()); + } + return result; + } + + static String toOutputString(Object value) { + if (value instanceof String s) { + return s; + } + return toJson(value); + } + + static String toJson(Object value) { + if (value == null) { + return "null"; + } + if (value instanceof String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + if (value instanceof Boolean || value instanceof Integer || value instanceof Long + || value instanceof Double || value instanceof Float || value instanceof Short) { + return String.valueOf(value); + } + if (value instanceof int[] a) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < a.length; i++) { + if (i > 0) { + sb.append(","); + } + sb.append(a[i]); + } + return sb.append("]").toString(); + } + if (value instanceof double[] a) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < a.length; i++) { + if (i > 0) { + sb.append(","); + } + sb.append(a[i]); + } + return sb.append("]").toString(); + } + if (value instanceof boolean[] a) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < a.length; i++) { + if (i > 0) { + sb.append(","); + } + sb.append(a[i]); + } + return sb.append("]").toString(); + } + if (value instanceof Object[] a) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < a.length; i++) { + if (i > 0) { + sb.append(","); + } + sb.append(toJson(a[i])); + } + return sb.append("]").toString(); + } + if (value instanceof List list) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(","); + } + sb.append(toJson(list.get(i))); + } + return sb.append("]").toString(); + } + throw new RuntimeException("Unsupported return type: " + value.getClass()); + } + } + """; + } + + private static void AddEntry(ZipArchive archive, string name, string content) + { + ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.Fastest); + using Stream entryStream = entry.Open(); + using StreamWriter writer = new(entryStream, new UTF8Encoding(false)); + writer.Write(content); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/JavaScriptCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/JavaScriptCodeTemplateStrategy.cs new file mode 100644 index 0000000..1729c38 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/JavaScriptCodeTemplateStrategy.cs @@ -0,0 +1,27 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class JavaScriptCodeTemplateStrategy : ICodeTemplateStrategy +{ + public string LanguageName => "JavaScript"; + + public string Render(CodeTemplateContext context) + { + return $$""" + {{context.UserCode}} + + process.stdin.on("data", data => { + const args = JSON.parse(data.toString().trim()); + const result = {{context.FunctionName}}(...args); + // String(result) coerces via Array.prototype.toString() for arrays — + // String([1,3]) is "1,3", not "[1,3]" — which never matches the stored + // JSON-shaped expected output. Strings stay raw/unquoted (matching the + // stored convention for string-returning problems); everything else is + // JSON-serialized. + const output = typeof result === "string" ? result : JSON.stringify(result); + process.stdout.write(output.trim()); + }); + """; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/PythonCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/PythonCodeTemplateStrategy.cs new file mode 100644 index 0000000..62611d8 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/PythonCodeTemplateStrategy.cs @@ -0,0 +1,28 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class PythonCodeTemplateStrategy : ICodeTemplateStrategy +{ + public string LanguageName => "Python"; + + public string Render(CodeTemplateContext context) + { + return $$""" + {{context.UserCode}} + + import sys + import json + + data = sys.stdin.read() + args = json.loads(data) + result = {{context.FunctionName}}(*args) + # print(result) would coerce via str() — str(True) is "True", not valid JSON + # and not the lowercase "true"/"false" convention stored expected outputs use. + # Strings stay raw/unquoted (matching that convention); everything else goes + # through json.dumps so booleans, None, and numbers serialize correctly. + output = result if isinstance(result, str) else json.dumps(result) + print(output) + """; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/ReactCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/ReactCodeTemplateStrategy.cs new file mode 100644 index 0000000..a9b48bb --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/ReactCodeTemplateStrategy.cs @@ -0,0 +1,76 @@ +using StackDuel.Application.ExecutionEngine; +using System.IO.Compression; +using System.Text; +using System.Text.Json; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class ReactCodeTemplateStrategy(IJsxTranspiler jsxTranspiler) : ICodeTemplateStrategy +{ + public string LanguageName => "React"; + + public string Render(CodeTemplateContext context) => string.Empty; + + public string BuildStdin(IReadOnlyList inputs) + { + string props = inputs.FirstOrDefault(i => i.ValueType == "props")?.Value ?? "{}"; + string actions = inputs.FirstOrDefault(i => i.ValueType == "actions")?.Value ?? "[]"; + + using JsonDocument propsDoc = JsonDocument.Parse(props); + using JsonDocument actionsDoc = JsonDocument.Parse(actions); + + return JsonSerializer.Serialize(new { props = propsDoc.RootElement, actions = actionsDoc.RootElement }); + } + + public byte[]? BuildAdditionalFiles(CodeTemplateContext context) + { + if (string.IsNullOrWhiteSpace(context.FunctionName)) + throw new InvalidOperationException( + "React problems require a FunctionName naming the component to render." + ); + + string wrappedSource = + $"const React = require(\"react\");\n\n{context.UserCode}\n\nmodule.exports.Solution = {context.FunctionName};\n"; + string transpiled = jsxTranspiler.Transpile(wrappedSource); + + using MemoryStream stream = new(); + using (ZipArchive archive = new(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach ((string zipPath, byte[] content) in ReactRuntimeAssets.ZipEntries) + AddEntry(archive, zipPath, content); + + AddEntry(archive, "solution.js", Encoding.UTF8.GetBytes(transpiled)); + AddEntry(archive, "compile", Encoding.UTF8.GetBytes("#!/bin/bash\ntrue\n")); + AddEntry(archive, "run", Encoding.UTF8.GetBytes("#!/bin/bash\nnode harness.js\n")); + + // Extra files are plain Node modules the user writes themselves — real + // `module.exports.X = X` and `require("./Other")`, no auto-wrapping beyond the + // same `React` convenience the main file gets. Node's own module resolution + // handles requiring between them once they're real files in the same directory, + // so no custom loader is needed here — only the main file gets the "bare + // function, no export" convenience since only it has a known entry point + // (FunctionName). + foreach (CodeTemplateSourceFile file in context.AdditionalFiles ?? []) + { + string extraWrapped = $"const React = require(\"react\");\n\n{file.Content}\n"; + string extraTranspiled = jsxTranspiler.Transpile(extraWrapped); + AddEntry(archive, NormalizeToJs(file.Path), Encoding.UTF8.GetBytes(extraTranspiled)); + } + } + + return stream.ToArray(); + } + + private static string NormalizeToJs(string path) + { + string withoutExtension = System.IO.Path.ChangeExtension(path, null); + return withoutExtension + ".js"; + } + + private static void AddEntry(ZipArchive archive, string name, byte[] content) + { + ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.Fastest); + using Stream entryStream = entry.Open(); + entryStream.Write(content); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/ReactRuntimeAssets.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/ReactRuntimeAssets.cs new file mode 100644 index 0000000..25f7e2e --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/ReactRuntimeAssets.cs @@ -0,0 +1,34 @@ +using System.Collections.Frozen; +using System.Linq; +using System.Reflection; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal static class ReactRuntimeAssets +{ + private const string LogicalNamePrefix = "ReactRuntime/"; + + public static readonly FrozenDictionary ZipEntries = Load(); + + private static FrozenDictionary Load() + { + Assembly assembly = typeof(ReactRuntimeAssets).Assembly; + Dictionary entries = []; + + foreach ( + string resourceName in assembly + .GetManifestResourceNames() + .Where(resourceName => resourceName.StartsWith(LogicalNamePrefix, StringComparison.Ordinal)) + ) + { + string zipPath = resourceName[LogicalNamePrefix.Length..]; + + using Stream stream = assembly.GetManifestResourceStream(resourceName)!; + using MemoryStream buffer = new(); + stream.CopyTo(buffer); + entries[zipPath] = buffer.ToArray(); + } + + return entries.ToFrozenDictionary(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/SQLiteCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/SQLiteCodeTemplateStrategy.cs new file mode 100644 index 0000000..3a89b20 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/SQLiteCodeTemplateStrategy.cs @@ -0,0 +1,38 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class SQLiteCodeTemplateStrategy : ICodeTemplateStrategy +{ + public string LanguageName => "SQLite"; + + public string Render(CodeTemplateContext context) + { + string setup = context.Inputs.Count > 0 ? context.Inputs[0].Value : string.Empty; + + return $$""" + .mode list + .headers off + .separator "," + .nullvalue NULL + + {{setup}} + + {{context.UserCode.Trim()}} + """; + } + + public string BuildStdin(IReadOnlyList inputs) => string.Empty; + + public ParsedExecutionOutput ParseOutput(string? stdout) + { + if (string.IsNullOrEmpty(stdout)) + return new ParsedExecutionOutput(UserLogs: null, ActualResult: null); + + string trimmed = stdout.TrimEnd('\r', '\n'); + return new ParsedExecutionOutput( + UserLogs: null, + ActualResult: string.IsNullOrWhiteSpace(trimmed) ? null : trimmed + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/TypeScriptCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/TypeScriptCodeTemplateStrategy.cs new file mode 100644 index 0000000..d994df4 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/TypeScriptCodeTemplateStrategy.cs @@ -0,0 +1,32 @@ +using StackDuel.Application.ExecutionEngine; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class TypeScriptCodeTemplateStrategy : ICodeTemplateStrategy +{ + public string LanguageName => "TypeScript"; + + public string Render(CodeTemplateContext context) + { + return $$""" + declare const process: { + stdin: { on(event: "data", listener: (data: any) => void): void }; + stdout: { write(chunk: string): void }; + }; + + {{context.UserCode}} + + process.stdin.on("data", (data: any) => { + const args: any[] = JSON.parse(data.toString().trim()); + const result = ({{context.FunctionName}} as any)(...args); + // String(result) coerces via Array.prototype.toString() for arrays — + // String([1,3]) is "1,3", not "[1,3]" — which never matches the stored + // JSON-shaped expected output. Strings stay raw/unquoted (matching the + // stored convention for string-returning problems); everything else is + // JSON-serialized. + const output = typeof result === "string" ? result : JSON.stringify(result); + process.stdout.write(output.trim()); + }); + """; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VanillaJsCodeTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VanillaJsCodeTemplateStrategy.cs new file mode 100644 index 0000000..3b2c16b --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VanillaJsCodeTemplateStrategy.cs @@ -0,0 +1,61 @@ +using StackDuel.Application.ExecutionEngine; +using System.IO.Compression; +using System.Text; +using System.Text.Json; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal sealed class VanillaJsCodeTemplateStrategy : ICodeTemplateStrategy +{ + public string LanguageName => "Vanilla JS"; + + public string Render(CodeTemplateContext context) => string.Empty; + + public string BuildStdin(IReadOnlyList inputs) + { + string props = inputs.FirstOrDefault(i => i.ValueType == "props")?.Value ?? "{}"; + string actions = inputs.FirstOrDefault(i => i.ValueType == "actions")?.Value ?? "[]"; + + using JsonDocument propsDoc = JsonDocument.Parse(props); + using JsonDocument actionsDoc = JsonDocument.Parse(actions); + + return JsonSerializer.Serialize(new { props = propsDoc.RootElement, actions = actionsDoc.RootElement }); + } + + public byte[]? BuildAdditionalFiles(CodeTemplateContext context) + { + if (string.IsNullOrWhiteSpace(context.FunctionName)) + throw new InvalidOperationException( + "Vanilla JS problems require a FunctionName naming the function to render." + ); + + string wrappedSource = $"{context.UserCode}\n\nmodule.exports.Solution = {context.FunctionName};\n"; + + using MemoryStream stream = new(); + using (ZipArchive archive = new(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "harness.js", VanillaJsRuntimeAssets.Harness); + AddEntry(archive, "solution.js", Encoding.UTF8.GetBytes(wrappedSource)); + AddEntry(archive, "compile", Encoding.UTF8.GetBytes("#!/bin/bash\ntrue\n")); + AddEntry(archive, "run", Encoding.UTF8.GetBytes("#!/bin/bash\nnode harness.js\n")); + + foreach (CodeTemplateSourceFile file in context.AdditionalFiles ?? []) + AddEntry(archive, NormalizeToJs(file.Path), Encoding.UTF8.GetBytes(file.Content)); + } + + return stream.ToArray(); + } + + private static string NormalizeToJs(string path) + { + string withoutExtension = System.IO.Path.ChangeExtension(path, null); + return withoutExtension + ".js"; + } + + private static void AddEntry(ZipArchive archive, string name, byte[] content) + { + ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.Fastest); + using Stream entryStream = entry.Open(); + entryStream.Write(content); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VanillaJsRuntimeAssets.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VanillaJsRuntimeAssets.cs new file mode 100644 index 0000000..6928c59 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VanillaJsRuntimeAssets.cs @@ -0,0 +1,19 @@ +using System.Reflection; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +internal static class VanillaJsRuntimeAssets +{ + private const string HarnessResourceName = "VanillaJsRuntime/harness.js"; + + public static readonly byte[] Harness = Load(); + + private static byte[] Load() + { + Assembly assembly = typeof(VanillaJsRuntimeAssets).Assembly; + using Stream stream = assembly.GetManifestResourceStream(HarnessResourceName)!; + using MemoryStream buffer = new(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VitestJavaScriptBatchTemplateStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VitestJavaScriptBatchTemplateStrategy.cs new file mode 100644 index 0000000..5767155 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/CodeTemplates/VitestJavaScriptBatchTemplateStrategy.cs @@ -0,0 +1,225 @@ +using StackDuel.Application.ExecutionEngine; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; + +/// +/// Renders every test case for one submission into a single Vitest spec and parses Vitest's +/// own Jest-compatible JSON reporter back into a per-case verdict. Pairs with a +/// LanguageVersionEntry whose ExecutionMode is +/// and +/// whose Judge0 image actually has Vitest installed — this is a distinct language version +/// from plain "JavaScript", not a replacement for it. +/// +/// The Judge0 run command for that image must: run Vitest with its JSON report sent to a file +/// (--reporter=json --outputFile=<path>), not stdout — Vitest's own console/progress +/// output must not land on stdout, only this harness's own console.log calls — then +/// print and cat that file to stdout. Concretely: +/// vitest run --reporter=json --outputFile=report.json && echo "===VITEST_REPORT===" && cat report.json. +/// Real stdout therefore looks like +/// ===ACTUAL_OUTPUTS===<outputs json>===VITEST_REPORT===<vitest report json> — +/// the outputs block (printed by this harness's own afterAll, which runs before the +/// Vitest process exits) comes first, the report (cat'd only after that process exits) second. +/// +internal sealed class VitestJavaScriptBatchTemplateStrategy : IBatchCodeTemplateStrategy +{ + private const string ActualOutputsMarker = "===ACTUAL_OUTPUTS==="; + private const string VitestReportMarker = "===VITEST_REPORT==="; + + public string LanguageName => "JavaScript"; + + public string RenderBatch( + string userCode, + string? functionName, + IReadOnlyList cases, + int perCaseTimeoutMs, + IReadOnlyList? additionalFiles = null + ) + { + string casesJson = JsonSerializer.Serialize( + cases.Select(c => new HarnessCase( + c.TestCaseId.ToString(), + c.Inputs.Select(ToJsonElement).ToArray(), + c.ExpectedOutput, + c.AssertStrategy.ToString(), + c.Tolerance, + c.CaseSensitive + )) + ); + + return $$""" + import { describe, it, expect, afterAll } from "vitest"; + + {{userCode}} + + const __cases = {{casesJson}}; + const __outputs = {}; + + // Mirrors EvaluateStepHandler.NormalizeJson's parse-or-fallback behavior: most + // expected/actual values are JSON-shaped ("[0,1]", "true"), but a plain string + // result (e.g. "reversed") is not valid JSON on its own — JSON.parse would throw + // and crash the whole case instead of comparing it. Falling back to the raw string + // keeps non-JSON values comparable instead of a parse failure masquerading as WrongAnswer. + function __normalize(value) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + describe("generated", () => { + for (const c of __cases) { + it(c.id, { timeout: {{perCaseTimeoutMs}} }, () => { + const result = {{functionName}}(...c.args); + const actual = typeof result === "string" ? result : JSON.stringify(result); + __outputs[c.id] = actual; + + switch (c.strategy) { + case "FloatTolerance": { + const diff = Math.abs(Number(actual) - Number(c.expected)); + expect(diff).toBeLessThanOrEqual(c.tolerance ?? 1e-6); + break; + } + case "SetEquality": + expect(__normalize(actual)).toEqual(__normalize(c.expected)); + break; + default: + if (c.caseSensitive) { + expect(actual.trim()).toBe(c.expected.trim()); + } else { + expect(actual.trim().toLowerCase()).toBe(c.expected.trim().toLowerCase()); + } + } + }); + } + + afterAll(() => { + console.log("{{ActualOutputsMarker}}"); + console.log(JSON.stringify(__outputs)); + }); + }); + """; + } + + public IReadOnlyList ParseBatchOutput(string? stdout, IReadOnlyList orderedTestCaseIds) + { + if (string.IsNullOrWhiteSpace(stdout)) + return [.. orderedTestCaseIds.Select(id => new BatchCaseResult(id, BatchCaseStatus.RuntimeError, null, "No output from Vitest."))]; + + (string reportJson, Dictionary? actualOutputs) = SplitStdout(stdout); + + VitestReport? report; + try + { + report = JsonSerializer.Deserialize(reportJson); + } + catch (JsonException) + { + return [.. orderedTestCaseIds.Select(id => new BatchCaseResult(id, BatchCaseStatus.RuntimeError, null, "Could not parse Vitest report."))]; + } + + Dictionary byTitle = (report?.TestResults ?? []) + .SelectMany(r => r.AssertionResults) + .GroupBy(a => a.Title) + .ToDictionary(g => g.Key, g => g.First()); + + return + [ + .. orderedTestCaseIds.Select(id => + { + string caseId = id.ToString(); + string? actual = actualOutputs is not null && actualOutputs.TryGetValue(caseId, out string? o) ? o : null; + + if (!byTitle.TryGetValue(caseId, out VitestAssertionResult? assertion)) + return new BatchCaseResult(id, BatchCaseStatus.RuntimeError, actual, "No matching Vitest result for this case."); + + string? failureMessage = assertion.FailureMessages.FirstOrDefault(); + + BatchCaseStatus status = assertion.Status switch + { + "passed" => BatchCaseStatus.Accepted, + "failed" when IsTimeout(failureMessage) => BatchCaseStatus.TimedOut, + "failed" when IsAssertionFailure(failureMessage) => BatchCaseStatus.WrongAnswer, + "failed" => BatchCaseStatus.RuntimeError, + _ => BatchCaseStatus.RuntimeError, + }; + + return new BatchCaseResult( + id, + status, + actual, + status == BatchCaseStatus.Accepted ? null : failureMessage, + assertion.Duration + ); + }), + ]; + } + + private static (string ReportJson, Dictionary? ActualOutputs) SplitStdout(string stdout) + { + int outputsIndex = stdout.IndexOf(ActualOutputsMarker, StringComparison.Ordinal); + int reportIndex = stdout.IndexOf(VitestReportMarker, StringComparison.Ordinal); + + // Markers missing or out of the expected order (outputs block, then report) means the + // run command didn't produce what this strategy expects — fall back to treating the + // whole thing as the report so the caller still gets a "could not parse" result instead + // of silently misreading unrelated bytes as one half of the split. + if (outputsIndex < 0 || reportIndex < 0 || reportIndex < outputsIndex) + return (stdout, null); + + string outputsJson = stdout[(outputsIndex + ActualOutputsMarker.Length)..reportIndex].Trim(); + string reportJson = stdout[(reportIndex + VitestReportMarker.Length)..].Trim(); + + try + { + return (reportJson, JsonSerializer.Deserialize>(outputsJson)); + } + catch (JsonException) + { + return (reportJson, null); + } + } + + private static bool IsTimeout(string? message) => + message is not null && message.Contains("timed out", StringComparison.OrdinalIgnoreCase); + + private static bool IsAssertionFailure(string? message) => + message is not null + && (message.Contains("AssertionError", StringComparison.Ordinal) || message.Contains("expected", StringComparison.OrdinalIgnoreCase)); + + private static JsonElement ToJsonElement(CodeTemplateInput input) + { + try + { + return JsonSerializer.Deserialize(input.Value); + } + catch (JsonException) + { + return JsonSerializer.SerializeToElement(input.Value); + } + } + + private sealed record HarnessCase( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("args")] JsonElement[] Args, + [property: JsonPropertyName("expected")] string Expected, + [property: JsonPropertyName("strategy")] string Strategy, + [property: JsonPropertyName("tolerance")] decimal? Tolerance, + [property: JsonPropertyName("caseSensitive")] bool CaseSensitive + ); + + private sealed record VitestReport([property: JsonPropertyName("testResults")] List TestResults); + + private sealed record VitestTestFileResult( + [property: JsonPropertyName("assertionResults")] List AssertionResults + ); + + private sealed record VitestAssertionResult( + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("failureMessages")] List FailureMessages, + [property: JsonPropertyName("duration")] int? Duration + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/Judge0/Judge0ExecutionEngineStrategy.cs b/StackDuel.Infrastructure/ExecutionEngine/Judge0/Judge0ExecutionEngineStrategy.cs new file mode 100644 index 0000000..11dbc61 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/Judge0/Judge0ExecutionEngineStrategy.cs @@ -0,0 +1,280 @@ +using Microsoft.AspNetCore.WebUtilities; +using StackDuel.Application.Configuration; +using StackDuel.Application.ExecutionEngine; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StackDuel.Infrastructure.ExecutionEngine.Judge0; + +internal sealed class Judge0ExecutionEngineStrategy(HttpClient http, Judge0Options options) : IExecutionEngineStrategy +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private const int BatchSize = 20; + + public async Task> SubmitBatchAsync( + IReadOnlyList submissions, + Guid? submissionId = null, + CancellationToken cancellationToken = default + ) + { + try + { + List allResults = []; + string? callbackUrl = BuildCallbackUrl(submissionId); + + foreach (var batch in submissions.Chunk(BatchSize)) + { + var query = new Dictionary + { + ["base64_encoded"] = options.IsEncoded.ToString().ToLowerInvariant(), + ["fields"] = "*", + }; + + string uri = QueryHelpers.AddQueryString("submissions/batch", query); + + var payload = new Judge0BatchRequest + { + Submissions = + [ + .. batch.Select(s => new Judge0SubmissionRequest + { + SourceCode = Encode(s.SourceCode), + LanguageId = s.LanguageId, + StdIn = Encode(s.Stdin), + AdditionalFiles = s.AdditionalFiles is null + ? null + : Convert.ToBase64String(s.AdditionalFiles), + CpuTimeLimit = s.TimeLimitMs.HasValue ? (double?)s.TimeLimitMs.Value / 1000.0 : null, + MemoryLimit = s.MemoryLimitKb, + CallbackUrl = callbackUrl, + }), + ], + }; + + var response = await http.PostAsJsonAsync(uri, payload, SerializerOptions, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + + var body = await response.Content.ReadFromJsonAsync>( + SerializerOptions, + cancellationToken + ); + + if (body is { Count: > 0 }) + { + allResults.AddRange( + body.Select(t => new ExecutionEngineResult( + Token: t.Token, + Stdout: null, + Stderr: null, + CompileOutput: null, + RuntimeMs: null, + MemoryUsedKb: null, + Status: ExecutionEngineResultStatus.Queued + )) + ); + } + } + + return allResults; + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException($"HTTP request failed: {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"JSON deserialization failed: {ex.Message}", ex); + } + } + + public async Task> PollBatchAsync( + IReadOnlyList tokens, + CancellationToken cancellationToken = default + ) + { + try + { + List allResults = []; + + foreach (string[] batch in tokens.Chunk(BatchSize)) + { + var query = new Dictionary + { + ["tokens"] = string.Join(",", batch), + ["base64_encoded"] = options.IsEncoded.ToString().ToLowerInvariant(), + ["fields"] = "token,stdout,stderr,compile_output,time,memory,status", + }; + + string uri = QueryHelpers.AddQueryString("submissions/batch", query); + + var response = await http.GetAsync(uri, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + + var batchResult = await response.Content.ReadFromJsonAsync( + SerializerOptions, + cancellationToken + ); + + if (batchResult?.Submissions is { Count: > 0 }) + { + allResults.AddRange( + batchResult.Submissions.Select(s => new ExecutionEngineResult( + Token: s.Token, + Stdout: Decode(s.Stdout), + Stderr: Decode(s.Stderr), + CompileOutput: Decode(s.CompileOutput), + RuntimeMs: s.Time.HasValue ? (int?)(s.Time.Value * 1000) : null, + MemoryUsedKb: s.Memory, + Status: MapStatus(s.Status?.Id ?? 0) + )) + ); + } + } + + return allResults; + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException($"HTTP request failed: {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"JSON deserialization failed: {ex.Message}", ex); + } + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + string body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Judge0 returned {(int)response.StatusCode} {response.ReasonPhrase}: {body}"); + } + + private string? BuildCallbackUrl(Guid? submissionId) + { + if (!options.UseCallback || submissionId is null) + return null; + + var query = new Dictionary + { + ["key"] = options.CallbackSecret, + ["submissionId"] = submissionId.Value.ToString(), + }; + + return QueryHelpers.AddQueryString($"{options.PublicBaseUrl.TrimEnd('/')}/api/v1/judge0/callback", query); + } + + private string? Encode(string? value) => + value is null ? null + : options.IsEncoded ? Convert.ToBase64String(Encoding.UTF8.GetBytes(value)) + : value; + + private string? Decode(string? value) + { + if (value is null) + return null; + if (!options.IsEncoded) + return value; + + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(value)); + } + catch (FormatException) + { + return value; + } + } + + private static ExecutionEngineResult MapResult(Judge0SubmissionResponse s) => + new( + Token: s.Token, + Stdout: s.Stdout, + Stderr: s.Stderr, + CompileOutput: s.CompileOutput, + RuntimeMs: s.Time.HasValue ? (int?)(s.Time.Value * 1000) : null, + MemoryUsedKb: s.Memory, + Status: MapStatus(s.Status?.Id ?? 0) + ); + + private static ExecutionEngineResultStatus MapStatus(int id) => + id switch + { + 1 => ExecutionEngineResultStatus.Queued, + 2 => ExecutionEngineResultStatus.Processing, + 3 => ExecutionEngineResultStatus.Accepted, + 4 => ExecutionEngineResultStatus.WrongAnswer, + 5 => ExecutionEngineResultStatus.TimeLimitExceeded, + 6 => ExecutionEngineResultStatus.CompilationError, + >= 7 and <= 12 => ExecutionEngineResultStatus.RuntimeError, + _ => ExecutionEngineResultStatus.InternalError, + }; + + private sealed class Judge0BatchRequest + { + [JsonPropertyName("submissions")] + public required IEnumerable Submissions { get; init; } + } + + private sealed class Judge0SubmissionRequest + { + [JsonPropertyName("language_id")] + public int LanguageId { get; init; } + + [JsonPropertyName("source_code")] + public string? SourceCode { get; init; } + + [JsonPropertyName("stdin")] + public string? StdIn { get; init; } + + [JsonPropertyName("additional_files")] + public string? AdditionalFiles { get; init; } + + [JsonPropertyName("expected_output")] + public string? ExpectedOutput { get; init; } + + [JsonPropertyName("cpu_time_limit")] + public double? CpuTimeLimit { get; init; } + + [JsonPropertyName("memory_limit")] + public int? MemoryLimit { get; init; } + + [JsonPropertyName("callback_url")] + public string? CallbackUrl { get; init; } + } + + private sealed record Judge0TokenResponse(string Token); + + private sealed class Judge0BatchGetResponse + { + public List Submissions { get; set; } = []; + } + + private sealed class Judge0SubmissionResponse + { + public string Token { get; set; } = string.Empty; + public string? Stdout { get; set; } + public string? Stderr { get; set; } + public string? CompileOutput { get; set; } + + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] + public double? Time { get; set; } + public int? Memory { get; set; } + public Judge0StatusResponse? Status { get; set; } + } + + private sealed class Judge0StatusResponse + { + public int Id { get; set; } + public string Description { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/Judge0/Judge0StepConfiguration.cs b/StackDuel.Infrastructure/ExecutionEngine/Judge0/Judge0StepConfiguration.cs new file mode 100644 index 0000000..cbf8476 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/Judge0/Judge0StepConfiguration.cs @@ -0,0 +1,16 @@ +namespace StackDuel.Infrastructure.ExecutionEngine.Judge0; + +public sealed class Judge0StepConfiguration +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid PipelineStepId { get; set; } + + public bool IsEncoded { get; set; } = true; + + public bool ShouldWait { get; set; } = false; + + public bool StripWhitespace { get; set; } = true; + + public int DefaultTimeoutSeconds { get; set; } = 10; +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/Judge0ReferenceSolutionRunner.cs b/StackDuel.Infrastructure/ExecutionEngine/Judge0ReferenceSolutionRunner.cs new file mode 100644 index 0000000..e872af3 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/Judge0ReferenceSolutionRunner.cs @@ -0,0 +1,156 @@ +using StackDuel.Application.ExecutionEngine; +using StackDuel.Domain.TestSuites.Entities; +using System.Text.Json; + +namespace StackDuel.Infrastructure.ExecutionEngine; + +/// +/// Runs a reference solution against a set of test cases via Judge0 and reports pass/fail. +/// Shared by (sanity-checking a +/// reference solution before trusting it to generate ground truth) and +/// (validating an admin-authored +/// reference solution against admin-authored sample cases before a problem can publish). +/// +internal sealed class Judge0ReferenceSolutionRunner(IExecutionEngineStrategy executionEngine) +{ + private const int MaxPollAttempts = 30; + private static readonly TimeSpan PollDelay = TimeSpan.FromSeconds(2); + + /// Runs the reference solution against each case and reports one warning per case that fails. + public async Task> RunAgainstCasesAsync( + string referenceSolutionCode, + string? functionName, + ICodeTemplateStrategy templateStrategy, + int judge0LanguageId, + IReadOnlyList testCases, + CancellationToken cancellationToken + ) + { + List> inputsByCase = + [ + .. testCases.Select(tc => tc.Inputs.Select(i => new CodeTemplateInput(i.Value, i.ValueType)).ToList()), + ]; + + List submissions = + [ + .. inputsByCase.Select(inputs => new ExecutionEngineSubmission( + SourceCode: templateStrategy.Render( + new CodeTemplateContext(referenceSolutionCode, functionName, inputs) + ), + LanguageId: judge0LanguageId, + Stdin: templateStrategy.BuildStdin(inputs), + TimeLimitMs: null, + MemoryLimitKb: null + )), + ]; + + List results = await SubmitAndPollAsync(submissions, cancellationToken); + + List warnings = []; + for (int i = 0; i < testCases.Count; i++) + { + ExecutionEngineResult? result = results.Count > i ? results[i] : null; + string? expected = testCases[i].ExpectedOutputs.OrderBy(e => e.Id).Select(e => e.Value).FirstOrDefault(); + string? actual = result is null ? null : ParseActualOutput(result.Stdout); + + bool ok = + result is not null + && result.Status == ExecutionEngineResultStatus.Accepted + && expected is not null + && actual is not null + && ValuesMatch(actual, expected); + + if (!ok) + { + warnings.Add( + $"Reference solution failed on case '{testCases[i].Name}' " + + $"(status: {result?.Status.ToString() ?? "none"}, expected: {expected}, actual: {actual}, " + + $"stderr: {result?.Stderr}, compileOutput: {result?.CompileOutput})." + ); + } + } + + return warnings; + } + + public async Task> SubmitAndPollAsync( + List submissions, + CancellationToken cancellationToken + ) + { + if (submissions.Count == 0) + return []; + + IReadOnlyList submitted = await executionEngine.SubmitBatchAsync( + submissions, + cancellationToken: cancellationToken + ); + List tokens = [.. submitted.Select(r => r.Token)]; + + Dictionary latest = submitted.ToDictionary(r => r.Token); + + for (int attempt = 0; attempt < MaxPollAttempts; attempt++) + { + IReadOnlyList polled = await executionEngine.PollBatchAsync( + tokens, + cancellationToken + ); + foreach (ExecutionEngineResult result in polled) + latest[result.Token] = result; + + bool allDone = latest.Values.All(r => + r.Status != ExecutionEngineResultStatus.Queued && r.Status != ExecutionEngineResultStatus.Processing + ); + + if (allDone) + break; + + await Task.Delay(PollDelay, cancellationToken); + } + + return [.. tokens.Select(t => latest[t])]; + } + + /// + /// Compares two output values the same tolerant way AssertStrategy.SetEquality + /// does in EvaluateStepHandler: array/object outputs are parsed as JSON and + /// re-serialized before comparing, so cosmetic differences (e.g. Python's + /// print([1, 2]) vs. the stored "[1,2]") don't make a correct reference + /// solution look broken. Falls back to a trimmed string comparison for anything that + /// isn't valid JSON. + /// + public static bool ValuesMatch(string actual, string expected) + { + string? normalizedActual = TryNormalizeJson(actual); + string? normalizedExpected = TryNormalizeJson(expected); + + return normalizedActual is not null && normalizedExpected is not null + ? normalizedActual == normalizedExpected + : string.Equals(actual.Trim(), expected.Trim(), StringComparison.Ordinal); + } + + public static string? TryNormalizeJson(string value) + { + try + { + using JsonDocument doc = JsonDocument.Parse(value.Trim()); + return JsonSerializer.Serialize(doc); + } + catch (JsonException) + { + return null; + } + } + + public static string? ParseActualOutput(string? stdout) + { + if (string.IsNullOrEmpty(stdout)) + return null; + + // A single trailing newline (the normal case for print()/console.log()) would + // otherwise show up as a spurious empty final "line" — mirrors Judge0PollStepHandler. + string[] lines = stdout.TrimEnd('\r', '\n').Split(["\r\n", "\r", "\n"], StringSplitOptions.None); + string actual = lines[^1]; + return string.IsNullOrWhiteSpace(actual) ? null : actual.Trim(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/EvaluateStepHandler.cs b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/EvaluateStepHandler.cs new file mode 100644 index 0000000..e5a02ff --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/EvaluateStepHandler.cs @@ -0,0 +1,240 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Events; +using StackDuel.Application.ExecutionEngine; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Domain.Submissions.Exceptions; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Infrastructure.ExecutionEngine.Assert; +using StackDuel.Infrastructure.Persistence; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StackDuel.Infrastructure.ExecutionEngine.StepHandlers; + +internal sealed partial class EvaluateStepHandler( + StackDuelDbContext db, + IDomainEventDispatcher domainEventDispatcher, + ILogger logger +) : IStepHandler +{ + // JsonSerializer.Serialize with no options writes enums as their numeric value — + // unlike the API's response formatter (configured with JsonStringEnumConverter in + // ApiServiceRegistration), this call has no such config, so without an explicit + // converter here the stored response payload would be the unreadable "{"Status":2}" + // instead of "{"Status":"Accepted"}" in the admin diagnostics view. + private static readonly JsonSerializerOptions ResponsePayloadOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + }; + + public bool CanHandle(ExecutionPipelineStepType stepType) => stepType == ExecutionPipelineStepType.Evaluate; + + public async Task ExecuteAsync(StepHandlerContext context) + { + var job = context.Job; + var ct = context.CancellationToken; + + var (pollResults, pollError) = LoadPollResults(job, context.Attempt.PipelineStepId); + if (pollError is not null) + return Fail(pollError); + + var submission = await db + .Submissions.Include(s => s.Results) + .FirstOrDefaultAsync(s => s.Id == job.SubmissionId, ct); + + if (submission is null) + return Fail($"Submission {job.SubmissionId} not found."); + + var configuredAssert = await db.AssertStepConfigurations.FirstOrDefaultAsync( + c => c.PipelineStepId == context.Step.Id, + ct + ); + + var testCaseIds = pollResults!.Select(r => r.TestCaseId).ToList(); + var testCases = await db.Set() + .Include(tc => tc.ExpectedOutputs) + .Where(tc => testCaseIds.Contains(tc.Id)) + .ToDictionaryAsync(tc => tc.Id, ct); + + ApplyPollResults(pollResults!, testCases, configuredAssert, submission); + + try + { + submission.Complete(); + } + catch (SubmissionNotCompleteException ex) + { + return Fail($"Could not complete submission: {ex.Message}"); + } + + await db.SaveChangesAsync(ct); + + await domainEventDispatcher.DispatchAsync(submission.PopDomainEvents(), ct); + + LogEvaluated(job.SubmissionId, submission.Status.ToString()); + + return new StepHandlerResult( + Succeeded: true, + ResponsePayload: JsonSerializer.Serialize(new { submission.Status }, ResponsePayloadOptions) + ); + } + + private static (List? Results, string? Error) LoadPollResults( + SubmissionJob job, + Guid currentPipelineStepId + ) + { + string? pollResponse = job + .Attempts.Where(a => a.PipelineStepId != currentPipelineStepId) + .OrderByDescending(a => a.StartedAt) + .Select(a => a.ResponsePayload) + .FirstOrDefault(p => p is not null); + + if (pollResponse is null) + return (null, "No poll response found from poll step."); + + List? pollResults; + try + { + pollResults = JsonSerializer.Deserialize>( + pollResponse, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + } + catch + { + return (null, "Could not deserialize poll results."); + } + + if (pollResults is null || pollResults.Count == 0) + return (null, "Poll results are empty."); + + return (pollResults, null); + } + + private static void ApplyPollResults( + List pollResults, + Dictionary testCases, + AssertStepConfiguration? configuredAssert, + Submission submission + ) + { + foreach (var pollResult in pollResults) + { + if (pollResult.TestCaseId == Guid.Empty) + continue; + + SubmissionResultStatus status = MapEngineStatus(pollResult.Status); + + if ( + status == SubmissionResultStatus.Accepted + && !pollResult.Precomputed + && testCases.TryGetValue(pollResult.TestCaseId, out TestCase? testCase) + ) + { + TestCaseExpectedOutput? expectedOutput = testCase.ExpectedOutputs.OrderBy(e => e.Id).FirstOrDefault(); + string? expected = expectedOutput?.Value; + + if (submission.Type != SubmissionType.Run || expected is not null) + { + string? actual = pollResult.ActualOutput?.TrimEnd('\n', '\r', ' '); + AssertStepConfiguration assertConfig = + configuredAssert ?? DefaultAssertConfigFor(expectedOutput?.ValueType); + status = Evaluate(actual, expected, assertConfig); + } + } + + submission.UpdateResult( + testCaseId: pollResult.TestCaseId, + status: status, + runtime: pollResult.RuntimeMs, + memoryUsed: pollResult.MemoryUsedKb, + actualOutput: pollResult.ActualOutput, + standardOutput: pollResult.Stdout, + standardError: pollResult.Stderr, + compileOutput: pollResult.CompileOutput + ); + } + } + + private static AssertStepConfiguration DefaultAssertConfigFor(string? valueType) => + valueType is "double" or "float" + ? new AssertStepConfiguration { Strategy = AssertStrategy.FloatTolerance, Tolerance = 1e-6m } + : new AssertStepConfiguration { Strategy = AssertStrategy.SetEquality, CaseSensitive = true }; + + private static SubmissionResultStatus Evaluate(string? actual, string? expected, AssertStepConfiguration config) + { + if (actual is null || expected is null) + return SubmissionResultStatus.WrongAnswer; + + bool match = config.Strategy switch + { + AssertStrategy.FloatTolerance => TryFloatCompare(actual, expected, config.Tolerance ?? 1e-6m), + AssertStrategy.SetEquality => NormalizeJson(actual) == NormalizeJson(expected), + _ => config.CaseSensitive + ? actual.Trim() == expected.Trim() + : string.Equals(actual.Trim(), expected.Trim(), StringComparison.OrdinalIgnoreCase), + }; + + return match ? SubmissionResultStatus.Accepted : SubmissionResultStatus.WrongAnswer; + } + + private static bool TryFloatCompare(string actual, string expected, decimal tolerance) + { + if (decimal.TryParse(actual.Trim(), out decimal a) && decimal.TryParse(expected.Trim(), out decimal e)) + return Math.Abs(a - e) <= tolerance; + return false; + } + + private static string NormalizeJson(string value) + { + try + { + var doc = JsonDocument.Parse(value.Trim()); + return JsonSerializer.Serialize(doc); + } + catch + { + return value.Trim(); + } + } + + private static SubmissionResultStatus MapEngineStatus(string? status) => + status switch + { + "Accepted" => SubmissionResultStatus.Accepted, + "WrongAnswer" => SubmissionResultStatus.WrongAnswer, + "TimeLimitExceeded" => SubmissionResultStatus.TimeLimitExceeded, + "CompilationError" => SubmissionResultStatus.CompileError, + "RuntimeError" => SubmissionResultStatus.RuntimeError, + _ => SubmissionResultStatus.RuntimeError, + }; + + private static StepHandlerResult Fail(string error) => new(Succeeded: false, Error: error); + + [LoggerMessage(Level = LogLevel.Information, Message = "Evaluated submission {SubmissionId}: {Status}")] + private partial void LogEvaluated(Guid submissionId, string status); + + /// + /// is set by the batched execution path (see + /// Judge0PollStepHandler) — there is already the final + /// verdict decided by the language's own test framework, so it must not be re-derived here + /// via against the stored expected output. + /// + private sealed record PollResultEntry( + Guid TestCaseId, + string? Stdout, + string? ActualOutput, + string? Stderr, + string? CompileOutput, + int? RuntimeMs, + int? MemoryUsedKb, + string? Status, + string? Token, + bool Precomputed = false + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/Judge0ExecutionStepHandler.cs b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/Judge0ExecutionStepHandler.cs new file mode 100644 index 0000000..6a2651e --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/Judge0ExecutionStepHandler.cs @@ -0,0 +1,385 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.ExecutionAssets; +using StackDuel.Application.ExecutionEngine; +using StackDuel.Application.Languages; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; +using StackDuel.Infrastructure.ExecutionEngine.Assert; +using StackDuel.Infrastructure.Persistence; +using System.Text.Json; + +namespace StackDuel.Infrastructure.ExecutionEngine.StepHandlers; + +internal sealed partial class Judge0ExecutionStepHandler( + ISubmissionWriteRepository submissionWriteRepository, + IProblemRepository problemRepository, + ILanguageReadRepository languageReadRepository, + IAdditionalFileBundleReadRepository additionalFileBundleReadRepository, + ICodeTemplateStrategyResolver templateResolver, + IBatchCodeTemplateStrategyResolver batchTemplateResolver, + IExecutionEngineStrategy executionEngine, + ITestCaseGeneratorService testCaseGenerator, + StackDuelDbContext db +) : IStepHandler +{ + // Covers Vitest/Node startup overhead on top of the per-case budget — a batch submission + // runs every case in one process, so the outer Judge0 time limit has to fit the whole run, + // not just one case. + private const int BatchStartupBufferMs = 5000; + + public bool CanHandle(ExecutionPipelineStepType stepType) => stepType == ExecutionPipelineStepType.Judge0Execute; + + public async Task ExecuteAsync(StepHandlerContext context) + { + var job = context.Job; + var ct = context.CancellationToken; + + var submission = await submissionWriteRepository.FindByIdAsync(job.SubmissionId, ct); + if (submission is null) + return Fail($"Submission {job.SubmissionId} not found."); + + var (problem, setup, setupError) = await ResolveSetupAsync(submission.ProblemSetupId, ct); + if (setupError is not null) + return Fail(setupError); + + var languageResult = await ResolveLanguageAsync(setup!.LanguageVersionId, ct); + if (languageResult.Error is not null) + return Fail(languageResult.Error); + + var orderedTestCaseIds = submission.Results.Select(r => r.TestCaseId).ToList(); + var (orderedTestCases, testCasesError) = await LoadOrderedTestCasesAsync(orderedTestCaseIds, ct); + if (testCasesError is not null) + return Fail(testCasesError); + + var (generationSpecs, generationSpecsError) = await LoadGenerationSpecsAsync(orderedTestCases!, ct); + if (generationSpecsError is not null) + return Fail(generationSpecsError); + + if (languageResult.VersionEntry!.ExecutionMode == TestExecutionMode.BatchedTestFramework) + { + var batchContext = new BatchExecutionContext( + problem!, + setup, + languageResult.Language!.Name.Value, + languageResult.VersionEntry.Judge0Id.Value + ); + + return await ExecuteBatchedAsync( + batchContext, + orderedTestCases!, + generationSpecs, + submission.SourceCode.Value, + ct + ); + } + + var templateStrategy = templateResolver.Resolve(languageResult.Language!.Name.Value); + + var (additionalFiles, additionalFilesError) = await ResolveAdditionalFilesAsync( + setup.AdditionalFileBundleId, + ct + ); + if (additionalFilesError is not null) + return Fail(additionalFilesError); + + var submissions = BuildSubmissions( + setup, + orderedTestCases!, + generationSpecs, + submission.SourceCode.Value, + submission.AdditionalFiles, + templateStrategy, + languageResult.VersionEntry.Judge0Id.Value, + additionalFiles + ); + + var results = await executionEngine.SubmitBatchAsync(submissions, job.SubmissionId, ct); + + var tokenMap = BuildTokenMap(orderedTestCases!, results); + + var payload = new ExecutePayload(languageResult.Language.Name.Value, TestExecutionMode.PerCase, tokenMap); + + return new StepHandlerResult(Succeeded: true, ResponsePayload: JsonSerializer.Serialize(payload)); + } + + private async Task ExecuteBatchedAsync( + BatchExecutionContext context, + IReadOnlyList testCases, + IReadOnlyDictionary generationSpecs, + string userCode, + CancellationToken ct + ) + { + AssertStepConfiguration? configuredAssert = await FindAssertConfigurationAsync(context.Setup.PipelineId, ct); + + var (batchCases, batchCasesError) = BuildBatchCases(testCases, generationSpecs, configuredAssert); + if (batchCasesError is not null) + return Fail(batchCasesError); + + IBatchCodeTemplateStrategy batchStrategy = batchTemplateResolver.Resolve(context.LanguageName); + + int perCaseTimeoutMs = context.Problem.TimeLimit.Milliseconds; + string source = batchStrategy.RenderBatch(userCode, context.Setup.FunctionName, batchCases!, perCaseTimeoutMs); + + int batchTimeLimitMs = (perCaseTimeoutMs * testCases.Count) + BatchStartupBufferMs; + + var submission = new ExecutionEngineSubmission( + SourceCode: source, + LanguageId: context.Judge0LanguageId, + Stdin: null, + TimeLimitMs: batchTimeLimitMs, + MemoryLimitKb: null + ); + + IReadOnlyList results = await executionEngine.SubmitBatchAsync([submission], null, ct); + string token = results[0].Token; + + var payload = new ExecutePayload( + context.LanguageName, + TestExecutionMode.BatchedTestFramework, + Tokens: [], + BatchToken: token, + BatchTestCaseIds: [.. testCases.Select(tc => tc.Id)] + ); + + return new StepHandlerResult(Succeeded: true, ResponsePayload: JsonSerializer.Serialize(payload)); + } + + private sealed record BatchExecutionContext(Problem Problem, ProblemSetup Setup, string LanguageName, int Judge0LanguageId); + + private (IReadOnlyList? Cases, string? Error) BuildBatchCases( + IReadOnlyList testCases, + IReadOnlyDictionary generationSpecs, + AssertStepConfiguration? configuredAssert + ) + { + List cases = []; + + foreach (TestCase testCase in testCases) + { + TestCaseExpectedOutput? expectedOutput = testCase.ExpectedOutputs.OrderBy(e => e.Id).FirstOrDefault(); + if (expectedOutput is null) + return (null, $"Test case {testCase.Id} has no expected output."); + + AssertStepConfiguration assertConfig = configuredAssert ?? DefaultAssertConfigFor(expectedOutput.ValueType); + + if (assertConfig.Strategy == AssertStrategy.Regex) + return (null, "Regex assertions are not supported in batched test execution mode."); + + BatchAssertStrategy batchAssertStrategy = assertConfig.Strategy switch + { + AssertStrategy.FloatTolerance => BatchAssertStrategy.FloatTolerance, + AssertStrategy.SetEquality => BatchAssertStrategy.SetEquality, + _ => BatchAssertStrategy.ExactMatch, + }; + + IReadOnlyList inputs = + testCase.Source == TestCaseSource.Generated + ? testCaseGenerator + .GenerateInputs(generationSpecs[testCase.GenerationSpecId!.Value], testCase.GenerationCaseIndex!.Value) + .Select(v => new CodeTemplateInput(v.Value, v.ValueType)) + .ToList() + : testCase.Inputs.Select(i => new CodeTemplateInput(i.Value, i.ValueType)).ToList(); + + cases.Add( + new BatchTestCase( + testCase.Id, + inputs, + expectedOutput.Value, + batchAssertStrategy, + assertConfig.Tolerance, + assertConfig.CaseSensitive + ) + ); + } + + return (cases, null); + } + + private async Task FindAssertConfigurationAsync(Guid pipelineId, CancellationToken ct) + { + Guid? evaluateStepId = await db + .Set() + .Where(s => EF.Property(s, "pipeline_id") == pipelineId && s.StepType == ExecutionPipelineStepType.Evaluate) + .Select(s => (Guid?)s.Id) + .FirstOrDefaultAsync(ct); + + if (evaluateStepId is null) + return null; + + return await db.AssertStepConfigurations.FirstOrDefaultAsync(c => c.PipelineStepId == evaluateStepId, ct); + } + + private static AssertStepConfiguration DefaultAssertConfigFor(string? valueType) => + valueType is "double" or "float" + ? new AssertStepConfiguration { Strategy = AssertStrategy.FloatTolerance, Tolerance = 1e-6m } + : new AssertStepConfiguration { Strategy = AssertStrategy.SetEquality, CaseSensitive = true }; + + private async Task<(Problem? Problem, ProblemSetup? Setup, string? Error)> ResolveSetupAsync( + Guid setupId, + CancellationToken ct + ) + { + var problem = await problemRepository.FindBySetupIdAsync(setupId, ct); + if (problem is null) + return (null, null, $"Problem for setup {setupId} not found."); + + var setup = problem.Setups.FirstOrDefault(s => s.Id == setupId); + if (setup is null) + return (null, null, $"Setup {setupId} not found on problem."); + + return (problem, setup, null); + } + + private async Task<(Language? Language, LanguageVersionEntry? VersionEntry, string? Error)> ResolveLanguageAsync( + Guid versionId, + CancellationToken ct + ) + { + var languages = await languageReadRepository.FindLanguagesByVersionId([versionId], ct); + var language = languages.FirstOrDefault(); + if (language is null) + return (null, null, $"Language for version {versionId} not found."); + + var versionEntry = language.Versions.FirstOrDefault(v => v.Id == versionId); + if (versionEntry is null) + return (null, null, $"Language version entry {versionId} not found."); + + return (language, versionEntry, null); + } + + private async Task<(byte[]? Content, string? Error)> ResolveAdditionalFilesAsync( + Guid? bundleId, + CancellationToken ct + ) + { + if (bundleId is null) + return (null, null); + + var bundle = await additionalFileBundleReadRepository.FindByIdAsync(bundleId.Value, ct); + if (bundle is null) + return (null, $"Additional file bundle {bundleId} not found."); + + return (bundle.Content, null); + } + + private async Task<(List? TestCases, string? Error)> LoadOrderedTestCasesAsync( + List orderedTestCaseIds, + CancellationToken ct + ) + { + var testCaseMap = await db.Set() + .AsNoTracking() + .AsSplitQuery() + .Include(tc => tc.Inputs.OrderBy(i => i.Position)) + .Include(tc => tc.ExpectedOutputs) + .Where(tc => orderedTestCaseIds.Contains(tc.Id)) + .ToDictionaryAsync(tc => tc.Id, ct); + + if (testCaseMap.Count != orderedTestCaseIds.Count) + return (null, "One or more submission test cases could not be loaded."); + + return (orderedTestCaseIds.Select(id => testCaseMap[id]).ToList(), null); + } + + private async Task<(Dictionary Specs, string? Error)> LoadGenerationSpecsAsync( + List orderedTestCases, + CancellationToken ct + ) + { + var generationSpecIds = orderedTestCases + .Where(tc => tc.Source == TestCaseSource.Generated) + .Select(tc => tc.GenerationSpecId!.Value) + .Distinct() + .ToList(); + + if (generationSpecIds.Count == 0) + return ([], null); + + var generationSpecs = await db.Set() + .AsNoTracking() + .Where(s => generationSpecIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, ct); + + if (generationSpecs.Count != generationSpecIds.Count) + return ([], "One or more generated test cases could not be regenerated: their generation spec is missing."); + + return (generationSpecs, null); + } + + private List BuildSubmissions( + ProblemSetup setup, + IReadOnlyCollection testCases, + IReadOnlyDictionary generationSpecs, + string userCode, + IReadOnlyList submittedFiles, + ICodeTemplateStrategy templateStrategy, + int judge0LanguageId, + byte[]? additionalFiles + ) + { + var additionalSourceFiles = submittedFiles.Select(f => new CodeTemplateSourceFile(f.Path, f.Content)).ToList(); + + return + [ + .. testCases.Select(testCase => + { + var inputs = + testCase.Source == TestCaseSource.Generated + ? testCaseGenerator + .GenerateInputs( + generationSpecs[testCase.GenerationSpecId!.Value], + testCase.GenerationCaseIndex!.Value + ) + .Select(v => new CodeTemplateInput(v.Value, v.ValueType)) + .ToList() + : testCase.Inputs.Select(i => new CodeTemplateInput(i.Value, i.ValueType)).ToList(); + + var templateContext = new CodeTemplateContext( + UserCode: userCode, + FunctionName: setup.FunctionName, + Inputs: inputs, + AdditionalFiles: additionalSourceFiles + ); + + return new ExecutionEngineSubmission( + SourceCode: templateStrategy.Render(templateContext), + LanguageId: judge0LanguageId, + Stdin: templateStrategy.BuildStdin(inputs), + TimeLimitMs: null, + MemoryLimitKb: null, + AdditionalFiles: templateStrategy.BuildAdditionalFiles(templateContext) ?? additionalFiles + ); + }), + ]; + } + + private static Dictionary BuildTokenMap( + List testCases, + IReadOnlyList results + ) + { + return testCases + .Zip(results, (testCase, result) => new { testCase.Id, result.Token }) + .ToDictionary(x => x.Token, x => x.Id); + } + + private static StepHandlerResult Fail(string error) => new(Succeeded: false, Error: error); + + internal sealed record ExecutePayload( + string LanguageName, + TestExecutionMode Mode, + Dictionary Tokens, + string? BatchToken = null, + List? BatchTestCaseIds = null + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/Judge0PollStepHandler.cs b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/Judge0PollStepHandler.cs new file mode 100644 index 0000000..e77ae2d --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/Judge0PollStepHandler.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Logging; +using StackDuel.Application.ExecutionEngine; +using StackDuel.Domain.ExecutionPipelines.Enums; +using StackDuel.Domain.Languages.Enums; +using System.Text.Json; + +namespace StackDuel.Infrastructure.ExecutionEngine.StepHandlers; + +/// +/// Polls Judge0 for the tokens stored by the execute step. +/// Returns = true only when all tokens have left +/// the queued/processing/internal-error state. The processor should retry (up to MaxAttempts) +/// if still pending. Stores the polled results JSON in . +/// +/// Branches on the execute step's recorded : PerCase +/// polls one token per test case exactly as before; BatchedTestFramework polls the +/// single batch token and expands its one result into one enriched row per test case via the +/// language's — each such row already +/// carries its final verdict (Vitest's own assertion decided it), so it's marked +/// Precomputed for EvaluateStepHandler to trust as-is. +/// +internal sealed partial class Judge0PollStepHandler( + IExecutionEngineStrategy engine, + ICodeTemplateStrategyResolver templateResolver, + IBatchCodeTemplateStrategyResolver batchTemplateResolver, + ILogger logger +) : IStepHandler +{ + public bool CanHandle(ExecutionPipelineStepType stepType) => stepType == ExecutionPipelineStepType.Judge0Poll; + + public async Task ExecuteAsync(StepHandlerContext context) + { + var job = context.Job; + var attempt = context.Attempt; + var ct = context.CancellationToken; + + string? previousResponse = job + .Attempts.Where(a => a.PipelineStepId != attempt.PipelineStepId) + .OrderByDescending(a => a.StartedAt) + .Select(a => a.ResponsePayload) + .FirstOrDefault(p => p is not null); + + if (previousResponse is null) + return new StepHandlerResult(Succeeded: false, Error: "No token map found from execute step."); + + Judge0ExecutionStepHandler.ExecutePayload? executePayload; + try + { + executePayload = JsonSerializer.Deserialize(previousResponse); + } + catch + { + return new StepHandlerResult(Succeeded: false, Error: "Could not deserialize token map from execute step."); + } + + if (executePayload is null) + return new StepHandlerResult(Succeeded: false, Error: "Token map is empty."); + + return executePayload.Mode == TestExecutionMode.BatchedTestFramework + ? await PollBatchedAsync(executePayload, job.SubmissionId, ct) + : await PollPerCaseAsync(executePayload, job.SubmissionId, ct); + } + + private async Task PollPerCaseAsync( + Judge0ExecutionStepHandler.ExecutePayload executePayload, + Guid submissionId, + CancellationToken ct + ) + { + if (executePayload.Tokens.Count == 0) + return new StepHandlerResult(Succeeded: false, Error: "Token map is empty."); + + Dictionary tokenMap = executePayload.Tokens; + ICodeTemplateStrategy templateStrategy = templateResolver.Resolve(executePayload.LanguageName); + + LogPolling(tokenMap.Count, submissionId); + + IReadOnlyList results; + try + { + results = await engine.PollBatchAsync(tokenMap.Keys.ToList(), ct); + } + catch (Exception ex) + { + LogPollFailed(submissionId, ex); + return new StepHandlerResult(Succeeded: false, Error: $"Judge0 poll failed: {ex.Message}"); + } + + if (!AllDone(results, submissionId)) + return new StepHandlerResult(Succeeded: false, Error: "Submissions still processing; will retry."); + + var enriched = results.Select(r => + { + var parsedOutput = templateStrategy.ParseOutput(r.Stdout); + + return new PollResultEntry( + r.Token, + tokenMap.TryGetValue(r.Token, out var id) ? id : Guid.Empty, + parsedOutput.UserLogs, + parsedOutput.ActualResult, + r.Stderr, + r.CompileOutput, + r.RuntimeMs, + r.MemoryUsedKb, + r.Status.ToString(), + Precomputed: false + ); + }); + + return new StepHandlerResult(Succeeded: true, ResponsePayload: JsonSerializer.Serialize(enriched)); + } + + private async Task PollBatchedAsync( + Judge0ExecutionStepHandler.ExecutePayload executePayload, + Guid submissionId, + CancellationToken ct + ) + { + if (executePayload.BatchToken is null || executePayload.BatchTestCaseIds is null) + return new StepHandlerResult(Succeeded: false, Error: "Batch token or case list is missing."); + + LogPolling(1, submissionId); + + IReadOnlyList results; + try + { + results = await engine.PollBatchAsync([executePayload.BatchToken], ct); + } + catch (Exception ex) + { + LogPollFailed(submissionId, ex); + return new StepHandlerResult(Succeeded: false, Error: $"Judge0 poll failed: {ex.Message}"); + } + + if (!AllDone(results, submissionId)) + return new StepHandlerResult(Succeeded: false, Error: "Submission still processing; will retry."); + + ExecutionEngineResult result = results[0]; + + // The Judge0 submission itself (one Node process running every case) failed to even + // produce a Vitest report — e.g. it crashed or was killed before finishing. Every case + // it was supposed to cover is reported as a runtime error rather than silently dropped. + if (result.Status != ExecutionEngineResultStatus.Accepted) + { + var failedRows = executePayload.BatchTestCaseIds.Select(id => new PollResultEntry( + executePayload.BatchToken, + id, + null, + null, + result.Stderr, + result.CompileOutput, + result.RuntimeMs, + result.MemoryUsedKb, + result.Status.ToString(), + Precomputed: true + )); + + return new StepHandlerResult(Succeeded: true, ResponsePayload: JsonSerializer.Serialize(failedRows)); + } + + IBatchCodeTemplateStrategy batchStrategy = batchTemplateResolver.Resolve(executePayload.LanguageName); + IReadOnlyList batchResults = batchStrategy.ParseBatchOutput( + result.Stdout, + executePayload.BatchTestCaseIds + ); + + var enriched = batchResults.Select(br => new PollResultEntry( + executePayload.BatchToken, + br.TestCaseId, + null, + br.ActualOutput, + result.Stderr, + result.CompileOutput, + br.RuntimeMs ?? result.RuntimeMs, + result.MemoryUsedKb, + MapBatchStatus(br.Status), + Precomputed: true + )); + + return new StepHandlerResult(Succeeded: true, ResponsePayload: JsonSerializer.Serialize(enriched)); + } + + // InternalError is Judge0's own "something went wrong in the sandbox" status (e.g. a + // transient RapidAPI/cgroup hiccup) — treated the same as still-pending rather than + // terminal, since it's frequently transient and there's no SubmissionResultStatus for it + // anyway. Bounded by the step's existing MaxAttempts, same as Queued/Processing. + private bool AllDone(IReadOnlyList results, Guid submissionId) + { + bool allDone = results.All(r => + r.Status != ExecutionEngineResultStatus.Queued + && r.Status != ExecutionEngineResultStatus.Processing + && r.Status != ExecutionEngineResultStatus.InternalError + ); + + if (!allDone) + { + LogStillPending( + results.Count(r => + r.Status + is ExecutionEngineResultStatus.Queued + or ExecutionEngineResultStatus.Processing + or ExecutionEngineResultStatus.InternalError + ), + submissionId + ); + } + + return allDone; + } + + private static string MapBatchStatus(BatchCaseStatus status) => + status switch + { + BatchCaseStatus.Accepted => "Accepted", + BatchCaseStatus.WrongAnswer => "WrongAnswer", + BatchCaseStatus.TimedOut => "TimeLimitExceeded", + _ => "RuntimeError", + }; + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Polling {Count} Judge0 tokens for submission {SubmissionId}" + )] + private partial void LogPolling(int count, Guid submissionId); + + [LoggerMessage(Level = LogLevel.Debug, Message = "{Count} tokens still pending for submission {SubmissionId}")] + private partial void LogStillPending(int count, Guid submissionId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Judge0 poll failed for submission {SubmissionId}")] + private partial void LogPollFailed(Guid submissionId, Exception exception); + + /// + /// marks a row whose is already a + /// final verdict (set by the batched path, where Vitest's own assertion decided + /// pass/fail) rather than a raw Judge0 engine status still needing comparison against the + /// expected output in EvaluateStepHandler. + /// + internal sealed record PollResultEntry( + string Token, + Guid TestCaseId, + string? Stdout, + string? ActualOutput, + string? Stderr, + string? CompileOutput, + int? RuntimeMs, + int? MemoryUsedKb, + string? Status, + bool Precomputed + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/StepHandlerRegistry.cs b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/StepHandlerRegistry.cs new file mode 100644 index 0000000..e220ef1 --- /dev/null +++ b/StackDuel.Infrastructure/ExecutionEngine/StepHandlers/StepHandlerRegistry.cs @@ -0,0 +1,13 @@ +using StackDuel.Application.ExecutionEngine; +using StackDuel.Domain.ExecutionPipelines.Enums; + +namespace StackDuel.Infrastructure.ExecutionEngine.StepHandlers; + +internal sealed class StepHandlerRegistry(IEnumerable handlers) : IStepHandlerRegistry +{ + private readonly IReadOnlyList _handlers = [.. handlers]; + + public IStepHandler Resolve(ExecutionPipelineStepType stepType) => + _handlers.FirstOrDefault(h => h.CanHandle(stepType)) + ?? throw new InvalidOperationException($"No step handler registered for step type '{stepType}'."); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/InfrastructureServiceRegistration.cs b/StackDuel.Infrastructure/InfrastructureServiceRegistration.cs new file mode 100644 index 0000000..61d838f --- /dev/null +++ b/StackDuel.Infrastructure/InfrastructureServiceRegistration.cs @@ -0,0 +1,485 @@ +using Azure.Messaging.ServiceBus; +using Azure.Storage.Blobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using RabbitMQ.Client; +using StackDuel.Application.Audit; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Configuration; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.Dashboard; +using StackDuel.Application.ExecutionAssets; +using StackDuel.Application.ExecutionEngine; +using StackDuel.Application.FeatureFlags; +using StackDuel.Application.Feedback; +using StackDuel.Application.Games; +using StackDuel.Application.Groups; +using StackDuel.Application.Jobs.DailyChallenges; +using StackDuel.Application.Jobs.Games; +using StackDuel.Application.Jobs.Submissions; +using StackDuel.Application.Jobs.Users; +using StackDuel.Application.Languages; +using StackDuel.Application.LanguageServer; +using StackDuel.Application.Leaderboards; +using StackDuel.Application.Messaging; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.RequiredLanguages; +using StackDuel.Application.Services.Users; +using StackDuel.Application.Settings; +using StackDuel.Application.Submissions; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Application.Tracks; +using StackDuel.Application.Users; +using StackDuel.Domain.Authorization; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.Feedback; +using StackDuel.Domain.Games; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.RequiredLanguages; +using StackDuel.Domain.ProblemValidation; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestSuites; +using StackDuel.Domain.Users; +using StackDuel.Infrastructure.ExecutionEngine; +using StackDuel.Infrastructure.ExecutionEngine.CodeTemplates; +using StackDuel.Infrastructure.ExecutionEngine.Judge0; +using StackDuel.Infrastructure.ExecutionEngine.StepHandlers; +using StackDuel.Infrastructure.Jobs.DailyChallenges; +using StackDuel.Infrastructure.Jobs.Games; +using StackDuel.Infrastructure.Jobs.ProblemValidation; +using StackDuel.Infrastructure.Jobs.Submissions; +using StackDuel.Infrastructure.Jobs.TestCaseGeneration; +using StackDuel.Infrastructure.Jobs.Users; +using StackDuel.Infrastructure.LanguageServer; +using StackDuel.Infrastructure.Messaging; +using StackDuel.Infrastructure.Messaging.Consumers; +using StackDuel.Infrastructure.Persistence; +using StackDuel.Infrastructure.Persistence.Read; +using StackDuel.Infrastructure.Persistence.Seeders; +using StackDuel.Infrastructure.Persistence.Seeders.Campaigns; +using StackDuel.Infrastructure.Persistence.Seeders.Problems; +using StackDuel.Infrastructure.ProblemValidation; +using StackDuel.Infrastructure.Repositories.AuditLog; +using StackDuel.Infrastructure.Repositories.Authorization; +using StackDuel.Infrastructure.Repositories.Campaigns; +using StackDuel.Infrastructure.Repositories.DailyChallenges; +using StackDuel.Infrastructure.Repositories.Dashboard; +using StackDuel.Infrastructure.Repositories.ExecutionAssets; +using StackDuel.Infrastructure.Repositories.ExecutionPipelines; +using StackDuel.Infrastructure.Repositories.FeatureFlags; +using StackDuel.Infrastructure.Repositories.Feedback; +using StackDuel.Infrastructure.Repositories.Games; +using StackDuel.Infrastructure.Repositories.Languages; +using StackDuel.Infrastructure.Repositories.Leaderboards; +using StackDuel.Infrastructure.Repositories.Problems; +using StackDuel.Infrastructure.Repositories.ProblemValidation; +using StackDuel.Infrastructure.Repositories.SubmissionJobs; +using StackDuel.Infrastructure.Repositories.Submissions; +using StackDuel.Infrastructure.Repositories.TestCaseGeneration; +using StackDuel.Infrastructure.Repositories.TestSuites; +using StackDuel.Infrastructure.Repositories.Tracks; +using StackDuel.Infrastructure.Repositories.Users; +using StackDuel.Infrastructure.Settings; +using StackDuel.Infrastructure.Storage; +using StackDuel.Infrastructure.TestCaseGeneration; + +namespace StackDuel.Infrastructure; + +public static class InfrastructureServiceRegistration +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions(configuration); + + services.AddPersistence(); + services.AddRepositories(); + services.AddExecutionEngine(configuration); + services.AddTestCaseGeneration(); + services.AddLanguageServer(configuration); + services.AddAvatarStorage(configuration); + + services.AddMessageBus(configuration); + services.AddJobs(configuration); + + services.AddSeeder(); + + return services; + } + + /// + /// Registration for the Seeder CLI — persistence and repositories only. Seeders don't run + /// test case generation directly anymore; they enqueue a TestCaseGenerationJob row (via + /// ITestCaseGenerationJobRepository, from AddRepositories) and the API's background + /// processor picks it up later. That means the Seeder needs no Judge0/execution-engine + /// dependency at all, and no message bus or Quartz jobs since it's a one-shot CLI, not a host. + /// + public static IServiceCollection AddInfrastructureForSeeder( + this IServiceCollection services, + IConfiguration configuration + ) + { + services.AddOption(configuration); + services.AddPersistence(); + services.AddRepositories(); + services.AddSeeder(); + + return services; + } + + private static IServiceCollection AddMessageBus(this IServiceCollection services, IConfiguration configuration) + { + var opts = + configuration.GetSection(MessageBusOptions.SectionName).Get() ?? new MessageBusOptions(); + + if (opts.Transport.Equals("AzureServiceBus", StringComparison.OrdinalIgnoreCase)) + { + services.AddSingleton(new ServiceBusClient(opts.AzureServiceBus.ConnectionString)); + services.AddScoped(); + services.AddHostedService(); + } + else + { + services.AddSingleton(_ => + { + var factory = new ConnectionFactory + { + HostName = opts.RabbitMQ.Host, + VirtualHost = opts.RabbitMQ.VirtualHost, + UserName = opts.RabbitMQ.Username, + Password = opts.RabbitMQ.Password, + DispatchConsumersAsync = true, + }; + return factory.CreateConnection(); + }); + services.AddScoped(); + services.AddHostedService(); + } + + return services; + } + + private static IServiceCollection AddExecutionEngine(this IServiceCollection services, IConfiguration configuration) + { + var opts = + configuration.GetSection(ExecutionEngineOptions.SectionName).Get() + ?? new ExecutionEngineOptions(); + + if (opts.Judge0.Enabled) + { + var judge0Opts = opts.Judge0; + services.AddSingleton(judge0Opts); + services.AddHttpClient( + nameof(Judge0ExecutionEngineStrategy), + client => + { + client.BaseAddress = new Uri(judge0Opts.BaseUrl.TrimEnd('/') + "/"); + if (!string.IsNullOrWhiteSpace(judge0Opts.ApiKey)) + client.DefaultRequestHeaders.Add("X-RapidAPI-Key", judge0Opts.ApiKey); + if (!string.IsNullOrWhiteSpace(judge0Opts.Host)) + client.DefaultRequestHeaders.Add("X-RapidAPI-Host", judge0Opts.Host); + } + ); + services.AddScoped(sp => + { + var httpFactory = sp.GetRequiredService(); + var http = httpFactory.CreateClient(nameof(Judge0ExecutionEngineStrategy)); + return new Judge0ExecutionEngineStrategy(http, judge0Opts); + }); + } + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(opts.JsxTranspiler); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + + return services; + } + + private static IServiceCollection AddLanguageServer(this IServiceCollection services, IConfiguration configuration) + { + var opts = + configuration.GetSection(LanguageServerOptions.SectionName).Get() + ?? new LanguageServerOptions(); + + services.AddSingleton(opts); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + + return services; + } + + private static IServiceCollection AddJobs(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + var opts = + configuration.GetSection(JobScheduleOptions.SectionName).Get() + ?? new JobScheduleOptions(); + + services.AddQuartz(q => + { + q.AddJob(GameExpirySweepJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(GameExpirySweepJob.Key) + .WithIdentity("GameExpirySweepJob-trigger") + .WithCronSchedule(opts.GameExpirySweepJob.CronExpression) + ); + + q.AddJob(SubmissionCleanupJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(SubmissionCleanupJob.Key) + .WithIdentity("SubmissionCleanupJob-trigger") + .WithCronSchedule(opts.SubmissionCleanupJob.CronExpression) + ); + + q.AddJob(SubmissionJobProcessorJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(SubmissionJobProcessorJob.Key) + .WithIdentity("SubmissionJobProcessorJob-trigger") + .WithCronSchedule(opts.SubmissionJobProcessorJob.CronExpression) + ); + + q.AddJob(TestCaseGenerationJobProcessorJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(TestCaseGenerationJobProcessorJob.Key) + .WithIdentity("TestCaseGenerationJobProcessorJob-trigger") + .WithCronSchedule(opts.TestCaseGenerationJobProcessorJob.CronExpression) + ); + + q.AddJob(TestCaseRefreshJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(TestCaseRefreshJob.Key) + .WithIdentity("TestCaseRefreshJob-trigger") + .WithCronSchedule(opts.TestCaseRefreshJob.CronExpression) + ); + + q.AddJob(ProblemValidationJobProcessorJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(ProblemValidationJobProcessorJob.Key) + .WithIdentity("ProblemValidationJobProcessorJob-trigger") + .WithCronSchedule(opts.ProblemValidationJobProcessorJob.CronExpression) + ); + + q.AddJob(AvatarCleanupJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(AvatarCleanupJob.Key) + .WithIdentity("AvatarCleanupJob-trigger") + .WithCronSchedule(opts.AvatarCleanupJob.CronExpression) + ); + + q.AddJob(DailyChallengeAssignmentJob.Key, j => j.StoreDurably()); + q.AddTrigger(t => + t.ForJob(DailyChallengeAssignmentJob.Key) + .WithIdentity("DailyChallengeAssignmentJob-trigger") + .WithCronSchedule(opts.DailyChallengeAssignmentJob.CronExpression) + ); + }); + + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + + return services; + } + + private static IServiceCollection AddOptions(this IServiceCollection services, IConfiguration configuration) + { + services.AddOption(configuration); + services.AddOption(configuration); + services.AddOption(configuration); + + return services; + } + + private static IServiceCollection AddAvatarStorage(this IServiceCollection services, IConfiguration configuration) + { + var opts = + configuration.GetSection(AzureStorageOptions.SectionName).Get() + ?? new AzureStorageOptions(); + + services.AddSingleton(new BlobServiceClient(opts.ConnectionString)); + services.AddScoped(); + + return services; + } + + private static IServiceCollection AddPersistence(this IServiceCollection services) + { + services.AddDbContextPool( + (serviceProvider, dbOptions) => + { + var options = serviceProvider.GetRequiredService(); + + dbOptions.UseNpgsql(options.DefaultConnection); + + dbOptions.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); + } + ); + + services.AddDbContextPool( + (serviceProvider, dbOptions) => + { + var options = serviceProvider.GetRequiredService(); + + dbOptions.UseNpgsql(options.DefaultConnection); + } + ); + + return services; + } + + private static IServiceCollection AddRepositories(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + private static IServiceCollection AddTestCaseGeneration(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + // Registration order is seeding order (the built-in container resolves IEnumerable/GetServices + // in registration order) — a seeder must be registered after everything it depends on + // (e.g. LanguageSeeder reads Tracks, so TrackSeeder has to run first). + private static IServiceCollection AddSeeder(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Also consumed by concrete type (GetOrCreateAsync) by the problem seeders below, + // so it needs its own registration in addition to the IStaticSeeder one. + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + return services; + } + + public static async Task MigrateAsync(this IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + public static async Task SeedAsync( + this IServiceProvider services, + SeederOptions options, + CancellationToken cancellationToken = default + ) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (options.SeedStaticData) + { + foreach (IStaticSeeder seeder in scope.ServiceProvider.GetServices()) + { + await seeder.SeedAsync(cancellationToken); + db.ChangeTracker.Clear(); + } + } + + if (options.SeedDemoData) + { + foreach (IDemoSeeder seeder in scope.ServiceProvider.GetServices()) + { + await seeder.SeedAsync(cancellationToken); + db.ChangeTracker.Clear(); + } + } + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/DailyChallenges/DailyChallengeAssignmentJob.cs b/StackDuel.Infrastructure/Jobs/DailyChallenges/DailyChallengeAssignmentJob.cs new file mode 100644 index 0000000..06c1d21 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/DailyChallenges/DailyChallengeAssignmentJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using Quartz; +using StackDuel.Application.Jobs.DailyChallenges; + +namespace StackDuel.Infrastructure.Jobs.DailyChallenges; + +[DisallowConcurrentExecution] +internal sealed partial class DailyChallengeAssignmentJob( + IDailyChallengeAssignmentService assignmentService, + ILogger logger +) : IJob +{ + public static readonly JobKey Key = new(nameof(DailyChallengeAssignmentJob), "DailyChallenges"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + await assignmentService.AssignUpcomingChallengesAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing DailyChallengeAssignmentJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/DailyChallenges/DailyChallengeAssignmentService.cs b/StackDuel.Infrastructure/Jobs/DailyChallenges/DailyChallengeAssignmentService.cs new file mode 100644 index 0000000..79b3b11 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/DailyChallenges/DailyChallengeAssignmentService.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging; +using StackDuel.Application.DailyChallenges; +using StackDuel.Application.Jobs.DailyChallenges; +using StackDuel.Application.Problems; +using StackDuel.Domain.DailyChallenges.Entities; + +namespace StackDuel.Infrastructure.Jobs.DailyChallenges; + +internal sealed partial class DailyChallengeAssignmentService( + IDailyChallengeRepository dailyChallengeRepository, + IProblemPoolRepository problemPoolRepository, + IProblemReadRepository problemReadRepository, + ILogger logger +) : IDailyChallengeAssignmentService +{ + public const string PoolKey = "daily-challenge"; + public const int BufferDays = 30; + + /// + /// Keeps the next days' challenges assigned by cycling through the + /// pool's members in their admin-defined order. The cursor resumes from one past whatever + /// problem was most recently assigned, so it continues the sequence across runs rather than + /// restarting it; a pool smaller than simply wraps around and repeats + /// rather than leaving days unassigned. + /// + public async Task AssignUpcomingChallengesAsync(CancellationToken cancellationToken = default) + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + + var pool = await problemPoolRepository.FindByKeyAsync(PoolKey, cancellationToken); + if (pool is null) + { + LogPoolMissing(); + return; + } + + List orderedProblemIds = [.. await problemReadRepository.GetOrderedEligibleProblemIdsAsync( + pool.Id, + cancellationToken + )]; + if (orderedProblemIds.Count == 0) + { + LogPoolEmpty(); + return; + } + + int cursor = await ResolveStartingCursorAsync(orderedProblemIds, cancellationToken); + + for (int offset = 0; offset < BufferDays; offset++) + { + DateOnly date = today.AddDays(offset); + cancellationToken.ThrowIfCancellationRequested(); + + if (await dailyChallengeRepository.FindByDateAsync(date, cancellationToken) is not null) + { + LogAlreadyAssigned(date); + continue; + } + + Guid problemId = orderedProblemIds[cursor % orderedProblemIds.Count]; + cursor++; + + await dailyChallengeRepository.AddAsync(new DailyChallenge(date, problemId), cancellationToken); + LogAssigned(date, problemId); + } + } + + private async Task ResolveStartingCursorAsync( + List orderedProblemIds, + CancellationToken cancellationToken + ) + { + IReadOnlyList history = await dailyChallengeRepository.GetAllOrderedByDateDescendingAsync( + cancellationToken + ); + DailyChallenge? mostRecent = history.FirstOrDefault(); + if (mostRecent is null) + return 0; + + int lastIndex = orderedProblemIds.IndexOf(mostRecent.ProblemId); + return lastIndex >= 0 ? lastIndex + 1 : 0; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Daily challenge for {Date} already assigned; skipping.")] + private partial void LogAlreadyAssigned(DateOnly date); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "No '" + PoolKey + "' problem pool exists; skipping daily challenge assignment." + )] + private partial void LogPoolMissing(); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "The '" + PoolKey + "' problem pool has no eligible problems; skipping daily challenge assignment." + )] + private partial void LogPoolEmpty(); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Assigned problem {ProblemId} as the daily challenge for {Date}." + )] + private partial void LogAssigned(DateOnly date, Guid problemId); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Games/GameExpirySweepJob.cs b/StackDuel.Infrastructure/Jobs/Games/GameExpirySweepJob.cs new file mode 100644 index 0000000..cd483f2 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Games/GameExpirySweepJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using Quartz; +using StackDuel.Application.Jobs.Games; + +namespace StackDuel.Infrastructure.Jobs.Games; + +[DisallowConcurrentExecution] +internal sealed partial class GameExpirySweepJob( + IGameExpirySweepService sweepService, + ILogger logger +) : IJob +{ + public static readonly JobKey Key = new(nameof(GameExpirySweepJob), "Games"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + await sweepService.RunAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Executing GameExpirySweepJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/ProblemValidation/ProblemValidationJobProcessorJob.cs b/StackDuel.Infrastructure/Jobs/ProblemValidation/ProblemValidationJobProcessorJob.cs new file mode 100644 index 0000000..18bc4e5 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/ProblemValidation/ProblemValidationJobProcessorJob.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; +using Quartz; + +namespace StackDuel.Infrastructure.Jobs.ProblemValidation; + +[DisallowConcurrentExecution] +internal sealed partial class ProblemValidationJobProcessorJob( + ProblemValidationJobProcessorService processorService, + ILogger logger +) : IJob +{ + public static readonly JobKey Key = new(nameof(ProblemValidationJobProcessorJob), "ProblemValidation"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + await processorService.RunAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Executing ProblemValidationJobProcessorJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/ProblemValidation/ProblemValidationJobProcessorService.cs b/StackDuel.Infrastructure/Jobs/ProblemValidation/ProblemValidationJobProcessorService.cs new file mode 100644 index 0000000..2d74864 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/ProblemValidation/ProblemValidationJobProcessorService.cs @@ -0,0 +1,323 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.ProblemValidation; +using StackDuel.Domain.ProblemValidation.Entities; +using StackDuel.Domain.ProblemValidation.Enums; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.Enums; +using StackDuel.Infrastructure.Persistence; +using StackDuel.Infrastructure.ProblemValidation; + +namespace StackDuel.Infrastructure.Jobs.ProblemValidation; + +/// +/// Drives a through its two passes: validate every required +/// language's reference solution (), then, once +/// all pass, enqueue a per setup and wait for those to finish +/// before publishing the problem. Same per-job-scope/claim/release structure as +/// , chaining into that +/// existing, unmodified pipeline for the actual generation work rather than reimplementing it. +/// +internal sealed partial class ProblemValidationJobProcessorService( + IServiceScopeFactory scopeFactory, + ILogger logger +) +{ + private const int BatchSize = 5; + private const string ProblemNoLongerExistsMessage = "Problem no longer exists."; + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); + + public async Task RunAsync(CancellationToken cancellationToken) + { + await RunPendingPassAsync(cancellationToken); + await RunAwaitingGenerationPassAsync(cancellationToken); + } + + public async Task RunPendingForJobAsync(Guid jobId, CancellationToken cancellationToken) => + await ProcessPendingInNewScopeAsync(jobId, cancellationToken); + + public async Task RunAwaitingGenerationPassAsync(CancellationToken cancellationToken) + { + IReadOnlyList jobs; + using (var scope = scopeFactory.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + jobs = await repo.FindAwaitingGenerationAsync(BatchSize, cancellationToken); + } + + LogFoundAwaitingGeneration(jobs.Count); + + foreach (ProblemValidationJob job in jobs) + await ProcessAwaitingGenerationInNewScopeAsync(job.Id, cancellationToken); + } + + private async Task RunPendingPassAsync(CancellationToken cancellationToken) + { + IReadOnlyList jobs; + using (var scope = scopeFactory.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + jobs = await repo.FindPendingAsync(BatchSize, cancellationToken); + } + + LogFoundPending(jobs.Count); + + foreach (ProblemValidationJob job in jobs) + await ProcessPendingInNewScopeAsync(job.Id, cancellationToken); + } + + private async Task ProcessPendingInNewScopeAsync(Guid jobId, CancellationToken ct) => + await ProcessInClaimedScopeAsync(jobId, ProblemValidationJobStatus.Pending, HandlePendingJobAsync, ct); + + private async Task ProcessAwaitingGenerationInNewScopeAsync(Guid jobId, CancellationToken ct) => + await ProcessInClaimedScopeAsync( + jobId, + ProblemValidationJobStatus.AwaitingGeneration, + HandleAwaitingGenerationJobAsync, + ct + ); + + /// + /// Shared claim/load/status-check/release scaffolding for both passes. Creates a fresh DI + /// scope and DbContext per job, atomically claims the job's processing lock, loads it, verifies + /// it's still in the expected status (another worker may have already advanced it), then hands + /// off to the pass-specific handler. + /// + private async Task ProcessInClaimedScopeAsync( + Guid jobId, + ProblemValidationJobStatus expectedStatus, + Func handle, + CancellationToken ct + ) + { + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + + var jobRepository = sp.GetRequiredService(); + + bool claimed = await jobRepository.TryClaimAsync(jobId, LeaseDuration, ct); + if (!claimed) + { + LogJobAlreadyLocked(jobId); + return; + } + + try + { + ProblemValidationJob? job = await jobRepository.FindByIdAsync(jobId, ct); + if (job is null) + { + LogJobNotFound(jobId); + return; + } + + if (job.Status != expectedStatus) + { + LogJobAlreadyProcessed(jobId); + return; + } + + await handle(sp, job, ct); + } + finally + { + await jobRepository.ReleaseLockAsync(jobId, CancellationToken.None); + } + } + + private async Task HandlePendingJobAsync(IServiceProvider sp, ProblemValidationJob job, CancellationToken ct) + { + Guid jobId = job.Id; + var jobRepository = sp.GetRequiredService(); + var messagePublisher = sp.GetRequiredService(); + + var problemRepository = sp.GetRequiredService(); + Problem? problem = await problemRepository.FindByIdAsync(job.ProblemId, ct); + + if (problem is null) + { + job.Fail(ProblemNoLongerExistsMessage); + await jobRepository.UpdateAsync(job, ct); + LogFailed(jobId, ProblemNoLongerExistsMessage); + return; + } + + var validationService = sp.GetRequiredService(); + ReferenceSolutionValidationResult result = await validationService.ValidateAsync(job.ProblemId, ct); + + var db = sp.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(ct); + + if (!result.AllPassed) + { + string summary = result.BuildFailureSummary(); + + problem.FailValidation(summary); + await problemRepository.UpdateAsync(problem, ct); + + job.Fail(summary); + await jobRepository.UpdateAsync(job, ct); + + await transaction.CommitAsync(ct); + LogFailed(jobId, summary); + return; + } + + var testCaseGenerationJobRepository = sp.GetRequiredService(); + List generationJobIds = []; + + ProblemGenerationSpec generationSpec = problem.GenerationSpec!; + + foreach (SetupValidationOutcome outcome in result.Outcomes) + { + ProblemSetup setup = problem.Setups.First(s => s.Id == outcome.ProblemSetupId); + + TestCaseGenerationJob generationJob = new( + setup.Id, + setup.ReferenceSolutionCode!, + generationSpec.Parameters, + generationSpec.OutputValueType, + generationSpec.TargetCaseCount, + generationSpec.Seed + ); + + await testCaseGenerationJobRepository.AddAsync(generationJob, ct); + generationJobIds.Add(generationJob.Id); + } + + job.MoveToAwaitingGeneration(generationJobIds); + await jobRepository.UpdateAsync(job, ct); + + await transaction.CommitAsync(ct); + LogValidationPassed(jobId, generationJobIds.Count); + + foreach (Guid generationJobId in generationJobIds) + await messagePublisher.PublishAsync( + new TestCaseGenerationJobContinuationMessage(generationJobId), + CancellationToken.None + ); + } + + private async Task HandleAwaitingGenerationJobAsync( + IServiceProvider sp, + ProblemValidationJob job, + CancellationToken ct + ) + { + Guid jobId = job.Id; + var jobRepository = sp.GetRequiredService(); + var testCaseGenerationJobRepository = sp.GetRequiredService(); + + List generationJobs = []; + foreach (Guid generationJobId in job.TestCaseGenerationJobIds) + { + TestCaseGenerationJob? generationJob = await testCaseGenerationJobRepository.FindByIdAsync( + generationJobId, + ct + ); + if (generationJob is not null) + generationJobs.Add(generationJob); + } + + if (generationJobs.Any(g => g.Status == TestCaseGenerationJobStatus.Pending)) + { + LogStillAwaitingGeneration(jobId); + return; + } + + var problemRepository = sp.GetRequiredService(); + Problem? problem = await problemRepository.FindByIdAsync(job.ProblemId, ct); + + if (problem is null) + { + job.Fail(ProblemNoLongerExistsMessage); + await jobRepository.UpdateAsync(job, ct); + LogFailed(jobId, ProblemNoLongerExistsMessage); + return; + } + + var db = sp.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(ct); + + List failedGenerationJobs = + [ + .. generationJobs.Where(g => g.Status == TestCaseGenerationJobStatus.Failed), + ]; + + if (failedGenerationJobs.Count > 0) + { + string reason = + $"Test case generation failed for {failedGenerationJobs.Count} setup(s): " + + string.Join("; ", failedGenerationJobs.Select(g => g.FailureReason ?? "unknown error")); + + problem.FailValidation(reason); + await problemRepository.UpdateAsync(problem, ct); + + job.Fail(reason); + await jobRepository.UpdateAsync(job, ct); + + await transaction.CommitAsync(ct); + LogFailed(jobId, reason); + return; + } + + string summary = $"Generated verified test cases for {generationJobs.Count} setup(s)."; + + problem.CompleteValidation(); + await problemRepository.UpdateAsync(problem, ct); + + job.Complete(summary); + await jobRepository.UpdateAsync(job, ct); + + await transaction.CommitAsync(ct); + LogCompleted(jobId, summary); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Found {Count} pending problem validation job(s)")] + private partial void LogFoundPending(int count); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Found {Count} problem validation job(s) awaiting generation" + )] + private partial void LogFoundAwaitingGeneration(int count); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Problem validation job {JobId} is already locked by another worker" + )] + private partial void LogJobAlreadyLocked(Guid jobId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Problem validation job {JobId} not found when processing in isolated scope" + )] + private partial void LogJobNotFound(Guid jobId); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Problem validation job {JobId} was already processed")] + private partial void LogJobAlreadyProcessed(Guid jobId); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Problem validation job {JobId} is still awaiting test case generation" + )] + private partial void LogStillAwaitingGeneration(Guid jobId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Problem validation job {JobId} passed validation, enqueued {Count} generation job(s)" + )] + private partial void LogValidationPassed(Guid jobId, int count); + + [LoggerMessage(Level = LogLevel.Information, Message = "Problem validation job {JobId} completed: {Summary}")] + private partial void LogCompleted(Guid jobId, string summary); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Problem validation job {JobId} failed: {Reason}")] + private partial void LogFailed(Guid jobId, string reason); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Submissions/SubmissionCleanupJob.cs b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionCleanupJob.cs new file mode 100644 index 0000000..97ca294 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionCleanupJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using Quartz; +using StackDuel.Application.Jobs.Submissions; + +namespace StackDuel.Infrastructure.Jobs.Submissions; + +[DisallowConcurrentExecution] +internal sealed partial class SubmissionCleanupJob( + ISubmissionCleanupService cleanupService, + ILogger logger +) : IJob +{ + public static readonly JobKey Key = new(nameof(SubmissionCleanupJob), "Submissions"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + await cleanupService.RunAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing SubmissionCleanupJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Submissions/SubmissionCleanupService.cs b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionCleanupService.cs new file mode 100644 index 0000000..d733d2a --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionCleanupService.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Jobs.Submissions; +using StackDuel.Domain.SubmissionJobs.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Jobs.Submissions; + +internal sealed partial class SubmissionCleanupService( + StackDuelDbContext context, + ILogger logger +) : ISubmissionCleanupService +{ + // Jobs stuck in Pending/Running longer than this are considered abandoned. + private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(30); + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + LogRunning(); + + var cutoff = DateTime.UtcNow - StaleThreshold; + + int count = await context + .SubmissionJobs.Where(j => + (j.Status == SubmissionJobStatus.Pending || j.Status == SubmissionJobStatus.Running) + && j.CreatedAt < cutoff + ) + .ExecuteUpdateAsync( + s => + s.SetProperty(j => j.Status, SubmissionJobStatus.Failed) + .SetProperty( + j => j.FailureReason, + "Job abandoned: exceeded stale threshold without completing." + ) + .SetProperty(j => j.CompletedAt, DateTime.UtcNow), + cancellationToken + ); + + if (count > 0) + LogAbandoned(count); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Running submission cleanup")] + private partial void LogRunning(); + + private void LogAbandoned(int count) => + logger.LogWarning("Marked {Count} stale submission job(s) as failed.", count); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Submissions/SubmissionJobProcessorJob.cs b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionJobProcessorJob.cs new file mode 100644 index 0000000..64f3627 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionJobProcessorJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using Quartz; + +namespace StackDuel.Infrastructure.Jobs.Submissions; + +[DisallowConcurrentExecution] +internal sealed partial class SubmissionJobProcessorJob( + SubmissionJobProcessorService processorService, + ILogger logger +) : IJob +{ + public static readonly JobKey Key = new(nameof(SubmissionJobProcessorJob), "Submissions"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + + await processorService.RunAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing SubmissionJobProcessorJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Submissions/SubmissionJobProcessorService.cs b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionJobProcessorService.cs new file mode 100644 index 0000000..d919289 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Submissions/SubmissionJobProcessorService.cs @@ -0,0 +1,350 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Events; +using StackDuel.Application.ExecutionEngine; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.SubmissionJobs.Enums; +using StackDuel.Domain.Submissions; + +namespace StackDuel.Infrastructure.Jobs.Submissions; + +/// +/// Picks up pending entries and processes them. +/// Creates a new DI scope per job so each job gets its own isolated DbContext. +/// +internal sealed partial class SubmissionJobProcessorService( + IServiceScopeFactory scopeFactory, + ILogger logger +) +{ + private const int BatchSize = 20; + + // How long a worker holds exclusive claim on a job while processing a single + // step. Must comfortably exceed the slowest step's execution time; if a worker + // crashes mid-step the lease simply expires and another worker can pick it up. + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(2); + + public async Task RunAsync(CancellationToken cancellationToken) + { + IReadOnlyList jobs; + using (var scope = scopeFactory.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + jobs = await repo.FindPendingAsync(BatchSize, cancellationToken); + } + + LogFound(jobs.Count); + + foreach (var job in jobs) + { + await ProcessInNewScopeAsync(job.Id, cancellationToken); + } + } + + public async Task RunForSubmissionAsync(Guid submissionId, CancellationToken cancellationToken) + { + Guid? jobId; + using (var scope = scopeFactory.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + var job = await repo.FindBySubmissionIdAsync(submissionId, cancellationToken); + if (job is null) + { + LogSubmissionJobNotFound(submissionId); + return; + } + + if (job.Status is SubmissionJobStatus.Completed or SubmissionJobStatus.Failed) + { + LogJobAlreadyProcessed(job.Id); + return; + } + + jobId = job.Id; + } + + await ProcessInNewScopeAsync(jobId.Value, cancellationToken); + } + + /// + /// Loads and processes a single job inside its own DI scope and DbContext. + /// + private async Task ProcessInNewScopeAsync(Guid jobId, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + + var jobRepository = sp.GetRequiredService(); + + // Atomically claim the job so the Quartz sweep and the RabbitMQ-driven + // continuation flow can never process the same job at the same time. + bool claimed = await jobRepository.TryClaimAsync(jobId, LeaseDuration, ct); + if (!claimed) + { + LogJobAlreadyLocked(jobId); + return; + } + + try + { + var pipelineRepository = sp.GetRequiredService(); + var handlerRegistry = sp.GetRequiredService(); + var messagePublisher = sp.GetRequiredService(); + var submissionRepository = sp.GetRequiredService(); + var domainEventDispatcher = sp.GetRequiredService(); + + var job = await jobRepository.FindByIdAsync(jobId, ct); + if (job is null) + { + logger.LogWarning("Job {JobId} not found when processing in isolated scope.", jobId); + return; + } + + if (job.Status is SubmissionJobStatus.Completed or SubmissionJobStatus.Failed) + { + LogJobAlreadyProcessed(job.Id); + return; + } + + await ProcessJobAsync( + job, + jobRepository, + pipelineRepository, + handlerRegistry, + messagePublisher, + submissionRepository, + domainEventDispatcher, + ct + ); + } + finally + { + await jobRepository.ReleaseLockAsync(jobId, CancellationToken.None); + } + } + + private async Task ProcessJobAsync( + SubmissionJob job, + ISubmissionJobRepository jobRepository, + IExecutionPipelineRepository pipelineRepository, + IStepHandlerRegistry handlerRegistry, + IMessagePublisher messagePublisher, + ISubmissionWriteRepository submissionRepository, + IDomainEventDispatcher domainEventDispatcher, + CancellationToken ct + ) + { + if (job.CurrentStepId is null) + { + job.Complete(); + await jobRepository.UpdateAsync(job, ct); + return; + } + + var pipeline = await pipelineRepository.FindByIdWithStepsAsync(job.PipelineId, ct); + if (pipeline is null) + { + job.Fail($"Pipeline {job.PipelineId} not found."); + await jobRepository.UpdateAsync(job, ct); + await FailSubmissionAsync(job.SubmissionId, submissionRepository, domainEventDispatcher, ct); + return; + } + + var step = pipeline.Steps.FirstOrDefault(s => s.Id == job.CurrentStepId); + if (step is null) + { + job.Fail($"Step {job.CurrentStepId} not found in pipeline {job.PipelineId}."); + await jobRepository.UpdateAsync(job, ct); + await FailSubmissionAsync(job.SubmissionId, submissionRepository, domainEventDispatcher, ct); + return; + } + + int priorAttempts = job.AttemptCountForCurrentStep(); + if (priorAttempts >= step.MaxAttempts) + { + job.Fail($"Step '{step.Name}' exceeded max attempts ({step.MaxAttempts})."); + await jobRepository.UpdateAsync(job, ct); + await FailSubmissionAsync(job.SubmissionId, submissionRepository, domainEventDispatcher, ct); + return; + } + + var attempt = job.StartAttempt(); + + await jobRepository.PersistAttemptAsync(job, attempt, ct); + + IStepHandler handler; + try + { + handler = handlerRegistry.Resolve(step.StepType); + } + catch (InvalidOperationException ex) + { + attempt.Fail(ex.Message); + job.Fail(ex.Message); + await jobRepository.UpdateAsync(job, ct); + await FailSubmissionAsync(job.SubmissionId, submissionRepository, domainEventDispatcher, ct); + return; + } + + LogExecutingStep(job.Id, step.Name, step.StepType.ToString()); + + StepHandlerResult result; + try + { + result = await handler.ExecuteAsync(new StepHandlerContext(job, step, attempt, ct)); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Unhandled exception in step handler '{StepType}' for job {JobId}.", + step.StepType, + job.Id + ); + result = new StepHandlerResult(Succeeded: false, Error: $"Unhandled exception: {ex.Message}"); + } + + if (result.Succeeded) + { + attempt.Succeed(result.RequestPayload, result.ResponsePayload); + + var nextStep = pipeline.NextStep(step.StepOrder); + if (nextStep is not null) + { + job.AdvanceTo(nextStep.Id); + LogAdvanced(job.Id, nextStep.Name); + } + else + { + job.Complete(); + LogCompleted(job.Id); + } + } + else + { + attempt.Fail(result.Error ?? "Unknown error.", result.RequestPayload, result.ResponsePayload); + + bool hasRetriesLeft = job.AttemptCountForCurrentStep() < step.MaxAttempts; + if (!hasRetriesLeft) + { + job.Fail($"Step '{step.Name}' failed after {step.MaxAttempts} attempt(s): {result.Error}"); + LogStepFailed(job.Id, step.Name, result.Error); + await jobRepository.UpdateAsync(job, ct); + await FailSubmissionAsync(job.SubmissionId, submissionRepository, domainEventDispatcher, ct); + return; + } + else + { + job.ResetToPending(); + LogStepRetrying(job.Id, step.Name, job.AttemptCountForCurrentStep(), step.MaxAttempts); + } + } + + await jobRepository.UpdateAsync(job, ct); + + // Always release the lock BEFORE publishing any continuation message. + // If we publish while still holding the lock, the second consumer + // (ConsumerConcurrency=2) can immediately receive the message, call + // TryClaimAsync, see the unexpired lease, log "already locked", ack the + // message, and discard it — leaving the job stranded until the Quartz + // sweep fires (~2 min). Releasing first guarantees the next consumer + // can always claim the job. + if (job.Status == SubmissionJobStatus.Pending) + { + await jobRepository.ReleaseLockAsync(job.Id, CancellationToken.None); + + bool isRetryOfSameStep = job.CurrentStepId == step.Id; + if (isRetryOfSameStep && step.IsPolling) + { + var delay = ComputePollDelay(job.AttemptCountForCurrentStep(), step.TimeoutSeconds); + await Task.Delay(delay, ct); + } + + await messagePublisher.PublishAsync(new SubmissionJobContinuationMessage(job.SubmissionId), ct); + } + } + + /// + /// Marks all non-terminal submission results as + /// and transitions the submission to a terminal state so the client stops polling. + /// + private async Task FailSubmissionAsync( + Guid submissionId, + ISubmissionWriteRepository submissionRepository, + IDomainEventDispatcher domainEventDispatcher, + CancellationToken ct + ) + { + try + { + var submission = await submissionRepository.FindByIdAsync(submissionId, ct); + if ( + submission is null + || submission.Status + is Domain.Submissions.Enums.SubmissionStatus.Accepted + or Domain.Submissions.Enums.SubmissionStatus.WrongAnswer + ) + return; + + submission.Fail(); + await submissionRepository.UpdateAsync(submission, ct); + await domainEventDispatcher.DispatchAsync(submission.PopDomainEvents(), CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to mark submission {SubmissionId} as failed after job failure.", submissionId); + } + } + + // Most Judge0 submissions finish within a couple hundred milliseconds once + // dequeued (confirmed: avg poll attempt duration is ~213ms, and most jobs + // only need 3 poll attempts). A flat 1s delay between attempts wastes time + // on that common fast case. Start fast and back off exponentially instead, + // capped at the step's configured TimeoutSeconds so the worst-case total + // wait budget (MaxAttempts * cap) is unchanged. + private const int PollBaseDelayMs = 200; + private const double PollBackoffMultiplier = 1.7; + + private static TimeSpan ComputePollDelay(int attemptNumber, int capSeconds) + { + double delayMs = PollBaseDelayMs * Math.Pow(PollBackoffMultiplier, Math.Max(0, attemptNumber - 1)); + double cappedMs = Math.Min(delayMs, capSeconds * 1000d); + return TimeSpan.FromMilliseconds(cappedMs); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Found {Count} pending submission jobs.")] + private partial void LogFound(int count); + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing step '{StepName}' ({StepType}) for job {JobId}")] + private partial void LogExecutingStep(Guid jobId, string stepName, string stepType); + + [LoggerMessage(Level = LogLevel.Information, Message = "Job {JobId} advanced to step '{StepName}'")] + private partial void LogAdvanced(Guid jobId, string stepName); + + [LoggerMessage(Level = LogLevel.Information, Message = "Job {JobId} completed successfully.")] + private partial void LogCompleted(Guid jobId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Job {JobId} step '{StepName}' failed permanently: {Error}")] + private partial void LogStepFailed(Guid jobId, string stepName, string? error); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Job {JobId} step '{StepName}' attempt {Attempt}/{Max} failed; will retry." + )] + private partial void LogStepRetrying(Guid jobId, string stepName, int attempt, int max); + + [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception processing job {JobId}")] + private partial void LogJobFailed(Guid jobId, Exception ex); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Job {JobId} was already processed by another worker — skipping.")] + private partial void LogJobAlreadyProcessed(Guid jobId); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Job {JobId} is currently locked by another worker — skipping.")] + private partial void LogJobAlreadyLocked(Guid jobId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "No submission job found for submission {SubmissionId}.")] + private partial void LogSubmissionJobNotFound(Guid submissionId); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseGenerationJobProcessorJob.cs b/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseGenerationJobProcessorJob.cs new file mode 100644 index 0000000..79b1dab --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseGenerationJobProcessorJob.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; +using Quartz; + +namespace StackDuel.Infrastructure.Jobs.TestCaseGeneration; + +[DisallowConcurrentExecution] +internal sealed partial class TestCaseGenerationJobProcessorJob( + TestCaseGenerationJobProcessorService processorService, + ILogger logger +) : IJob +{ + public static readonly JobKey Key = new(nameof(TestCaseGenerationJobProcessorJob), "TestCaseGeneration"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + await processorService.RunAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Executing TestCaseGenerationJobProcessorJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseGenerationJobProcessorService.cs b/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseGenerationJobProcessorService.cs new file mode 100644 index 0000000..26832c8 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseGenerationJobProcessorService.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.Enums; + +namespace StackDuel.Infrastructure.Jobs.TestCaseGeneration; + +/// +/// Picks up pending entries and runs generation for each. +/// Creates a new DI scope per job so each job gets its own isolated DbContext — same pattern +/// as SubmissionJobProcessorService, minus the multi-step machinery, since generation is a +/// single-shot operation rather than a pipeline. +/// +internal sealed partial class TestCaseGenerationJobProcessorService( + IServiceScopeFactory scopeFactory, + ILogger logger +) +{ + // Generation makes many Judge0 round trips per job (a full batch of attempts), so batches + // are kept small relative to SubmissionJobProcessorService's — this isn't latency-sensitive. + private const int BatchSize = 5; + + // Must comfortably exceed how long a single problem's generation can take (attempts up to + // TargetCaseCount * 15, in rounds — see TestCaseGenerationService). If a worker crashes + // mid-run the lease simply expires and another sweep picks it back up. + private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(10); + + public async Task RunAsync(CancellationToken cancellationToken) + { + IReadOnlyList jobs; + using (var scope = scopeFactory.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + jobs = await repo.FindPendingAsync(BatchSize, cancellationToken); + } + + LogFound(jobs.Count); + + foreach (var job in jobs) + { + await ProcessInNewScopeAsync(job.Id, cancellationToken); + } + } + + public async Task RunForJobAsync(Guid jobId, CancellationToken cancellationToken) => + await ProcessInNewScopeAsync(jobId, cancellationToken); + + private async Task ProcessInNewScopeAsync(Guid jobId, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + + var jobRepository = sp.GetRequiredService(); + var messagePublisher = sp.GetRequiredService(); + + bool claimed = await jobRepository.TryClaimAsync(jobId, LeaseDuration, ct); + if (!claimed) + { + LogJobAlreadyLocked(jobId); + return; + } + + try + { + var job = await jobRepository.FindByIdAsync(jobId, ct); + if (job is null) + { + LogJobNotFound(jobId); + return; + } + + if (job.Status != TestCaseGenerationJobStatus.Pending) + { + LogJobAlreadyProcessed(jobId); + return; + } + + var generationService = sp.GetRequiredService(); + + try + { + GenerateTestCasesResult result = await generationService.GenerateAsync( + new GenerateTestCasesRequest( + job.ProblemSetupId, + job.ReferenceSolutionCode, + job.Parameters, + job.OutputValueType, + job.TargetCaseCount, + job.Seed + ), + ct + ); + + string summary = + $"Generated {result.Generated}/{result.Requested} " + + $"({result.Skipped} attempts skipped, {result.Warnings.Count} warnings)."; + + // A job that produced at least some verified cases is a completion, even if it + // fell short of the target (result.Succeeded is the stricter "hit target + // exactly" signal) — only zero output (e.g. the sanity check rejected the + // reference solution) counts as a real failure. + if (result.Generated > 0) + job.Complete(summary); + else + job.Fail(summary); + + await jobRepository.UpdateAsync(job, ct); + LogCompleted(jobId, summary); + } + catch (Exception ex) + { + job.Fail($"Unhandled error: {ex.Message}"); + await jobRepository.UpdateAsync(job, ct); + LogFailed(jobId, ex); + } + + await messagePublisher.PublishAsync( + new TestCaseGenerationJobCompletedMessage(jobId), + CancellationToken.None + ); + } + finally + { + await jobRepository.ReleaseLockAsync(jobId, CancellationToken.None); + } + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Found {Count} pending test case generation job(s)")] + private partial void LogFound(int count); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Test case generation job {JobId} is already locked by another worker" + )] + private partial void LogJobAlreadyLocked(Guid jobId); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Test case generation job {JobId} not found when processing in isolated scope" + )] + private partial void LogJobNotFound(Guid jobId); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Test case generation job {JobId} was already processed")] + private partial void LogJobAlreadyProcessed(Guid jobId); + + [LoggerMessage(Level = LogLevel.Information, Message = "Test case generation job {JobId} completed: {Summary}")] + private partial void LogCompleted(Guid jobId, string summary); + + [LoggerMessage(Level = LogLevel.Error, Message = "Test case generation job {JobId} failed")] + private partial void LogFailed(Guid jobId, Exception exception); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseRefreshJob.cs b/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseRefreshJob.cs new file mode 100644 index 0000000..35e7247 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/TestCaseGeneration/TestCaseRefreshJob.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging; +using Quartz; +using StackDuel.Application.TestCaseGeneration; + +namespace StackDuel.Infrastructure.Jobs.TestCaseGeneration; + +[DisallowConcurrentExecution] +internal sealed partial class TestCaseRefreshJob(ITestCaseRefreshService refreshService, ILogger logger) + : IJob +{ + public static readonly JobKey Key = new(nameof(TestCaseRefreshJob), "TestCaseGeneration"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + int enqueued = await refreshService.EnqueueRefreshesAsync(context.CancellationToken); + LogCompleted(enqueued); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing TestCaseRefreshJob")] + private partial void LogExecuting(); + + [LoggerMessage(Level = LogLevel.Information, Message = "TestCaseRefreshJob enqueued {Count} refresh job(s)")] + private partial void LogCompleted(int count); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Users/AvatarCleanupJob.cs b/StackDuel.Infrastructure/Jobs/Users/AvatarCleanupJob.cs new file mode 100644 index 0000000..7cc9461 --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Users/AvatarCleanupJob.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; +using Quartz; +using StackDuel.Application.Jobs.Users; + +namespace StackDuel.Infrastructure.Jobs.Users; + +[DisallowConcurrentExecution] +internal sealed partial class AvatarCleanupJob(IAvatarCleanupService cleanupService, ILogger logger) + : IJob +{ + public static readonly JobKey Key = new(nameof(AvatarCleanupJob), "Users"); + + public async Task Execute(IJobExecutionContext context) + { + LogExecuting(); + await cleanupService.RunAsync(context.CancellationToken); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Executing AvatarCleanupJob")] + private partial void LogExecuting(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Jobs/Users/AvatarCleanupService.cs b/StackDuel.Infrastructure/Jobs/Users/AvatarCleanupService.cs new file mode 100644 index 0000000..76e40dd --- /dev/null +++ b/StackDuel.Infrastructure/Jobs/Users/AvatarCleanupService.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Jobs.Users; +using StackDuel.Application.Services.Users; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Jobs.Users; + +internal sealed partial class AvatarCleanupService( + StackDuelDbContext context, + IUserAvatarWriteRepository userAvatarRepository, + IAvatarBlobStorage avatarBlobStorage, + ILogger logger +) : IAvatarCleanupService +{ + public async Task RunAsync(CancellationToken cancellationToken = default) + { + LogRunning(); + + List userIds = await context.UserAvatars.Select(a => a.UserId).Distinct().ToListAsync(cancellationToken); + + int deletedCount = 0; + + foreach (Guid userId in userIds) + { + if (cancellationToken.IsCancellationRequested) + break; + + User? user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + if (user is null) + continue; + + IReadOnlyList avatars = await userAvatarRepository.GetAllByUserIdNewestFirstAsync( + userId, + cancellationToken + ); + + foreach (UserAvatar stale in AvatarRetentionPolicy.SelectStale(avatars, user.ImageUrl?.Value)) + { + await avatarBlobStorage.DeleteAsync(stale.ImageUrl.Value, cancellationToken); + await userAvatarRepository.DeleteAsync(stale, cancellationToken); + deletedCount++; + } + } + + if (deletedCount > 0) + LogDeleted(deletedCount); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Running avatar cleanup")] + private partial void LogRunning(); + + private void LogDeleted(int count) => + logger.LogInformation("Deleted {Count} unused avatar(s) during cleanup.", count); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/LanguageServer/JavaLanguageServerAdapter.cs b/StackDuel.Infrastructure/LanguageServer/JavaLanguageServerAdapter.cs new file mode 100644 index 0000000..0697be9 --- /dev/null +++ b/StackDuel.Infrastructure/LanguageServer/JavaLanguageServerAdapter.cs @@ -0,0 +1,84 @@ +using StackDuel.Application.Configuration; +using StackDuel.Application.LanguageServer; + +namespace StackDuel.Infrastructure.LanguageServer; + +internal sealed class JavaLanguageServerAdapter(LanguageServerOptions options) : ILanguageServerAdapter +{ + public string LanguageSlug => "java"; + + public string DocumentFileName => "Solution.java"; + + public Task PrepareWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + { + string filePath = Path.Combine(workspaceDirectory, DocumentFileName); + return File.WriteAllTextAsync(filePath, "class Solution {\n}\n", cancellationToken); + } + + public LanguageServerProcessStartInfo BuildProcessStartInfo(string workspaceDirectory) + { + JavaLanguageServerOptions javaOptions = options.Java; + string sessionRootDirectory = + Path.GetDirectoryName(Path.GetFullPath(workspaceDirectory)) + ?? throw new InvalidOperationException($"Could not resolve a parent directory for '{workspaceDirectory}'."); + string dataDirectory = Path.Combine(sessionRootDirectory, "data"); + + List arguments = + [ + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Dosgi.checkConfiguration=true", + $"-Dosgi.sharedConfiguration.area={javaOptions.JdtlsConfigDirectory}", + "-Dosgi.sharedConfiguration.area.readOnly=true", + "-Dosgi.configuration.cascaded=true", + "-Xms1G", + "--add-modules=ALL-SYSTEM", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "-jar", + javaOptions.JdtlsLauncherJarPath, + "-data", + dataDirectory, + ]; + + string javaBinaryName = OperatingSystem.IsWindows() ? "java.exe" : "java"; + string javaExecutable = string.IsNullOrWhiteSpace(javaOptions.JdtlsRuntimeJavaHome) + ? "java" + : Path.Combine(javaOptions.JdtlsRuntimeJavaHome, "bin", javaBinaryName); + + return new LanguageServerProcessStartInfo(javaExecutable, arguments, workspaceDirectory); + } + + public object? BuildInitializationOptions(string workspaceDirectory) + { + string javaHome = options.Java.JavaHome; + if (string.IsNullOrWhiteSpace(javaHome)) + return null; + + return new + { + settings = new + { + java = new + { + home = javaHome, + configuration = new + { + runtimes = new object[] + { + new + { + name = "JavaSE-17", + path = javaHome, + @default = true, + }, + }, + }, + }, + }, + }; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/LanguageServer/LanguageServerIdleSessionReaper.cs b/StackDuel.Infrastructure/LanguageServer/LanguageServerIdleSessionReaper.cs new file mode 100644 index 0000000..d5654af --- /dev/null +++ b/StackDuel.Infrastructure/LanguageServer/LanguageServerIdleSessionReaper.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Configuration; +using StackDuel.Application.LanguageServer; + +namespace StackDuel.Infrastructure.LanguageServer; + +internal sealed class LanguageServerIdleSessionReaper( + ILanguageServerSessionManager sessionManager, + LanguageServerOptions options, + ILogger logger +) : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!options.Enabled) + return; + + try + { + await sessionManager.SweepOrphanedWorkspacesAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to sweep orphaned language server workspaces on startup."); + } + + TimeSpan idleTimeout = TimeSpan.FromMinutes(Math.Max(1, options.IdleTimeoutMinutes)); + using PeriodicTimer timer = new(PollInterval); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await sessionManager.ReapIdleSessionsAsync(idleTimeout, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Failed to reap idle language server sessions."); + } + } + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/LanguageServer/LanguageServerProcess.cs b/StackDuel.Infrastructure/LanguageServer/LanguageServerProcess.cs new file mode 100644 index 0000000..3aa0745 --- /dev/null +++ b/StackDuel.Infrastructure/LanguageServer/LanguageServerProcess.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; +using StackDuel.Application.LanguageServer; +using System.Diagnostics; +using System.Text; + +namespace StackDuel.Infrastructure.LanguageServer; + +internal sealed class LanguageServerProcess : ILanguageServerProcess +{ + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + private readonly Process _process; + + private LanguageServerProcess(Process process, ILogger logger) + { + _process = process; + Completion = process.WaitForExitAsync(); + _ = DrainStandardErrorAsync(process, logger); + } + + public static LanguageServerProcess Start(LanguageServerProcessStartInfo startInfo, ILogger logger) + { + ProcessStartInfo psi = new(startInfo.FileName) + { + WorkingDirectory = startInfo.WorkingDirectory, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Utf8NoBom, + StandardInputEncoding = Utf8NoBom, + }; + + foreach (string argument in startInfo.Arguments) + psi.ArgumentList.Add(argument); + + Process process = + Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start process '{startInfo.FileName}'."); + + return new LanguageServerProcess(process, logger); + } + + public Stream Input => _process.StandardInput.BaseStream; + + public Stream Output => _process.StandardOutput.BaseStream; + + public Task Completion { get; } + + public bool HasExited => _process.HasExited; + + public int ProcessId => _process.Id; + + public async ValueTask DisposeAsync() + { + if (!_process.HasExited) + { + try + { + _process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) { } + } + + _process.Dispose(); + await Task.CompletedTask; + } + + private static async Task DrainStandardErrorAsync(Process process, ILogger logger) + { + try + { + string? line; + while ((line = await process.StandardError.ReadLineAsync()) is not null) + logger.LogDebug("Language server stderr: {Line}", line); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) { } + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/LanguageServer/LanguageServerSessionManager.cs b/StackDuel.Infrastructure/LanguageServer/LanguageServerSessionManager.cs new file mode 100644 index 0000000..bb2b63f --- /dev/null +++ b/StackDuel.Infrastructure/LanguageServer/LanguageServerSessionManager.cs @@ -0,0 +1,254 @@ +using Microsoft.Extensions.Logging; +using StackDuel.Application.Configuration; +using StackDuel.Application.LanguageServer; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace StackDuel.Infrastructure.LanguageServer; + +internal sealed class LanguageServerSessionManager( + IEnumerable adapters, + LanguageServerOptions options, + ILoggerFactory loggerFactory +) : ILanguageServerSessionManager +{ + private static readonly string RootDirectory = Path.Combine(Path.GetTempPath(), "stackduel-lsp"); + + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly ConcurrentDictionary _sessions = new(); + private readonly SemaphoreSlim _admissionSemaphore = new(1, 1); + + public async Task StartSessionAsync( + Guid userId, + string languageSlug, + CancellationToken cancellationToken + ) + { + if (!options.Enabled) + return LanguageServerSessionStartResult.Failure(LanguageServerSessionStartError.Disabled); + + ILanguageServerAdapter? adapter = adapters.FirstOrDefault(a => + a.LanguageSlug.Equals(languageSlug, StringComparison.OrdinalIgnoreCase) + ); + + if (adapter is null) + return LanguageServerSessionStartResult.Failure(LanguageServerSessionStartError.UnsupportedLanguage); + + await _admissionSemaphore.WaitAsync(cancellationToken); + + try + { + InternalSession? existing = _sessions.Values.FirstOrDefault(s => s.OwnerUserId == userId); + if (existing is not null) + { + _sessions.TryRemove(existing.Id, out _); + await DisposeSessionAsync(existing); + } + + if (_sessions.Count >= options.MaxConcurrentSessions) + return LanguageServerSessionStartResult.Failure(LanguageServerSessionStartError.AtCapacity); + + Guid sessionId = Guid.NewGuid(); + string sessionRootDirectory = Path.Combine(RootDirectory, sessionId.ToString()); + string workspaceDirectory = Path.Combine(sessionRootDirectory, "project"); + Directory.CreateDirectory(workspaceDirectory); + + await adapter.PrepareWorkspaceAsync(workspaceDirectory, cancellationToken); + + LanguageServerProcessStartInfo startInfo = adapter.BuildProcessStartInfo(workspaceDirectory); + LanguageServerProcess process = LanguageServerProcess.Start(startInfo, _logger); + + await File.WriteAllTextAsync( + Path.Combine(sessionRootDirectory, ".pid"), + process.ProcessId.ToString(), + cancellationToken + ); + + string rootUri = ToFileUri(workspaceDirectory); + string documentUri = ToFileUri(Path.Combine(workspaceDirectory, adapter.DocumentFileName)); + object? initializationOptions = adapter.BuildInitializationOptions(workspaceDirectory); + + InternalSession session = new( + sessionId, + userId, + languageSlug, + sessionRootDirectory, + rootUri, + documentUri, + initializationOptions, + process + ); + + _sessions[sessionId] = session; + + return LanguageServerSessionStartResult.Success( + new LanguageServerSession(sessionId, userId, languageSlug, rootUri, documentUri, initializationOptions) + ); + } + finally + { + _admissionSemaphore.Release(); + } + } + + public LanguageServerSession? TryGetSession(Guid sessionId, Guid userId) + { + if (!_sessions.TryGetValue(sessionId, out InternalSession? session) || session.OwnerUserId != userId) + return null; + + return new LanguageServerSession( + session.Id, + session.OwnerUserId, + session.LanguageSlug, + session.RootUri, + session.DocumentUri, + session.InitializationOptions + ); + } + + public async Task EndSessionAsync(Guid sessionId, CancellationToken cancellationToken) + { + if (_sessions.TryRemove(sessionId, out InternalSession? session)) + await DisposeSessionAsync(session); + } + + public ILanguageServerProcess? TryAttach(Guid sessionId, Guid userId) + { + if (!_sessions.TryGetValue(sessionId, out InternalSession? session) || session.OwnerUserId != userId) + return null; + + return session.TryMarkAttached() ? session.Process : null; + } + + public void Detach(Guid sessionId) + { + if (_sessions.TryGetValue(sessionId, out InternalSession? session)) + session.MarkDetached(); + } + + public void Touch(Guid sessionId) + { + if (_sessions.TryGetValue(sessionId, out InternalSession? session)) + session.Touch(); + } + + public async Task ReapIdleSessionsAsync(TimeSpan idleTimeout, CancellationToken cancellationToken) + { + DateTimeOffset cutoff = DateTimeOffset.UtcNow - idleTimeout; + + List toReap = _sessions + .Values.Where(s => s.LastActivityUtc < cutoff || s.Process.HasExited) + .ToList(); + + foreach (InternalSession session in toReap) + { + if (!_sessions.TryRemove(session.Id, out _)) + continue; + + _logger.LogInformation("Reaping idle language server session {SessionId}", session.Id); + await DisposeSessionAsync(session); + } + } + + public Task SweepOrphanedWorkspacesAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(RootDirectory)) + return Task.CompletedTask; + + foreach (string workspaceDirectory in Directory.EnumerateDirectories(RootDirectory)) + { + string pidFile = Path.Combine(workspaceDirectory, ".pid"); + + if ( + File.Exists(pidFile) + && int.TryParse(File.ReadAllText(pidFile), out int pid) + && TryKillIfStillRunning(pid) + ) + { + _logger.LogInformation("Killed orphaned language server process {Pid} from a previous run", pid); + } + + TryDeleteDirectory(workspaceDirectory); + } + + return Task.CompletedTask; + } + + private async Task DisposeSessionAsync(InternalSession session) + { + await session.Process.DisposeAsync(); + TryDeleteDirectory(session.SessionRootDirectory); + } + + private void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete language server workspace {Path}", path); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Failed to delete language server workspace {Path}", path); + } + } + + private static bool TryKillIfStillRunning(int pid) + { + try + { + Process process = Process.GetProcessById(pid); + if (!process.ProcessName.Contains("java", StringComparison.OrdinalIgnoreCase)) + return false; + + process.Kill(entireProcessTree: true); + return true; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static string ToFileUri(string path) => new Uri(Path.GetFullPath(path)).AbsoluteUri; + + private sealed class InternalSession( + Guid id, + Guid ownerUserId, + string languageSlug, + string sessionRootDirectory, + string rootUri, + string documentUri, + object? initializationOptions, + LanguageServerProcess process + ) + { + private int _attached; + private long _lastActivityTicks = DateTimeOffset.UtcNow.UtcTicks; + + public Guid Id { get; } = id; + public Guid OwnerUserId { get; } = ownerUserId; + public string LanguageSlug { get; } = languageSlug; + public string SessionRootDirectory { get; } = sessionRootDirectory; + public string RootUri { get; } = rootUri; + public string DocumentUri { get; } = documentUri; + public object? InitializationOptions { get; } = initializationOptions; + public LanguageServerProcess Process { get; } = process; + + public DateTimeOffset LastActivityUtc => new(Interlocked.Read(ref _lastActivityTicks), TimeSpan.Zero); + + public void Touch() => Interlocked.Exchange(ref _lastActivityTicks, DateTimeOffset.UtcNow.UtcTicks); + + public bool TryMarkAttached() => Interlocked.CompareExchange(ref _attached, 1, 0) == 0; + + public void MarkDetached() => Interlocked.Exchange(ref _attached, 0); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Messaging/AzureServiceBusMessagePublisher.cs b/StackDuel.Infrastructure/Messaging/AzureServiceBusMessagePublisher.cs new file mode 100644 index 0000000..cd6c31f --- /dev/null +++ b/StackDuel.Infrastructure/Messaging/AzureServiceBusMessagePublisher.cs @@ -0,0 +1,48 @@ +using Azure.Messaging.ServiceBus; +using StackDuel.Application.Messaging; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Messaging; + +internal sealed class AzureServiceBusMessagePublisher(ServiceBusClient client) : IMessagePublisher +{ + public async Task PublishAsync(T message, CancellationToken cancellationToken = default) + where T : class, IMessage + { + await using var sender = client.CreateSender(QueueNames.ForType()); + await sender.SendMessageAsync(new ServiceBusMessage(JsonSerializer.Serialize(message)), cancellationToken); + } + + /// + /// Schedules delayed delivery natively via Azure Service Bus. Returns the sequence number + /// so the caller can cancel it later (e.g. if the game is forfeited before its time limit + /// elapses) via CancelScheduledAsync. + /// + public async Task PublishScheduledAsync( + T message, + DateTimeOffset scheduledEnqueueTimeUtc, + CancellationToken cancellationToken = default + ) + where T : class, IMessage + { + await using var sender = client.CreateSender(QueueNames.ForType()); + var sbMessage = new ServiceBusMessage(JsonSerializer.Serialize(message)); + return await sender.ScheduleMessageAsync(sbMessage, scheduledEnqueueTimeUtc, cancellationToken); + } + + public async Task CancelScheduledAsync(long sequenceNumber, CancellationToken cancellationToken = default) + where T : class, IMessage + { + await using var sender = client.CreateSender(QueueNames.ForType()); + + try + { + await sender.CancelScheduledMessageAsync(sequenceNumber, cancellationToken); + } + catch (ServiceBusException ex) when (ex.Reason == ServiceBusFailureReason.MessageNotFound) + { + // Already delivered/expired/cancelled — nothing to do. The consumer's own + // idempotency check is the real safety net here, not this cancel call succeeding. + } + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Messaging/Consumers/AzureServiceBusConsumerService.cs b/StackDuel.Infrastructure/Messaging/Consumers/AzureServiceBusConsumerService.cs new file mode 100644 index 0000000..aab2558 --- /dev/null +++ b/StackDuel.Infrastructure/Messaging/Consumers/AzureServiceBusConsumerService.cs @@ -0,0 +1,263 @@ +using Azure.Messaging.ServiceBus; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StackDuel.Application.Commands.Games.CompleteExpiredGame; +using StackDuel.Application.Configuration; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Infrastructure.Jobs.ProblemValidation; +using StackDuel.Infrastructure.Jobs.Submissions; +using StackDuel.Infrastructure.Jobs.TestCaseGeneration; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Messaging.Consumers; + +internal sealed partial class AzureServiceBusConsumerService( + ServiceBusClient client, + MessageBusOptions options, + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + int maxConcurrentCalls = Math.Max(1, options.AzureServiceBus.MaxConcurrentCalls); + var processorOptions = new ServiceBusProcessorOptions + { + MaxConcurrentCalls = maxConcurrentCalls, + AutoCompleteMessages = false, + }; + + var createdProcessor = client.CreateProcessor(QueueNames.ForType(), processorOptions); + var continuationProcessor = client.CreateProcessor( + QueueNames.ForType(), + processorOptions + ); + var problemValidationProcessor = client.CreateProcessor( + QueueNames.ForType(), + processorOptions + ); + var testCaseGenerationContinuationProcessor = client.CreateProcessor( + QueueNames.ForType(), + processorOptions + ); + var testCaseGenerationCompletedProcessor = client.CreateProcessor( + QueueNames.ForType(), + processorOptions + ); + var gameExpiryProcessor = client.CreateProcessor( + QueueNames.ForType(), + processorOptions + ); + + WireUp( + createdProcessor, + (sp, m, ct) => + sp.GetRequiredService().RunForSubmissionAsync(m.SubmissionId, ct) + ); + + WireUp( + continuationProcessor, + (sp, m, ct) => + sp.GetRequiredService().RunForSubmissionAsync(m.SubmissionId, ct) + ); + + WireUp( + problemValidationProcessor, + (sp, m, ct) => + sp.GetRequiredService().RunPendingForJobAsync(m.JobId, ct) + ); + + WireUp( + testCaseGenerationContinuationProcessor, + (sp, m, ct) => sp.GetRequiredService().RunForJobAsync(m.JobId, ct) + ); + + WireUp( + testCaseGenerationCompletedProcessor, + (sp, _, ct) => + sp.GetRequiredService().RunAwaitingGenerationPassAsync(ct) + ); + + WireUpGameExpiry(gameExpiryProcessor); + + await Task.WhenAll( + createdProcessor.StartProcessingAsync(stoppingToken), + continuationProcessor.StartProcessingAsync(stoppingToken), + problemValidationProcessor.StartProcessingAsync(stoppingToken), + testCaseGenerationContinuationProcessor.StartProcessingAsync(stoppingToken), + testCaseGenerationCompletedProcessor.StartProcessingAsync(stoppingToken), + gameExpiryProcessor.StartProcessingAsync(stoppingToken) + ); + + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) { } + + await createdProcessor.StopProcessingAsync(CancellationToken.None); + await continuationProcessor.StopProcessingAsync(CancellationToken.None); + await problemValidationProcessor.StopProcessingAsync(CancellationToken.None); + await testCaseGenerationContinuationProcessor.StopProcessingAsync(CancellationToken.None); + await testCaseGenerationCompletedProcessor.StopProcessingAsync(CancellationToken.None); + await gameExpiryProcessor.StopProcessingAsync(CancellationToken.None); + await createdProcessor.DisposeAsync(); + await continuationProcessor.DisposeAsync(); + await problemValidationProcessor.DisposeAsync(); + await testCaseGenerationContinuationProcessor.DisposeAsync(); + await testCaseGenerationCompletedProcessor.DisposeAsync(); + await gameExpiryProcessor.DisposeAsync(); + } + + private void WireUp( + ServiceBusProcessor processor, + Func handle + ) + where TMessage : IMessage + { + processor.ProcessMessageAsync += async args => + { + TMessage? message; + try + { + message = JsonSerializer.Deserialize(args.Message.Body); + } + catch (JsonException ex) + { + LogProcessingError(ex); + await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed"); + return; + } + + if (message is null) + { + await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "NullMessage"); + return; + } + + LogMessageReceived(typeof(TMessage).Name, message.ToString() ?? string.Empty); + + try + { + using var scope = scopeFactory.CreateScope(); + await handle(scope.ServiceProvider, message, args.CancellationToken); + await args.CompleteMessageAsync(args.Message); + } + catch (Exception ex) + { + LogProcessingError(ex); + await args.AbandonMessageAsync(args.Message); + } + }; + + processor.ProcessErrorAsync += async args => + { + if (args.Exception is ServiceBusException { Reason: ServiceBusFailureReason.MessagingEntityNotFound }) + { + LogQueueNotFound(args.EntityPath); + await processor.StopProcessingAsync(); + return; + } + + LogProcessingError(args.Exception); + }; + } + + /// + /// Unlike WireUp<T>, this sends a mediator command whose result can indicate + /// "arrived early" — in which case this re-publishes a scheduled message with + /// RescheduleCount + 1 rather than treating it as a terminal outcome. + /// + private void WireUpGameExpiry(ServiceBusProcessor processor) + { + processor.ProcessMessageAsync += async args => + { + GameTimeExpiredMessage? message; + try + { + message = JsonSerializer.Deserialize(args.Message.Body); + } + catch (JsonException ex) + { + LogProcessingError(ex); + await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "DeserializationFailed"); + return; + } + + if (message is null) + { + await args.DeadLetterMessageAsync(args.Message, deadLetterReason: "NullMessage"); + return; + } + + LogGameExpiryReceived(message.GameId, message.RescheduleCount); + + try + { + using var scope = scopeFactory.CreateScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + var result = await mediator.Send( + new CompleteExpiredGameCommand(message.GameId, message.ExpectedStartedAt, message.RescheduleCount), + args.CancellationToken + ); + + if ( + result.IsSuccess + && result.Value.Outcome == CompleteExpiredGameOutcome.NotYetExpired_Rescheduled + && result.Value.RescheduleForUtc is { } rescheduleFor + ) + { + var publisher = scope.ServiceProvider.GetRequiredService(); + await publisher.PublishScheduledAsync( + message with + { + RescheduleCount = message.RescheduleCount + 1, + }, + rescheduleFor, + args.CancellationToken + ); + } + + await args.CompleteMessageAsync(args.Message); + } + catch (Exception ex) + { + LogProcessingError(ex); + await args.AbandonMessageAsync(args.Message); + } + }; + + processor.ProcessErrorAsync += async args => + { + if (args.Exception is ServiceBusException { Reason: ServiceBusFailureReason.MessagingEntityNotFound }) + { + LogQueueNotFound(args.EntityPath); + await processor.StopProcessingAsync(); + return; + } + + LogProcessingError(args.Exception); + }; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Received {MessageType}: {Message}")] + private partial void LogMessageReceived(string messageType, string message); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Received GameTimeExpiredMessage for game {GameId} (reschedule count {RescheduleCount})" + )] + private partial void LogGameExpiryReceived(Guid gameId, int rescheduleCount); + + [LoggerMessage( + Level = LogLevel.Critical, + Message = "Azure Service Bus queue '{QueueName}' not found — consumer stopped. Check queue name configuration." + )] + private partial void LogQueueNotFound(string QueueName); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error processing message from Azure Service Bus")] + private partial void LogProcessingError(Exception ex); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Messaging/Consumers/RabbitMqConsumerService.cs b/StackDuel.Infrastructure/Messaging/Consumers/RabbitMqConsumerService.cs new file mode 100644 index 0000000..952bd79 --- /dev/null +++ b/StackDuel.Infrastructure/Messaging/Consumers/RabbitMqConsumerService.cs @@ -0,0 +1,231 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using StackDuel.Application.Commands.Games.CompleteExpiredGame; +using StackDuel.Application.Configuration; +using StackDuel.Application.Messaging; +using StackDuel.Application.Messaging.Messages; +using StackDuel.Infrastructure.Jobs.ProblemValidation; +using StackDuel.Infrastructure.Jobs.Submissions; +using StackDuel.Infrastructure.Jobs.TestCaseGeneration; +using StackDuel.Infrastructure.Messaging; +using System.Text; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Messaging.Consumers; + +internal sealed partial class RabbitMqConsumerService( + IConnection connection, + MessageBusOptions options, + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + int consumerConcurrency = Math.Max(1, options.RabbitMQ.ConsumerConcurrency); + var channels = new List(); + + Subscribe( + channels, + (sp, m, ct) => + sp.GetRequiredService().RunForSubmissionAsync(m.SubmissionId, ct), + consumerConcurrency, + stoppingToken + ); + + Subscribe( + channels, + (sp, m, ct) => + sp.GetRequiredService().RunForSubmissionAsync(m.SubmissionId, ct), + consumerConcurrency, + stoppingToken + ); + + Subscribe( + channels, + (sp, m, ct) => + sp.GetRequiredService().RunPendingForJobAsync(m.JobId, ct), + consumerConcurrency, + stoppingToken + ); + + Subscribe( + channels, + (sp, m, ct) => sp.GetRequiredService().RunForJobAsync(m.JobId, ct), + consumerConcurrency, + stoppingToken + ); + + Subscribe( + channels, + (sp, _, ct) => + sp.GetRequiredService().RunAwaitingGenerationPassAsync(ct), + consumerConcurrency, + stoppingToken + ); + + SubscribeGameExpiryMessages(channels, consumerConcurrency, stoppingToken); + + stoppingToken.Register(() => + { + foreach (var channel in channels) + { + try + { + channel.Close(); + } + catch { } + channel.Dispose(); + } + }); + + return Task.CompletedTask; + } + + private void Subscribe( + ICollection channels, + Func handle, + int consumerConcurrency, + CancellationToken stoppingToken + ) + where TMessage : IMessage + { + string queueName = QueueNames.ForType(); + + for (int i = 0; i < consumerConcurrency; i++) + { + IModel channel = connection.CreateModel(); + channels.Add(channel); + + channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false); + channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + AsyncEventingBasicConsumer consumer = new(channel); + consumer.Received += async (_, ea) => + { + try + { + string body = Encoding.UTF8.GetString(ea.Body.Span); + TMessage? message = JsonSerializer.Deserialize(body); + if (message is not null) + { + LogMessageReceived(typeof(TMessage).Name, message.ToString() ?? string.Empty); + using var scope = scopeFactory.CreateScope(); + await handle(scope.ServiceProvider, message, stoppingToken); + } + + channel.BasicAck(ea.DeliveryTag, multiple: false); + } + catch (Exception ex) + { + LogProcessingError(ex); + channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true); + } + }; + + channel.BasicConsume(queueName, autoAck: false, consumer); + } + } + + /// + /// Consumes from the queue bound to the x-delayed-message exchange. Declares the same + /// exchange/queue/binding defensively in case this consumer starts before anything has + /// published yet. A "not yet expired" result triggers a fresh scheduled publish + /// (RescheduleCount + 1) rather than a simple ack/nack. + /// + private void SubscribeGameExpiryMessages( + ICollection channels, + int consumerConcurrency, + CancellationToken stoppingToken + ) + { + string queueName = QueueNames.ForType(); + string exchangeName = $"{queueName}.delayed"; + + for (int i = 0; i < consumerConcurrency; i++) + { + IModel channel = connection.CreateModel(); + channels.Add(channel); + + channel.ExchangeDeclare( + exchangeName, + type: "x-delayed-message", + durable: true, + arguments: new Dictionary { ["x-delayed-type"] = "direct" } + ); + channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false); + channel.QueueBind(queueName, exchangeName, routingKey: queueName); + + channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + AsyncEventingBasicConsumer consumer = new(channel); + consumer.Received += async (_, ea) => + { + try + { + string body = Encoding.UTF8.GetString(ea.Body.Span); + GameTimeExpiredMessage? message = JsonSerializer.Deserialize(body); + + if (message is not null) + { + LogGameExpiryReceived(message.GameId, message.RescheduleCount); + await ProcessGameExpiryAsync(message, stoppingToken); + } + + channel.BasicAck(ea.DeliveryTag, multiple: false); + } + catch (Exception ex) + { + LogProcessingError(ex); + channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true); + } + }; + + channel.BasicConsume(queueName, autoAck: false, consumer); + } + } + + private async Task ProcessGameExpiryAsync(GameTimeExpiredMessage message, CancellationToken cancellationToken) + { + using var scope = scopeFactory.CreateScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + + var result = await mediator.Send( + new CompleteExpiredGameCommand(message.GameId, message.ExpectedStartedAt, message.RescheduleCount), + cancellationToken + ); + + if ( + result.IsSuccess + && result.Value.Outcome == CompleteExpiredGameOutcome.NotYetExpired_Rescheduled + && result.Value.RescheduleForUtc is { } rescheduleFor + ) + { + var publisher = scope.ServiceProvider.GetRequiredService(); + await publisher.PublishScheduledAsync( + message with + { + RescheduleCount = message.RescheduleCount + 1, + }, + rescheduleFor, + cancellationToken + ); + } + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Received {MessageType}: {Message}")] + private partial void LogMessageReceived(string messageType, string message); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Received GameTimeExpiredMessage for game {GameId} (reschedule count {RescheduleCount})" + )] + private partial void LogGameExpiryReceived(Guid gameId, int rescheduleCount); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error processing message from RabbitMQ")] + private partial void LogProcessingError(Exception ex); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Messaging/QueueNames.cs b/StackDuel.Infrastructure/Messaging/QueueNames.cs new file mode 100644 index 0000000..09891b7 --- /dev/null +++ b/StackDuel.Infrastructure/Messaging/QueueNames.cs @@ -0,0 +1,9 @@ +using StackDuel.Application.Messaging; + +namespace StackDuel.Infrastructure.Messaging; + +internal static class QueueNames +{ + public static string ForType() + where T : IMessage => T.QueueName; +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Messaging/RabbitMqMessagePublisher.cs b/StackDuel.Infrastructure/Messaging/RabbitMqMessagePublisher.cs new file mode 100644 index 0000000..dd957e1 --- /dev/null +++ b/StackDuel.Infrastructure/Messaging/RabbitMqMessagePublisher.cs @@ -0,0 +1,76 @@ +using RabbitMQ.Client; +using StackDuel.Application.Messaging; +using System.Text; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Messaging; + +internal sealed class RabbitMqMessagePublisher(IConnection connection) : IMessagePublisher +{ + public Task PublishAsync(T message, CancellationToken cancellationToken = default) + where T : class, IMessage + { + using var channel = connection.CreateModel(); + string queueName = QueueNames.ForType(); + channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false); + byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); + var props = channel.CreateBasicProperties(); + props.Persistent = true; + channel.BasicPublish(exchange: "", routingKey: queueName, basicProperties: props, body: body); + return Task.CompletedTask; + } + + /// + /// Publishes via an x-delayed-message exchange (rabbitmq_delayed_message_exchange plugin) + /// so scheduled delivery works locally the same way it does against Azure Service Bus in + /// prod. Requires the plugin enabled on the broker — see docker-compose.yml. + /// + public Task PublishScheduledAsync( + T message, + DateTimeOffset scheduledEnqueueTimeUtc, + CancellationToken cancellationToken = default + ) + where T : class, IMessage + { + using var channel = connection.CreateModel(); + string queueName = QueueNames.ForType(); + string exchangeName = $"{queueName}.delayed"; + + channel.ExchangeDeclare( + exchangeName, + type: "x-delayed-message", + durable: true, + arguments: new Dictionary { ["x-delayed-type"] = "direct" } + ); + + channel.QueueDeclare(queueName, durable: true, exclusive: false, autoDelete: false); + channel.QueueBind(queueName, exchangeName, routingKey: queueName); + + long delayMs = Math.Max(0, (long)(scheduledEnqueueTimeUtc - DateTimeOffset.UtcNow).TotalMilliseconds); + + var props = channel.CreateBasicProperties(); + props.Persistent = true; + props.Headers = new Dictionary { ["x-delay"] = delayMs }; + + byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); + channel.BasicPublish(exchange: exchangeName, routingKey: queueName, basicProperties: props, body: body); + + // RabbitMQ's delayed-message-exchange plugin has no server-generated handle to return + // (unlike Azure Service Bus's sequence number). 0 is a placeholder; do not rely on it + // for CancelScheduledAsync — see that method. + return Task.FromResult(0L); + } + + /// + /// Not supported: the delayed-message-exchange plugin has no cancel-by-handle API — once + /// published, a delayed message will eventually land in the queue. Consumers must + /// re-verify state and treat a message for an already-finalized entity as a no-op rather + /// than depending on cancellation. + /// + public Task CancelScheduledAsync(long sequenceNumber, CancellationToken cancellationToken = default) + where T : class, IMessage => + throw new NotSupportedException( + "RabbitMQ's delayed-message-exchange plugin does not support cancelling a scheduled " + + "message by handle. Rely on idempotent, state-checking consumers instead." + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260619031407_InitialMigration.Designer.cs b/StackDuel.Infrastructure/Migrations/20260619031407_InitialMigration.Designer.cs new file mode 100644 index 0000000..d2d82ef --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260619031407_InitialMigration.Designer.cs @@ -0,0 +1,74 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260619031407_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260619031407_InitialMigration.cs b/StackDuel.Infrastructure/Migrations/20260619031407_InitialMigration.cs new file mode 100644 index 0000000..6d462d1 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260619031407_InitialMigration.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class InitialMigration : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + bio = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + image_url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + sub = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + username = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + username_last_changed_at = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.id); + } + ); + + migrationBuilder.CreateIndex(name: "IX_users_sub", table: "users", column: "sub", unique: true); + + migrationBuilder.CreateIndex(name: "IX_users_username", table: "users", column: "username", unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "users"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.Designer.cs b/StackDuel.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.Designer.cs new file mode 100644 index 0000000..a6ec7ab --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.Designer.cs @@ -0,0 +1,409 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260621220618_MigrateOldDatabase")] + partial class MigrateOldDatabase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.cs b/StackDuel.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.cs new file mode 100644 index 0000000..04e4034 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260621220618_MigrateOldDatabase.cs @@ -0,0 +1,243 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class MigrateOldDatabase : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + question = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + difficulty = table.Column(type: "integer", nullable: false), + time_limit = table.Column(type: "integer", nullable: false), + memory_limit = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problems", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "test_suites", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + type = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_test_suites", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "problem_history", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + question = table.Column(type: "character varying(10000)", maxLength: 10000, nullable: false), + difficulty = table.Column(type: "integer", nullable: false), + time_limit = table.Column(type: "integer", nullable: false), + memory_limit = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_history", x => x.id); + table.ForeignKey( + name: "FK_problem_history_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "problem_setups", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + language_version_id = table.Column(type: "uuid", nullable: false), + initial_code = table.Column(type: "text", nullable: false), + function_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_setups", x => x.id); + table.ForeignKey( + name: "FK_problem_setups_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "test_cases", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "text", nullable: true), + test_suite_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_test_cases", x => x.id); + table.ForeignKey( + name: "FK_test_cases_test_suites_test_suite_id", + column: x => x.test_suite_id, + principalTable: "test_suites", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "problem_setup_test_suites", + columns: table => new + { + problem_setup_id = table.Column(type: "uuid", nullable: false), + test_suite_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_setup_test_suites", x => new { x.problem_setup_id, x.test_suite_id }); + table.ForeignKey( + name: "FK_problem_setup_test_suites_problem_setups_problem_setup_id", + column: x => x.problem_setup_id, + principalTable: "problem_setups", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_problem_setup_test_suites_test_suites_test_suite_id", + column: x => x.test_suite_id, + principalTable: "test_suites", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "test_case_expected_outputs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + value = table.Column(type: "text", nullable: false), + value_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + test_case_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_test_case_expected_outputs", x => x.id); + table.ForeignKey( + name: "FK_test_case_expected_outputs_test_cases_test_case_id", + column: x => x.test_case_id, + principalTable: "test_cases", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "test_case_inputs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + value = table.Column(type: "text", nullable: false), + value_type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + test_case_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_test_case_inputs", x => x.id); + table.ForeignKey( + name: "FK_test_case_inputs_test_cases_test_case_id", + column: x => x.test_case_id, + principalTable: "test_cases", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_history_problem_id", + table: "problem_history", + column: "problem_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_setup_test_suites_test_suite_id", + table: "problem_setup_test_suites", + column: "test_suite_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_setups_problem_id", + table: "problem_setups", + column: "problem_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_test_case_expected_outputs_test_case_id", + table: "test_case_expected_outputs", + column: "test_case_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_test_case_inputs_test_case_id", + table: "test_case_inputs", + column: "test_case_id" + ); + + migrationBuilder.CreateIndex(name: "IX_test_cases_test_suite_id", table: "test_cases", column: "test_suite_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "problem_history"); + + migrationBuilder.DropTable(name: "problem_setup_test_suites"); + + migrationBuilder.DropTable(name: "test_case_expected_outputs"); + + migrationBuilder.DropTable(name: "test_case_inputs"); + + migrationBuilder.DropTable(name: "problem_setups"); + + migrationBuilder.DropTable(name: "test_cases"); + + migrationBuilder.DropTable(name: "problems"); + + migrationBuilder.DropTable(name: "test_suites"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.Designer.cs b/StackDuel.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.Designer.cs new file mode 100644 index 0000000..04a4d3f --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.Designer.cs @@ -0,0 +1,483 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260622032111_MigrateOldDatabase2")] + partial class MigrateOldDatabase2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.cs b/StackDuel.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.cs new file mode 100644 index 0000000..8774aa4 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260622032111_MigrateOldDatabase2.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class MigrateOldDatabase2 : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "languages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + slug = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + status = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_languages", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "language_versions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + judge0_id = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + version = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + language_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_language_versions", x => x.id); + table.ForeignKey( + name: "FK_language_versions_languages_language_id", + column: x => x.language_id, + principalTable: "languages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_language_versions_language_id", + table: "language_versions", + column: "language_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "language_versions"); + + migrationBuilder.DropTable(name: "languages"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260701033538_BringOldDatabaseTablesOver.Designer.cs b/StackDuel.Infrastructure/Migrations/20260701033538_BringOldDatabaseTablesOver.Designer.cs new file mode 100644 index 0000000..75be3aa --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260701033538_BringOldDatabaseTablesOver.Designer.cs @@ -0,0 +1,610 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260701033538_BringOldDatabaseTablesOver")] + partial class BringOldDatabaseTablesOver + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260701033538_BringOldDatabaseTablesOver.cs b/StackDuel.Infrastructure/Migrations/20260701033538_BringOldDatabaseTablesOver.cs new file mode 100644 index 0000000..e56b1a2 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260701033538_BringOldDatabaseTablesOver.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class BringOldDatabaseTablesOver : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "submissions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_setup_id = table.Column(type: "uuid", nullable: false), + source_code = table.Column(type: "character varying(65536)", maxLength: 65536, nullable: false), + status = table.Column(type: "integer", nullable: false), + type = table.Column(type: "integer", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_submissions", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "submission_results", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + actual_output = table.Column(type: "text", nullable: true), + compile_output = table.Column(type: "text", nullable: true), + memory_used = table.Column(type: "integer", nullable: true), + runtime = table.Column(type: "integer", nullable: true), + standard_error = table.Column(type: "text", nullable: true), + standard_output = table.Column(type: "text", nullable: true), + status = table.Column(type: "integer", nullable: false), + test_case_id = table.Column(type: "uuid", nullable: false), + evaluation_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + execution_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + submission_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_submission_results", x => x.id); + table.ForeignKey( + name: "FK_submission_results_submissions_submission_id", + column: x => x.submission_id, + principalTable: "submissions", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_submission_results_submission_id", + table: "submission_results", + column: "submission_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "submission_results"); + + migrationBuilder.DropTable(name: "submissions"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260712032553_AddProblemTags.Designer.cs b/StackDuel.Infrastructure/Migrations/20260712032553_AddProblemTags.Designer.cs new file mode 100644 index 0000000..273b576 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712032553_AddProblemTags.Designer.cs @@ -0,0 +1,661 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260712032553_AddProblemTags")] + partial class AddProblemTags + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260712032553_AddProblemTags.cs b/StackDuel.Infrastructure/Migrations/20260712032553_AddProblemTags.cs new file mode 100644 index 0000000..e6eabd5 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712032553_AddProblemTags.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddProblemTags : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "tags", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_tags", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "problem_tags", + columns: table => new + { + ProblemsId = table.Column(type: "uuid", nullable: false), + TagsId = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_tags", x => new { x.ProblemsId, x.TagsId }); + table.ForeignKey( + name: "FK_problem_tags_problems_ProblemsId", + column: x => x.ProblemsId, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_problem_tags_tags_TagsId", + column: x => x.TagsId, + principalTable: "tags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex(name: "IX_problem_tags_TagsId", table: "problem_tags", column: "TagsId"); + + migrationBuilder.CreateIndex(name: "IX_tags_name", table: "tags", column: "name", unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "problem_tags"); + + migrationBuilder.DropTable(name: "tags"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260712040716_AddSlugIndex.Designer.cs b/StackDuel.Infrastructure/Migrations/20260712040716_AddSlugIndex.Designer.cs new file mode 100644 index 0000000..4476587 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712040716_AddSlugIndex.Designer.cs @@ -0,0 +1,664 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260712040716_AddSlugIndex")] + partial class AddSlugIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260712040716_AddSlugIndex.cs b/StackDuel.Infrastructure/Migrations/20260712040716_AddSlugIndex.cs new file mode 100644 index 0000000..8a5dd7f --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712040716_AddSlugIndex.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddSlugIndex : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex(name: "IX_problems_slug", table: "problems", column: "slug", unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "IX_problems_slug", table: "problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260712041201_AddProblemAuthor.Designer.cs b/StackDuel.Infrastructure/Migrations/20260712041201_AddProblemAuthor.Designer.cs new file mode 100644 index 0000000..896da87 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712041201_AddProblemAuthor.Designer.cs @@ -0,0 +1,677 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260712041201_AddProblemAuthor")] + partial class AddProblemAuthor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260712041201_AddProblemAuthor.cs b/StackDuel.Infrastructure/Migrations/20260712041201_AddProblemAuthor.cs new file mode 100644 index 0000000..8af16ec --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712041201_AddProblemAuthor.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddProblemAuthor : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "created_by_id", table: "problems", type: "uuid", nullable: true); + + migrationBuilder.CreateIndex(name: "IX_problems_created_by_id", table: "problems", column: "created_by_id"); + + migrationBuilder.AddForeignKey( + name: "FK_problems_users_created_by_id", + table: "problems", + column: "created_by_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey(name: "FK_problems_users_created_by_id", table: "problems"); + + migrationBuilder.DropIndex(name: "IX_problems_created_by_id", table: "problems"); + + migrationBuilder.DropColumn(name: "created_by_id", table: "problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260712054551_AddSubmissionPipeline.Designer.cs b/StackDuel.Infrastructure/Migrations/20260712054551_AddSubmissionPipeline.Designer.cs new file mode 100644 index 0000000..09da602 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712054551_AddSubmissionPipeline.Designer.cs @@ -0,0 +1,938 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260712054551_AddSubmissionPipeline")] + partial class AddSubmissionPipeline + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260712054551_AddSubmissionPipeline.cs b/StackDuel.Infrastructure/Migrations/20260712054551_AddSubmissionPipeline.cs new file mode 100644 index 0000000..1959dbd --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260712054551_AddSubmissionPipeline.cs @@ -0,0 +1,194 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddSubmissionPipeline : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "pipeline_id", + table: "problem_setups", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000") + ); + + migrationBuilder.CreateTable( + name: "assert_step_configurations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + pipeline_step_id = table.Column(type: "uuid", nullable: false), + strategy = table.Column(type: "integer", nullable: false), + tolerance = table.Column(type: "numeric", nullable: true), + case_sensitive = table.Column(type: "boolean", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_assert_step_configurations", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "execution_pipelines", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "text", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_execution_pipelines", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "judge0_step_configurations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + pipeline_step_id = table.Column(type: "uuid", nullable: false), + is_encoded = table.Column(type: "boolean", nullable: false), + should_wait = table.Column(type: "boolean", nullable: false), + strip_whitespace = table.Column(type: "boolean", nullable: false), + default_timeout_seconds = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_judge0_step_configurations", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "submission_jobs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + submission_id = table.Column(type: "uuid", nullable: false), + pipeline_id = table.Column(type: "uuid", nullable: false), + current_step_id = table.Column(type: "uuid", nullable: true), + status = table.Column(type: "integer", nullable: false), + failure_reason = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_submission_jobs", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "execution_pipeline_steps", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + step_type = table.Column(type: "integer", nullable: false), + step_order = table.Column(type: "integer", nullable: false), + max_attempts = table.Column(type: "integer", nullable: false), + timeout_seconds = table.Column(type: "integer", nullable: false), + is_polling = table.Column(type: "boolean", nullable: false), + pipeline_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_execution_pipeline_steps", x => x.id); + table.ForeignKey( + name: "FK_execution_pipeline_steps_execution_pipelines_pipeline_id", + column: x => x.pipeline_id, + principalTable: "execution_pipelines", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "submission_job_attempts", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + pipeline_step_id = table.Column(type: "uuid", nullable: false), + attempt_number = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + request_payload = table.Column(type: "text", nullable: true), + response_payload = table.Column(type: "text", nullable: true), + error = table.Column(type: "text", nullable: true), + started_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + duration_ms = table.Column(type: "integer", nullable: true), + job_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_submission_job_attempts", x => x.id); + table.ForeignKey( + name: "FK_submission_job_attempts_submission_jobs_job_id", + column: x => x.job_id, + principalTable: "submission_jobs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_assert_step_configurations_pipeline_step_id", + table: "assert_step_configurations", + column: "pipeline_step_id", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_execution_pipeline_steps_pipeline_id", + table: "execution_pipeline_steps", + column: "pipeline_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_judge0_step_configurations_pipeline_step_id", + table: "judge0_step_configurations", + column: "pipeline_step_id", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_submission_job_attempts_job_id", + table: "submission_job_attempts", + column: "job_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_submission_jobs_submission_id", + table: "submission_jobs", + column: "submission_id", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "assert_step_configurations"); + + migrationBuilder.DropTable(name: "execution_pipeline_steps"); + + migrationBuilder.DropTable(name: "judge0_step_configurations"); + + migrationBuilder.DropTable(name: "submission_job_attempts"); + + migrationBuilder.DropTable(name: "execution_pipelines"); + + migrationBuilder.DropTable(name: "submission_jobs"); + + migrationBuilder.DropColumn(name: "pipeline_id", table: "problem_setups"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260718170122_FixDbIssues.Designer.cs b/StackDuel.Infrastructure/Migrations/20260718170122_FixDbIssues.Designer.cs new file mode 100644 index 0000000..ea99e02 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260718170122_FixDbIssues.Designer.cs @@ -0,0 +1,1031 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260718170122_FixDbIssues")] + partial class FixDbIssues + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260718170122_FixDbIssues.cs b/StackDuel.Infrastructure/Migrations/20260718170122_FixDbIssues.cs new file mode 100644 index 0000000..de61df9 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260718170122_FixDbIssues.cs @@ -0,0 +1,254 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class FixDbIssues : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + @" + INSERT INTO execution_pipelines (id, name, description) + SELECT '2d8d6f9f-0cb6-4c85-924e-3038f7f89f58'::uuid, + 'Default Judge0 Pipeline', + 'Backfilled by migration to satisfy existing problem setup pipeline references.' + WHERE NOT EXISTS (SELECT 1 FROM execution_pipelines); + " + ); + + migrationBuilder.Sql( + @" + UPDATE problem_setups AS ps + SET pipeline_id = fallback.id + FROM ( + SELECT id + FROM execution_pipelines + ORDER BY name + LIMIT 1 + ) AS fallback + WHERE ps.pipeline_id = '00000000-0000-0000-0000-000000000000'::uuid + OR NOT EXISTS ( + SELECT 1 + FROM execution_pipelines ep + WHERE ep.id = ps.pipeline_id + ); + " + ); + + migrationBuilder.CreateIndex( + name: "IX_submissions_problem_setup_id", + table: "submissions", + column: "problem_setup_id" + ); + + migrationBuilder.CreateIndex(name: "IX_submissions_user_id", table: "submissions", column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_submission_results_test_case_id", + table: "submission_results", + column: "test_case_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_submission_jobs_current_step_id", + table: "submission_jobs", + column: "current_step_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_submission_jobs_pipeline_id", + table: "submission_jobs", + column: "pipeline_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_submission_job_attempts_pipeline_step_id", + table: "submission_job_attempts", + column: "pipeline_step_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_setups_language_version_id", + table: "problem_setups", + column: "language_version_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_setups_pipeline_id", + table: "problem_setups", + column: "pipeline_id" + ); + + migrationBuilder.AddForeignKey( + name: "FK_assert_step_configurations_execution_pipeline_steps_pipelin~", + table: "assert_step_configurations", + column: "pipeline_step_id", + principalTable: "execution_pipeline_steps", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + + migrationBuilder.AddForeignKey( + name: "FK_judge0_step_configurations_execution_pipeline_steps_pipelin~", + table: "judge0_step_configurations", + column: "pipeline_step_id", + principalTable: "execution_pipeline_steps", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + + migrationBuilder.AddForeignKey( + name: "FK_problem_setups_execution_pipelines_pipeline_id", + table: "problem_setups", + column: "pipeline_id", + principalTable: "execution_pipelines", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_problem_setups_language_versions_language_version_id", + table: "problem_setups", + column: "language_version_id", + principalTable: "language_versions", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_submission_job_attempts_execution_pipeline_steps_pipeline_s~", + table: "submission_job_attempts", + column: "pipeline_step_id", + principalTable: "execution_pipeline_steps", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_submission_jobs_execution_pipeline_steps_current_step_id", + table: "submission_jobs", + column: "current_step_id", + principalTable: "execution_pipeline_steps", + principalColumn: "id", + onDelete: ReferentialAction.SetNull + ); + + migrationBuilder.AddForeignKey( + name: "FK_submission_jobs_execution_pipelines_pipeline_id", + table: "submission_jobs", + column: "pipeline_id", + principalTable: "execution_pipelines", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_submission_jobs_submissions_submission_id", + table: "submission_jobs", + column: "submission_id", + principalTable: "submissions", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + + migrationBuilder.AddForeignKey( + name: "FK_submission_results_test_cases_test_case_id", + table: "submission_results", + column: "test_case_id", + principalTable: "test_cases", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_submissions_problem_setups_problem_setup_id", + table: "submissions", + column: "problem_setup_id", + principalTable: "problem_setups", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_submissions_users_user_id", + table: "submissions", + column: "user_id", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_assert_step_configurations_execution_pipeline_steps_pipelin~", + table: "assert_step_configurations" + ); + + migrationBuilder.DropForeignKey( + name: "FK_judge0_step_configurations_execution_pipeline_steps_pipelin~", + table: "judge0_step_configurations" + ); + + migrationBuilder.DropForeignKey( + name: "FK_problem_setups_execution_pipelines_pipeline_id", + table: "problem_setups" + ); + + migrationBuilder.DropForeignKey( + name: "FK_problem_setups_language_versions_language_version_id", + table: "problem_setups" + ); + + migrationBuilder.DropForeignKey( + name: "FK_submission_job_attempts_execution_pipeline_steps_pipeline_s~", + table: "submission_job_attempts" + ); + + migrationBuilder.DropForeignKey( + name: "FK_submission_jobs_execution_pipeline_steps_current_step_id", + table: "submission_jobs" + ); + + migrationBuilder.DropForeignKey( + name: "FK_submission_jobs_execution_pipelines_pipeline_id", + table: "submission_jobs" + ); + + migrationBuilder.DropForeignKey(name: "FK_submission_jobs_submissions_submission_id", table: "submission_jobs"); + + migrationBuilder.DropForeignKey( + name: "FK_submission_results_test_cases_test_case_id", + table: "submission_results" + ); + + migrationBuilder.DropForeignKey(name: "FK_submissions_problem_setups_problem_setup_id", table: "submissions"); + + migrationBuilder.DropForeignKey(name: "FK_submissions_users_user_id", table: "submissions"); + + migrationBuilder.DropIndex(name: "IX_submissions_problem_setup_id", table: "submissions"); + + migrationBuilder.DropIndex(name: "IX_submissions_user_id", table: "submissions"); + + migrationBuilder.DropIndex(name: "IX_submission_results_test_case_id", table: "submission_results"); + + migrationBuilder.DropIndex(name: "IX_submission_jobs_current_step_id", table: "submission_jobs"); + + migrationBuilder.DropIndex(name: "IX_submission_jobs_pipeline_id", table: "submission_jobs"); + + migrationBuilder.DropIndex( + name: "IX_submission_job_attempts_pipeline_step_id", + table: "submission_job_attempts" + ); + + migrationBuilder.DropIndex(name: "IX_problem_setups_language_version_id", table: "problem_setups"); + + migrationBuilder.DropIndex(name: "IX_problem_setups_pipeline_id", table: "problem_setups"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260719210239_AddGroupsAndPermissions.Designer.cs b/StackDuel.Infrastructure/Migrations/20260719210239_AddGroupsAndPermissions.Designer.cs new file mode 100644 index 0000000..a804dc6 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260719210239_AddGroupsAndPermissions.Designer.cs @@ -0,0 +1,1235 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260719210239_AddGroupsAndPermissions")] + partial class AddGroupsAndPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260719210239_AddGroupsAndPermissions.cs b/StackDuel.Infrastructure/Migrations/20260719210239_AddGroupsAndPermissions.cs new file mode 100644 index 0000000..6eb583c --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260719210239_AddGroupsAndPermissions.cs @@ -0,0 +1,192 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGroupsAndPermissions : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "groups", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_groups", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "permissions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + code = table.Column(type: "text", nullable: false), + description = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_permissions", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "roles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_roles", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "security_restrictions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + PermissionCode = table.Column(type: "text", nullable: false), + Effect = table.Column(type: "text", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + Reason = table.Column(type: "text", nullable: false), + DetectionEventId = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_security_restrictions", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "user_groups", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + group_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_user_groups", x => new { x.user_id, x.group_id }); + table.ForeignKey( + name: "FK_user_groups_groups_group_id", + column: x => x.group_id, + principalTable: "groups", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_user_groups_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "group_roles", + columns: table => new + { + group_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_group_roles", x => new { x.group_id, x.role_id }); + table.ForeignKey( + name: "FK_group_roles_groups_group_id", + column: x => x.group_id, + principalTable: "groups", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_group_roles_roles_role_id", + column: x => x.role_id, + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new + { + permission_id = table.Column(type: "uuid", nullable: false), + role_id = table.Column(type: "uuid", nullable: false), + effect = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_role_permissions", x => new { x.permission_id, x.role_id }); + table.ForeignKey( + name: "FK_role_permissions_permissions_permission_id", + column: x => x.permission_id, + principalTable: "permissions", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_role_permissions_roles_role_id", + column: x => x.role_id, + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex(name: "IX_group_roles_role_id", table: "group_roles", column: "role_id"); + + migrationBuilder.CreateIndex(name: "IX_permissions_code", table: "permissions", column: "code", unique: true); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_permission_id", + table: "role_permissions", + column: "permission_id" + ); + + migrationBuilder.CreateIndex(name: "IX_role_permissions_role_id", table: "role_permissions", column: "role_id"); + + migrationBuilder.CreateIndex( + name: "IX_security_restrictions_UserId_PermissionCode", + table: "security_restrictions", + columns: new[] { "UserId", "PermissionCode" } + ); + + migrationBuilder.CreateIndex(name: "IX_user_groups_group_id", table: "user_groups", column: "group_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "group_roles"); + + migrationBuilder.DropTable(name: "role_permissions"); + + migrationBuilder.DropTable(name: "security_restrictions"); + + migrationBuilder.DropTable(name: "user_groups"); + + migrationBuilder.DropTable(name: "permissions"); + + migrationBuilder.DropTable(name: "roles"); + + migrationBuilder.DropTable(name: "groups"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260724015343_AddSubmissionCreatedAt.Designer.cs b/StackDuel.Infrastructure/Migrations/20260724015343_AddSubmissionCreatedAt.Designer.cs new file mode 100644 index 0000000..4d108a5 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260724015343_AddSubmissionCreatedAt.Designer.cs @@ -0,0 +1,1239 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260724015343_AddSubmissionCreatedAt")] + partial class AddSubmissionCreatedAt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260724015343_AddSubmissionCreatedAt.cs b/StackDuel.Infrastructure/Migrations/20260724015343_AddSubmissionCreatedAt.cs new file mode 100644 index 0000000..545eea1 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260724015343_AddSubmissionCreatedAt.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddSubmissionCreatedAt : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "created_at", + table: "submissions", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified) + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "created_at", table: "submissions"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260809055245_AddGamesAndSoloRush.Designer.cs b/StackDuel.Infrastructure/Migrations/20260809055245_AddGamesAndSoloRush.Designer.cs new file mode 100644 index 0000000..0907255 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260809055245_AddGamesAndSoloRush.Designer.cs @@ -0,0 +1,1432 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260809055245_AddGamesAndSoloRush")] + partial class AddGamesAndSoloRush + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260809055245_AddGamesAndSoloRush.cs b/StackDuel.Infrastructure/Migrations/20260809055245_AddGamesAndSoloRush.cs new file mode 100644 index 0000000..0b25025 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260809055245_AddGamesAndSoloRush.cs @@ -0,0 +1,153 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGamesAndSoloRush : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "game_modes", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + is_built_in = table.Column(type: "boolean", nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_game_modes", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "game_mode_time_options", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + duration_seconds = table.Column(type: "integer", nullable: false), + is_default = table.Column(type: "boolean", nullable: false), + game_mode_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_game_mode_time_options", x => x.id); + table.ForeignKey( + name: "FK_game_mode_time_options_game_modes_game_mode_id", + column: x => x.game_mode_id, + principalTable: "game_modes", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "games", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + lobby_id = table.Column(type: "uuid", nullable: true), + game_mode_id = table.Column(type: "uuid", nullable: false), + time_limit_in_seconds = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + started_at = table.Column(type: "timestamp with time zone", nullable: true), + ended_at = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_games", x => x.id); + table.ForeignKey( + name: "FK_games_game_modes_game_mode_id", + column: x => x.game_mode_id, + principalTable: "game_modes", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "game_participants", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + seat_no = table.Column(type: "integer", nullable: false), + joined_at = table.Column(type: "timestamp with time zone", nullable: false), + game_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_game_participants", x => x.id); + table.ForeignKey( + name: "FK_game_participants_games_game_id", + column: x => x.game_id, + principalTable: "games", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_game_participants_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_game_mode_time_options_game_mode_id_duration_seconds", + table: "game_mode_time_options", + columns: new[] { "game_mode_id", "duration_seconds" }, + unique: true + ); + + migrationBuilder.CreateIndex(name: "IX_game_modes_key", table: "game_modes", column: "key", unique: true); + + migrationBuilder.CreateIndex( + name: "IX_game_participants_game_id_seat_no", + table: "game_participants", + columns: new[] { "game_id", "seat_no" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_game_participants_game_id_user_id", + table: "game_participants", + columns: new[] { "game_id", "user_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_game_participants_user_id", + table: "game_participants", + column: "user_id" + ); + + migrationBuilder.CreateIndex(name: "IX_games_game_mode_id", table: "games", column: "game_mode_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "game_mode_time_options"); + + migrationBuilder.DropTable(name: "game_participants"); + + migrationBuilder.DropTable(name: "games"); + + migrationBuilder.DropTable(name: "game_modes"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260809175255_AddGameModePlayerLimits.Designer.cs b/StackDuel.Infrastructure/Migrations/20260809175255_AddGameModePlayerLimits.Designer.cs new file mode 100644 index 0000000..c3867a0 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260809175255_AddGameModePlayerLimits.Designer.cs @@ -0,0 +1,1440 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260809175255_AddGameModePlayerLimits")] + partial class AddGameModePlayerLimits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260809175255_AddGameModePlayerLimits.cs b/StackDuel.Infrastructure/Migrations/20260809175255_AddGameModePlayerLimits.cs new file mode 100644 index 0000000..5256b77 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260809175255_AddGameModePlayerLimits.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameModePlayerLimits : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "max_players", + table: "game_modes", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "min_players", + table: "game_modes", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + // Backfill Solo Rush as a single-player mode + migrationBuilder.Sql("UPDATE game_modes SET min_players = 1, max_players = 1 WHERE key = 'solo_rush'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "max_players", table: "game_modes"); + + migrationBuilder.DropColumn(name: "min_players", table: "game_modes"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260810032612_AddGameParticipantScoreAndProblemSession.Designer.cs b/StackDuel.Infrastructure/Migrations/20260810032612_AddGameParticipantScoreAndProblemSession.Designer.cs new file mode 100644 index 0000000..36a7be5 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260810032612_AddGameParticipantScoreAndProblemSession.Designer.cs @@ -0,0 +1,1450 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260810032612_AddGameParticipantScoreAndProblemSession")] + partial class AddGameParticipantScoreAndProblemSession + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260810032612_AddGameParticipantScoreAndProblemSession.cs b/StackDuel.Infrastructure/Migrations/20260810032612_AddGameParticipantScoreAndProblemSession.cs new file mode 100644 index 0000000..3d4a005 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260810032612_AddGameParticipantScoreAndProblemSession.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameParticipantScoreAndProblemSession : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "problem_session", + table: "game_participants", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "score", + table: "game_participants", + type: "integer", + nullable: false, + defaultValue: 0 + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "problem_session", table: "game_participants"); + + migrationBuilder.DropColumn(name: "score", table: "game_participants"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260812050041_UpdateDatabase.Designer.cs b/StackDuel.Infrastructure/Migrations/20260812050041_UpdateDatabase.Designer.cs new file mode 100644 index 0000000..e99f2da --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260812050041_UpdateDatabase.Designer.cs @@ -0,0 +1,1469 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260812050041_UpdateDatabase")] + partial class UpdateDatabase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260812050041_UpdateDatabase.cs b/StackDuel.Infrastructure/Migrations/20260812050041_UpdateDatabase.cs new file mode 100644 index 0000000..c1bd440 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260812050041_UpdateDatabase.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class UpdateDatabase : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ScheduledExpirySequenceNumber", + table: "games", + type: "bigint", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "ScheduledExpirySequenceNumber", table: "games"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260816182549_AddSubmissionJobLock.Designer.cs b/StackDuel.Infrastructure/Migrations/20260816182549_AddSubmissionJobLock.Designer.cs new file mode 100644 index 0000000..40e468f --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260816182549_AddSubmissionJobLock.Designer.cs @@ -0,0 +1,1473 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260816182549_AddSubmissionJobLock")] + partial class AddSubmissionJobLock + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260816182549_AddSubmissionJobLock.cs b/StackDuel.Infrastructure/Migrations/20260816182549_AddSubmissionJobLock.cs new file mode 100644 index 0000000..9479bff --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260816182549_AddSubmissionJobLock.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddSubmissionJobLock : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "locked_until", + table: "submission_jobs", + type: "timestamp with time zone", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "locked_until", table: "submission_jobs"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260822191904_AddGameParticipantForfeitedAt.Designer.cs b/StackDuel.Infrastructure/Migrations/20260822191904_AddGameParticipantForfeitedAt.Designer.cs new file mode 100644 index 0000000..2951dd7 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260822191904_AddGameParticipantForfeitedAt.Designer.cs @@ -0,0 +1,1477 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260822191904_AddGameParticipantForfeitedAt")] + partial class AddGameParticipantForfeitedAt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260822191904_AddGameParticipantForfeitedAt.cs b/StackDuel.Infrastructure/Migrations/20260822191904_AddGameParticipantForfeitedAt.cs new file mode 100644 index 0000000..e568c9f --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260822191904_AddGameParticipantForfeitedAt.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameParticipantForfeitedAt : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "forfeited_at", + table: "game_participants", + type: "timestamp with time zone", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "forfeited_at", table: "game_participants"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260822200209_AddGameParticipantFinishedAt.Designer.cs b/StackDuel.Infrastructure/Migrations/20260822200209_AddGameParticipantFinishedAt.Designer.cs new file mode 100644 index 0000000..1057a2d --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260822200209_AddGameParticipantFinishedAt.Designer.cs @@ -0,0 +1,1481 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260822200209_AddGameParticipantFinishedAt")] + partial class AddGameParticipantFinishedAt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260822200209_AddGameParticipantFinishedAt.cs b/StackDuel.Infrastructure/Migrations/20260822200209_AddGameParticipantFinishedAt.cs new file mode 100644 index 0000000..1f00fd3 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260822200209_AddGameParticipantFinishedAt.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameParticipantFinishedAt : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "finished_at", + table: "game_participants", + type: "timestamp with time zone", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "finished_at", table: "game_participants"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260823173456_AddTestCaseGeneration.Designer.cs b/StackDuel.Infrastructure/Migrations/20260823173456_AddTestCaseGeneration.Designer.cs new file mode 100644 index 0000000..13a9b09 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260823173456_AddTestCaseGeneration.Designer.cs @@ -0,0 +1,1560 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260823173456_AddTestCaseGeneration")] + partial class AddTestCaseGeneration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260823173456_AddTestCaseGeneration.cs b/StackDuel.Infrastructure/Migrations/20260823173456_AddTestCaseGeneration.cs new file mode 100644 index 0000000..2d82f3b --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260823173456_AddTestCaseGeneration.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddTestCaseGeneration : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "generation_case_index", + table: "test_cases", + type: "integer", + nullable: true + ); + + migrationBuilder.AddColumn(name: "generation_spec_id", table: "test_cases", type: "uuid", nullable: true); + + migrationBuilder.AddColumn( + name: "source", + table: "test_cases", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "generation_spec_id", + table: "problem_setups", + type: "uuid", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "reference_solution_code", + table: "problem_setups", + type: "text", + nullable: true + ); + + migrationBuilder.CreateTable( + name: "test_case_generation_specs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_setup_id = table.Column(type: "uuid", nullable: false), + parameters = table.Column(type: "jsonb", nullable: false), + output_value_type = table.Column( + type: "character varying(100)", + maxLength: 100, + nullable: false + ), + target_case_count = table.Column(type: "integer", nullable: false), + seed = table.Column(type: "integer", nullable: false), + version = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_test_case_generation_specs", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_test_cases_generation_spec_id", + table: "test_cases", + column: "generation_spec_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_setups_generation_spec_id", + table: "problem_setups", + column: "generation_spec_id" + ); + + migrationBuilder.AddForeignKey( + name: "FK_problem_setups_test_case_generation_specs_generation_spec_id", + table: "problem_setups", + column: "generation_spec_id", + principalTable: "test_case_generation_specs", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_test_cases_test_case_generation_specs_generation_spec_id", + table: "test_cases", + column: "generation_spec_id", + principalTable: "test_case_generation_specs", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_problem_setups_test_case_generation_specs_generation_spec_id", + table: "problem_setups" + ); + + migrationBuilder.DropForeignKey( + name: "FK_test_cases_test_case_generation_specs_generation_spec_id", + table: "test_cases" + ); + + migrationBuilder.DropTable(name: "test_case_generation_specs"); + + migrationBuilder.DropIndex(name: "IX_test_cases_generation_spec_id", table: "test_cases"); + + migrationBuilder.DropIndex(name: "IX_problem_setups_generation_spec_id", table: "problem_setups"); + + migrationBuilder.DropColumn(name: "generation_case_index", table: "test_cases"); + + migrationBuilder.DropColumn(name: "generation_spec_id", table: "test_cases"); + + migrationBuilder.DropColumn(name: "source", table: "test_cases"); + + migrationBuilder.DropColumn(name: "generation_spec_id", table: "problem_setups"); + + migrationBuilder.DropColumn(name: "reference_solution_code", table: "problem_setups"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260823174246_AddTestCaseInputPosition.Designer.cs b/StackDuel.Infrastructure/Migrations/20260823174246_AddTestCaseInputPosition.Designer.cs new file mode 100644 index 0000000..00eda8a --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260823174246_AddTestCaseInputPosition.Designer.cs @@ -0,0 +1,1564 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260823174246_AddTestCaseInputPosition")] + partial class AddTestCaseInputPosition + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260823174246_AddTestCaseInputPosition.cs b/StackDuel.Infrastructure/Migrations/20260823174246_AddTestCaseInputPosition.cs new file mode 100644 index 0000000..38ad0cf --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260823174246_AddTestCaseInputPosition.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddTestCaseInputPosition : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "position", + table: "test_case_inputs", + type: "integer", + nullable: false, + defaultValue: 0 + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "position", table: "test_case_inputs"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260824013046_AddRetiredAtAndGenerationJobs.Designer.cs b/StackDuel.Infrastructure/Migrations/20260824013046_AddRetiredAtAndGenerationJobs.Designer.cs new file mode 100644 index 0000000..e7b776b --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260824013046_AddRetiredAtAndGenerationJobs.Designer.cs @@ -0,0 +1,1634 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260824013046_AddRetiredAtAndGenerationJobs")] + partial class AddRetiredAtAndGenerationJobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260824013046_AddRetiredAtAndGenerationJobs.cs b/StackDuel.Infrastructure/Migrations/20260824013046_AddRetiredAtAndGenerationJobs.cs new file mode 100644 index 0000000..57fd964 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260824013046_AddRetiredAtAndGenerationJobs.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddRetiredAtAndGenerationJobs : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "retired_at", + table: "test_cases", + type: "timestamp with time zone", + nullable: true + ); + + migrationBuilder.CreateTable( + name: "test_case_generation_jobs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_setup_id = table.Column(type: "uuid", nullable: false), + reference_solution_code = table.Column(type: "text", nullable: false), + parameters = table.Column(type: "jsonb", nullable: false), + output_value_type = table.Column( + type: "character varying(100)", + maxLength: 100, + nullable: false + ), + target_case_count = table.Column(type: "integer", nullable: false), + seed = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + result_summary = table.Column(type: "text", nullable: true), + failure_reason = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + locked_until = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_test_case_generation_jobs", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_test_case_generation_jobs_status", + table: "test_case_generation_jobs", + column: "status" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "test_case_generation_jobs"); + + migrationBuilder.DropColumn(name: "retired_at", table: "test_cases"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260825120000_AddUserCreatedAtAndSetupCompletedAt.Designer.cs b/StackDuel.Infrastructure/Migrations/20260825120000_AddUserCreatedAtAndSetupCompletedAt.Designer.cs new file mode 100644 index 0000000..98e8bce --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260825120000_AddUserCreatedAtAndSetupCompletedAt.Designer.cs @@ -0,0 +1,1642 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260825120000_AddUserCreatedAtAndSetupCompletedAt")] + partial class AddUserCreatedAtAndSetupCompletedAt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260825120000_AddUserCreatedAtAndSetupCompletedAt.cs b/StackDuel.Infrastructure/Migrations/20260825120000_AddUserCreatedAtAndSetupCompletedAt.cs new file mode 100644 index 0000000..02488c8 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260825120000_AddUserCreatedAtAndSetupCompletedAt.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddUserCreatedAtAndSetupCompletedAt : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "created_at", + table: "users", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified) + ); + + migrationBuilder.AddColumn( + name: "setup_completed_at", + table: "users", + type: "timestamp with time zone", + nullable: true + ); + + migrationBuilder.Sql( + "UPDATE users SET setup_completed_at = username_last_changed_at WHERE username_last_changed_at IS NOT NULL;" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "created_at", table: "users"); + + migrationBuilder.DropColumn(name: "setup_completed_at", table: "users"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260825130000_AddUserProfilePrivacy.Designer.cs b/StackDuel.Infrastructure/Migrations/20260825130000_AddUserProfilePrivacy.Designer.cs new file mode 100644 index 0000000..ce99bfd --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260825130000_AddUserProfilePrivacy.Designer.cs @@ -0,0 +1,1646 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260825130000_AddUserProfilePrivacy")] + partial class AddUserProfilePrivacy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260825130000_AddUserProfilePrivacy.cs b/StackDuel.Infrastructure/Migrations/20260825130000_AddUserProfilePrivacy.cs new file mode 100644 index 0000000..18d9cf8 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260825130000_AddUserProfilePrivacy.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddUserProfilePrivacy : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_private", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "is_private", table: "users"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260827021503_AddUserLanguagePreferences.Designer.cs b/StackDuel.Infrastructure/Migrations/20260827021503_AddUserLanguagePreferences.Designer.cs new file mode 100644 index 0000000..afbf4a2 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260827021503_AddUserLanguagePreferences.Designer.cs @@ -0,0 +1,1687 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260827021503_AddUserLanguagePreferences")] + partial class AddUserLanguagePreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260827021503_AddUserLanguagePreferences.cs b/StackDuel.Infrastructure/Migrations/20260827021503_AddUserLanguagePreferences.cs new file mode 100644 index 0000000..a6a331e --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260827021503_AddUserLanguagePreferences.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddUserLanguagePreferences : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_language_preferences", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + language_id = table.Column(type: "uuid", nullable: false), + position = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_user_language_preferences", x => new { x.user_id, x.language_id }); + table.ForeignKey( + name: "FK_user_language_preferences_languages_language_id", + column: x => x.language_id, + principalTable: "languages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_user_language_preferences_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_user_language_preferences_language_id", + table: "user_language_preferences", + column: "language_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_user_language_preferences_user_id_position", + table: "user_language_preferences", + columns: new[] { "user_id", "position" }, + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "user_language_preferences"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260830020639_AddLeaderboards.Designer.cs b/StackDuel.Infrastructure/Migrations/20260830020639_AddLeaderboards.Designer.cs new file mode 100644 index 0000000..ce7538c --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260830020639_AddLeaderboards.Designer.cs @@ -0,0 +1,1770 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260830020639_AddLeaderboards")] + partial class AddLeaderboards + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260830020639_AddLeaderboards.cs b/StackDuel.Infrastructure/Migrations/20260830020639_AddLeaderboards.cs new file mode 100644 index 0000000..cdefe10 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260830020639_AddLeaderboards.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddLeaderboards : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "leaderboards", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + game_mode_id = table.Column(type: "uuid", nullable: false), + time_limit_in_seconds = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_leaderboards", x => x.id); + table.ForeignKey( + name: "FK_leaderboards_game_modes_game_mode_id", + column: x => x.game_mode_id, + principalTable: "game_modes", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "leaderboard_participants", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + high_score = table.Column(type: "integer", nullable: false, defaultValue: 0), + leaderboard_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_leaderboard_participants", x => x.id); + table.ForeignKey( + name: "FK_leaderboard_participants_leaderboards_leaderboard_id", + column: x => x.leaderboard_id, + principalTable: "leaderboards", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_leaderboard_participants_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_leaderboard_participants_leaderboard_id_user_id", + table: "leaderboard_participants", + columns: new[] { "leaderboard_id", "user_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_leaderboard_participants_user_id", + table: "leaderboard_participants", + column: "user_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_leaderboards_game_mode_id_time_limit_in_seconds", + table: "leaderboards", + columns: new[] { "game_mode_id", "time_limit_in_seconds" }, + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "leaderboard_participants"); + + migrationBuilder.DropTable(name: "leaderboards"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260830193248_AddFeedback.Designer.cs b/StackDuel.Infrastructure/Migrations/20260830193248_AddFeedback.Designer.cs new file mode 100644 index 0000000..569f2c8 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260830193248_AddFeedback.Designer.cs @@ -0,0 +1,1862 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260830193248_AddFeedback")] + partial class AddFeedback + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260830193248_AddFeedback.cs b/StackDuel.Infrastructure/Migrations/20260830193248_AddFeedback.cs new file mode 100644 index 0000000..d5a6dcb --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260830193248_AddFeedback.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddFeedback : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "feedback_submissions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "integer", nullable: false), + message = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + rating = table.Column(type: "integer", nullable: true), + context_type = table.Column(type: "integer", nullable: false), + context_entity_id = table.Column(type: "uuid", nullable: true), + status = table.Column(type: "integer", nullable: false), + admin_note = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + page_url = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + user_agent = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_feedback_submissions", x => x.id); + table.ForeignKey( + name: "FK_feedback_submissions_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_feedback_submissions_user_id", + table: "feedback_submissions", + column: "user_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "feedback_submissions"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260830215417_AddProblemReactions.Designer.cs b/StackDuel.Infrastructure/Migrations/20260830215417_AddProblemReactions.Designer.cs new file mode 100644 index 0000000..2ab8749 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260830215417_AddProblemReactions.Designer.cs @@ -0,0 +1,1975 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260830215417_AddProblemReactions")] + partial class AddProblemReactions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260830215417_AddProblemReactions.cs b/StackDuel.Infrastructure/Migrations/20260830215417_AddProblemReactions.cs new file mode 100644 index 0000000..3600c9e --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260830215417_AddProblemReactions.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddProblemReactions : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "problem_reaction_types", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + emoji = table.Column(type: "character varying(16)", maxLength: 16, nullable: true), + sort_order = table.Column(type: "integer", nullable: false), + is_enabled = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_reaction_types", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "problem_reactions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + reaction_type_id = table.Column(type: "uuid", nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_reactions", x => x.id); + table.ForeignKey( + name: "FK_problem_reactions_problem_reaction_types_reaction_type_id", + column: x => x.reaction_type_id, + principalTable: "problem_reaction_types", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_problem_reactions_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_problem_reactions_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_reaction_types_key", + table: "problem_reaction_types", + column: "key", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_problem_reactions_active_per_user", + table: "problem_reactions", + columns: new[] { "problem_id", "user_id" }, + unique: true, + filter: "is_active = true" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_reactions_problem_id_user_id_reaction_type_id", + table: "problem_reactions", + columns: new[] { "problem_id", "user_id", "reaction_type_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_reactions_reaction_type_id", + table: "problem_reactions", + column: "reaction_type_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_reactions_user_id", + table: "problem_reactions", + column: "user_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "problem_reactions"); + + migrationBuilder.DropTable(name: "problem_reaction_types"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260831023653_AddAdditionalFileBundles.Designer.cs b/StackDuel.Infrastructure/Migrations/20260831023653_AddAdditionalFileBundles.Designer.cs new file mode 100644 index 0000000..8bb88ce --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260831023653_AddAdditionalFileBundles.Designer.cs @@ -0,0 +1,2016 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260831023653_AddAdditionalFileBundles")] + partial class AddAdditionalFileBundles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("FunctionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260831023653_AddAdditionalFileBundles.cs b/StackDuel.Infrastructure/Migrations/20260831023653_AddAdditionalFileBundles.cs new file mode 100644 index 0000000..f7e181f --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260831023653_AddAdditionalFileBundles.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddAdditionalFileBundles : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "additional_file_bundle_id", + table: "problem_setups", + type: "uuid", + nullable: true + ); + + migrationBuilder.CreateTable( + name: "additional_file_bundles", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + content = table.Column(type: "bytea", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_additional_file_bundles", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_setups_additional_file_bundle_id", + table: "problem_setups", + column: "additional_file_bundle_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_additional_file_bundles_name", + table: "additional_file_bundles", + column: "name", + unique: true + ); + + migrationBuilder.AddForeignKey( + name: "FK_problem_setups_additional_file_bundles_additional_file_bund~", + table: "problem_setups", + column: "additional_file_bundle_id", + principalTable: "additional_file_bundles", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_problem_setups_additional_file_bundles_additional_file_bund~", + table: "problem_setups" + ); + + migrationBuilder.DropTable(name: "additional_file_bundles"); + + migrationBuilder.DropIndex(name: "IX_problem_setups_additional_file_bundle_id", table: "problem_setups"); + + migrationBuilder.DropColumn(name: "additional_file_bundle_id", table: "problem_setups"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260901125247_AddTracks.Designer.cs b/StackDuel.Infrastructure/Migrations/20260901125247_AddTracks.Designer.cs new file mode 100644 index 0000000..ede3323 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260901125247_AddTracks.Designer.cs @@ -0,0 +1,2103 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260901125247_AddTracks")] + partial class AddTracks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260901125247_AddTracks.cs b/StackDuel.Infrastructure/Migrations/20260901125247_AddTracks.cs new file mode 100644 index 0000000..17c5cdb --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260901125247_AddTracks.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddTracks : Migration +{ + private const string GeneralPurposeTrackId = "11111111-1111-1111-1111-111111111111"; + private const string SqlTrackId = "22222222-2222-2222-2222-222222222222"; + private const string FrontendTrackId = "33333333-3333-3333-3333-333333333333"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "function_name", + table: "problem_setups", + type: "character varying(200)", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200 + ); + + migrationBuilder.CreateTable( + name: "tracks", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_tracks", x => x.id); + } + ); + + migrationBuilder.CreateIndex(name: "IX_tracks_key", table: "tracks", column: "key", unique: true); + + // Fixed ids so pre-existing rows (languages, games) can be backfilled to a real track + // within this same migration — TrackSeeder recognizes these by key at startup and will + // not duplicate them. + migrationBuilder.InsertData( + table: "tracks", + columns: new[] { "id", "key", "name", "is_active" }, + values: new object[,] + { + { GeneralPurposeTrackId, "general-purpose", "General Purpose", true }, + { SqlTrackId, "sql", "SQL", true }, + { FrontendTrackId, "frontend", "Frontend", false }, + } + ); + + migrationBuilder.AddColumn(name: "track_id", table: "languages", type: "uuid", nullable: true); + + migrationBuilder.Sql($"UPDATE languages SET track_id = '{SqlTrackId}' WHERE slug = 'sqlite'"); + migrationBuilder.Sql($"UPDATE languages SET track_id = '{GeneralPurposeTrackId}' WHERE track_id IS NULL"); + + migrationBuilder.AlterColumn( + name: "track_id", + table: "languages", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true + ); + + migrationBuilder.CreateIndex(name: "IX_languages_track_id", table: "languages", column: "track_id"); + + migrationBuilder.AddForeignKey( + name: "FK_languages_tracks_track_id", + table: "languages", + column: "track_id", + principalTable: "tracks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.CreateTable( + name: "game_tracks", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + track_id = table.Column(type: "uuid", nullable: false), + game_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_game_tracks", x => x.id); + table.ForeignKey( + name: "FK_game_tracks_games_game_id", + column: x => x.game_id, + principalTable: "games", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_game_tracks_tracks_track_id", + column: x => x.track_id, + principalTable: "tracks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_game_tracks_game_id_track_id", + table: "game_tracks", + columns: new[] { "game_id", "track_id" }, + unique: true + ); + + migrationBuilder.CreateIndex(name: "IX_game_tracks_track_id", table: "game_tracks", column: "track_id"); + + // Pre-existing games predate the track concept — put them in general-purpose so they + // stay queryable rather than left with no track membership at all. + migrationBuilder.Sql( + $"INSERT INTO game_tracks (id, game_id, track_id) SELECT gen_random_uuid(), id, '{GeneralPurposeTrackId}' FROM games" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "game_tracks"); + + migrationBuilder.DropForeignKey(name: "FK_languages_tracks_track_id", table: "languages"); + + migrationBuilder.DropIndex(name: "IX_languages_track_id", table: "languages"); + + migrationBuilder.DropColumn(name: "track_id", table: "languages"); + + migrationBuilder.DropTable(name: "tracks"); + + migrationBuilder.AlterColumn( + name: "function_name", + table: "problem_setups", + type: "character varying(200)", + maxLength: 200, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200, + oldNullable: true + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260903000728_AddFeatureFlags.Designer.cs b/StackDuel.Infrastructure/Migrations/20260903000728_AddFeatureFlags.Designer.cs new file mode 100644 index 0000000..5123669 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260903000728_AddFeatureFlags.Designer.cs @@ -0,0 +1,2228 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260903000728_AddFeatureFlags")] + partial class AddFeatureFlags + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260903000728_AddFeatureFlags.cs b/StackDuel.Infrastructure/Migrations/20260903000728_AddFeatureFlags.cs new file mode 100644 index 0000000..5ed25d4 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260903000728_AddFeatureFlags.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddFeatureFlags : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "feature_flags", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + default_enabled = table.Column(type: "boolean", nullable: false), + rollout_percentage = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_feature_flags", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "feature_flag_group_overrides", + columns: table => new + { + group_id = table.Column(type: "uuid", nullable: false), + feature_flag_id = table.Column(type: "uuid", nullable: false), + effect = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_feature_flag_group_overrides", x => new { x.group_id, x.feature_flag_id }); + table.ForeignKey( + name: "FK_feature_flag_group_overrides_feature_flags_feature_flag_id", + column: x => x.feature_flag_id, + principalTable: "feature_flags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_feature_flag_group_overrides_groups_group_id", + column: x => x.group_id, + principalTable: "groups", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "feature_flag_user_overrides", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + feature_flag_id = table.Column(type: "uuid", nullable: false), + effect = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_feature_flag_user_overrides", x => new { x.user_id, x.feature_flag_id }); + table.ForeignKey( + name: "FK_feature_flag_user_overrides_feature_flags_feature_flag_id", + column: x => x.feature_flag_id, + principalTable: "feature_flags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_feature_flag_group_overrides_feature_flag_id", + table: "feature_flag_group_overrides", + column: "feature_flag_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_feature_flag_user_overrides_feature_flag_id", + table: "feature_flag_user_overrides", + column: "feature_flag_id" + ); + + migrationBuilder.CreateIndex(name: "IX_feature_flags_key", table: "feature_flags", column: "key", unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "feature_flag_group_overrides"); + + migrationBuilder.DropTable(name: "feature_flag_user_overrides"); + + migrationBuilder.DropTable(name: "feature_flags"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260905142313_AddMultiFileSupport.Designer.cs b/StackDuel.Infrastructure/Migrations/20260905142313_AddMultiFileSupport.Designer.cs new file mode 100644 index 0000000..a09559d --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260905142313_AddMultiFileSupport.Designer.cs @@ -0,0 +1,2238 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260905142313_AddMultiFileSupport")] + partial class AddMultiFileSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260905142313_AddMultiFileSupport.cs b/StackDuel.Infrastructure/Migrations/20260905142313_AddMultiFileSupport.cs new file mode 100644 index 0000000..f78ccab --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260905142313_AddMultiFileSupport.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddMultiFileSupport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "additional_files", + table: "submissions", + type: "text", + nullable: false, + defaultValue: "[]" + ); + + migrationBuilder.AddColumn( + name: "additional_files", + table: "problem_setups", + type: "text", + nullable: false, + defaultValue: "[]" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "additional_files", table: "submissions"); + + migrationBuilder.DropColumn(name: "additional_files", table: "problem_setups"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260906180000_AddGameTrackLanguages.Designer.cs b/StackDuel.Infrastructure/Migrations/20260906180000_AddGameTrackLanguages.Designer.cs new file mode 100644 index 0000000..29a63c1 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260906180000_AddGameTrackLanguages.Designer.cs @@ -0,0 +1,2284 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260906180000_AddGameTrackLanguages")] + /// + partial class AddGameTrackLanguages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260906180000_AddGameTrackLanguages.cs b/StackDuel.Infrastructure/Migrations/20260906180000_AddGameTrackLanguages.cs new file mode 100644 index 0000000..9e445b9 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260906180000_AddGameTrackLanguages.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameTrackLanguages : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "game_track_languages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + language_id = table.Column(type: "uuid", nullable: false), + game_track_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_game_track_languages", x => x.id); + table.ForeignKey( + name: "FK_game_track_languages_game_tracks_game_track_id", + column: x => x.game_track_id, + principalTable: "game_tracks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_game_track_languages_languages_language_id", + column: x => x.language_id, + principalTable: "languages", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_game_track_languages_game_track_id_language_id", + table: "game_track_languages", + columns: new[] { "game_track_id", "language_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_game_track_languages_language_id", + table: "game_track_languages", + column: "language_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "game_track_languages"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260906190000_AddTrackAllowsLanguageSelection.Designer.cs b/StackDuel.Infrastructure/Migrations/20260906190000_AddTrackAllowsLanguageSelection.Designer.cs new file mode 100644 index 0000000..6d13cb4 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260906190000_AddTrackAllowsLanguageSelection.Designer.cs @@ -0,0 +1,2288 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260906190000_AddTrackAllowsLanguageSelection")] + /// + partial class AddTrackAllowsLanguageSelection + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260906190000_AddTrackAllowsLanguageSelection.cs b/StackDuel.Infrastructure/Migrations/20260906190000_AddTrackAllowsLanguageSelection.cs new file mode 100644 index 0000000..55063eb --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260906190000_AddTrackAllowsLanguageSelection.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddTrackAllowsLanguageSelection : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "allows_language_selection", + table: "tracks", + type: "boolean", + nullable: false, + defaultValue: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "allows_language_selection", table: "tracks"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260907042532_AddSubmissionGameId.Designer.cs b/StackDuel.Infrastructure/Migrations/20260907042532_AddSubmissionGameId.Designer.cs new file mode 100644 index 0000000..dd0bf58 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260907042532_AddSubmissionGameId.Designer.cs @@ -0,0 +1,2298 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260907042532_AddSubmissionGameId")] + partial class AddSubmissionGameId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260907042532_AddSubmissionGameId.cs b/StackDuel.Infrastructure/Migrations/20260907042532_AddSubmissionGameId.cs new file mode 100644 index 0000000..0de28b9 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260907042532_AddSubmissionGameId.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddSubmissionGameId : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "game_id", table: "submissions", type: "uuid", nullable: true); + + migrationBuilder.CreateIndex(name: "IX_submissions_game_id", table: "submissions", column: "game_id"); + + migrationBuilder.AddForeignKey( + name: "FK_submissions_games_game_id", + table: "submissions", + column: "game_id", + principalTable: "games", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey(name: "FK_submissions_games_game_id", table: "submissions"); + + migrationBuilder.DropIndex(name: "IX_submissions_game_id", table: "submissions"); + + migrationBuilder.DropColumn(name: "game_id", table: "submissions"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260907210537_AddGameJoinCode.Designer.cs b/StackDuel.Infrastructure/Migrations/20260907210537_AddGameJoinCode.Designer.cs new file mode 100644 index 0000000..322c6e2 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260907210537_AddGameJoinCode.Designer.cs @@ -0,0 +1,2307 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260907210537_AddGameJoinCode")] + partial class AddGameJoinCode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260907210537_AddGameJoinCode.cs b/StackDuel.Infrastructure/Migrations/20260907210537_AddGameJoinCode.cs new file mode 100644 index 0000000..bf2402e --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260907210537_AddGameJoinCode.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameJoinCode : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "join_code", + table: "games", + type: "character varying(7)", + maxLength: 7, + nullable: false, + defaultValue: "" + ); + + // Backfill existing rows with a code derived from their id so the column can be made + // unique below — pre-existing games predate join codes and were never meant to be + // joined this way, so a deterministic (not cryptographically random) code is fine here. + migrationBuilder.Sql("UPDATE games SET join_code = UPPER(SUBSTRING(REPLACE(id::text, '-', ''), 1, 7));"); + + migrationBuilder.CreateIndex(name: "IX_games_join_code", table: "games", column: "join_code", unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "IX_games_join_code", table: "games"); + + migrationBuilder.DropColumn(name: "join_code", table: "games"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260908003152_AddUserAvatars.Designer.cs b/StackDuel.Infrastructure/Migrations/20260908003152_AddUserAvatars.Designer.cs new file mode 100644 index 0000000..e9852b9 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260908003152_AddUserAvatars.Designer.cs @@ -0,0 +1,2336 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260908003152_AddUserAvatars")] + partial class AddUserAvatars + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260908003152_AddUserAvatars.cs b/StackDuel.Infrastructure/Migrations/20260908003152_AddUserAvatars.cs new file mode 100644 index 0000000..2966724 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260908003152_AddUserAvatars.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddUserAvatars : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_avatars", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + image_url = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_user_avatars", x => x.id); + table.ForeignKey( + name: "FK_user_avatars_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_user_avatars_user_id_created_at", + table: "user_avatars", + columns: new[] { "user_id", "created_at" } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "user_avatars"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260909173728_AddAuditLog.Designer.cs b/StackDuel.Infrastructure/Migrations/20260909173728_AddAuditLog.Designer.cs new file mode 100644 index 0000000..187cf14 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260909173728_AddAuditLog.Designer.cs @@ -0,0 +1,2397 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260909173728_AddAuditLog")] + partial class AddAuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260909173728_AddAuditLog.cs b/StackDuel.Infrastructure/Migrations/20260909173728_AddAuditLog.cs new file mode 100644 index 0000000..bac34b1 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260909173728_AddAuditLog.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddAuditLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "audit_log_entries", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + actor_user_id = table.Column(type: "uuid", nullable: true), + actor_username = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + action = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + target_type = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + target_id = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + details_json = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_audit_log_entries", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_audit_log_entries_actor_user_id", + table: "audit_log_entries", + column: "actor_user_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_audit_log_entries_created_at", + table: "audit_log_entries", + column: "created_at" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "audit_log_entries"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260909184707_AddCampaigns.Designer.cs b/StackDuel.Infrastructure/Migrations/20260909184707_AddCampaigns.Designer.cs new file mode 100644 index 0000000..c0453b3 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260909184707_AddCampaigns.Designer.cs @@ -0,0 +1,2706 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260909184707_AddCampaigns")] + partial class AddCampaigns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260909184707_AddCampaigns.cs b/StackDuel.Infrastructure/Migrations/20260909184707_AddCampaigns.cs new file mode 100644 index 0000000..b283bc7 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260909184707_AddCampaigns.cs @@ -0,0 +1,284 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddCampaigns : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "campaigns", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + difficulty = table.Column(type: "integer", nullable: false), + status = table.Column(type: "integer", nullable: false), + icon_key = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + sort_order = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + published_at = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_campaigns", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "campaign_enrollments", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + campaign_id = table.Column(type: "uuid", nullable: false), + status = table.Column(type: "integer", nullable: false), + enrolled_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_campaign_enrollments", x => x.id); + table.ForeignKey( + name: "FK_campaign_enrollments_campaigns_campaign_id", + column: x => x.campaign_id, + principalTable: "campaigns", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_campaign_enrollments_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "campaign_modules", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + CampaignId = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + sort_order = table.Column(type: "integer", nullable: false), + campaign_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_campaign_modules", x => x.id); + table.ForeignKey( + name: "FK_campaign_modules_campaigns_campaign_id", + column: x => x.campaign_id, + principalTable: "campaigns", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "campaign_prerequisites", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + CampaignId = table.Column(type: "uuid", nullable: false), + required_campaign_id = table.Column(type: "uuid", nullable: false), + campaign_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_campaign_prerequisites", x => x.id); + table.ForeignKey( + name: "FK_campaign_prerequisites_campaigns_campaign_id", + column: x => x.campaign_id, + principalTable: "campaigns", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_campaign_prerequisites_campaigns_required_campaign_id", + column: x => x.required_campaign_id, + principalTable: "campaigns", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "campaign_units", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + CampaignModuleId = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + content = table.Column(type: "character varying(20000)", maxLength: 20000, nullable: false), + unit_type = table.Column(type: "integer", nullable: false), + estimated_minutes = table.Column(type: "integer", nullable: false), + sort_order = table.Column(type: "integer", nullable: false), + campaign_module_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_campaign_units", x => x.id); + table.ForeignKey( + name: "FK_campaign_units_campaign_modules_campaign_module_id", + column: x => x.campaign_module_id, + principalTable: "campaign_modules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "unit_completions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + campaign_unit_id = table.Column(type: "uuid", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_unit_completions", x => x.id); + table.ForeignKey( + name: "FK_unit_completions_campaign_units_campaign_unit_id", + column: x => x.campaign_unit_id, + principalTable: "campaign_units", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_unit_completions_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "unit_problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + CampaignUnitId = table.Column(type: "uuid", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + sort_order = table.Column(type: "integer", nullable: false), + campaign_unit_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_unit_problems", x => x.id); + table.ForeignKey( + name: "FK_unit_problems_campaign_units_campaign_unit_id", + column: x => x.campaign_unit_id, + principalTable: "campaign_units", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_unit_problems_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_campaign_enrollments_campaign_id", + table: "campaign_enrollments", + column: "campaign_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_campaign_enrollments_user_id_campaign_id", + table: "campaign_enrollments", + columns: new[] { "user_id", "campaign_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_campaign_modules_campaign_id_sort_order", + table: "campaign_modules", + columns: new[] { "campaign_id", "sort_order" } + ); + + migrationBuilder.CreateIndex( + name: "IX_campaign_prerequisites_campaign_id_required_campaign_id", + table: "campaign_prerequisites", + columns: new[] { "campaign_id", "required_campaign_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_campaign_prerequisites_required_campaign_id", + table: "campaign_prerequisites", + column: "required_campaign_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_campaign_units_campaign_module_id_sort_order", + table: "campaign_units", + columns: new[] { "campaign_module_id", "sort_order" } + ); + + migrationBuilder.CreateIndex(name: "IX_campaigns_slug", table: "campaigns", column: "slug", unique: true); + + migrationBuilder.CreateIndex( + name: "IX_unit_completions_campaign_unit_id", + table: "unit_completions", + column: "campaign_unit_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_unit_completions_user_id_campaign_unit_id", + table: "unit_completions", + columns: new[] { "user_id", "campaign_unit_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_unit_problems_campaign_unit_id_problem_id", + table: "unit_problems", + columns: new[] { "campaign_unit_id", "problem_id" }, + unique: true + ); + + migrationBuilder.CreateIndex(name: "IX_unit_problems_problem_id", table: "unit_problems", column: "problem_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "campaign_enrollments"); + + migrationBuilder.DropTable(name: "campaign_prerequisites"); + + migrationBuilder.DropTable(name: "unit_completions"); + + migrationBuilder.DropTable(name: "unit_problems"); + + migrationBuilder.DropTable(name: "campaign_units"); + + migrationBuilder.DropTable(name: "campaign_modules"); + + migrationBuilder.DropTable(name: "campaigns"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260914042448_AddPlayerRatings.Designer.cs b/StackDuel.Infrastructure/Migrations/20260914042448_AddPlayerRatings.Designer.cs new file mode 100644 index 0000000..3cf7863 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260914042448_AddPlayerRatings.Designer.cs @@ -0,0 +1,2950 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260914042448_AddPlayerRatings")] + partial class AddPlayerRatings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerModeRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.HasIndex("GameModeId", "SeasonId", "Rating"); + + b.HasIndex("UserId", "GameModeId", "SeasonId") + .IsUnique(); + + b.ToTable("player_mode_ratings", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvedProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("solved_at"); + + b.Property("player_solving_rating_id") + .HasColumnType("uuid") + .HasColumnName("player_solving_rating_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("player_solving_rating_id", "ProblemId") + .IsUnique(); + + b.ToTable("player_solved_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemsSolved") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("problems_solved"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player_solving_ratings", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("period_start"); + + b.HasKey("Id"); + + b.HasIndex("PeriodStart") + .IsUnique(); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerModeRating", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Ratings.Entities.Season", null) + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvedProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", null) + .WithMany("SolvedProblems") + .HasForeignKey("player_solving_rating_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.Navigation("SolvedProblems"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260914042448_AddPlayerRatings.cs b/StackDuel.Infrastructure/Migrations/20260914042448_AddPlayerRatings.cs new file mode 100644 index 0000000..33b03e5 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260914042448_AddPlayerRatings.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddPlayerRatings : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "player_solving_ratings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + rating = table.Column(type: "integer", nullable: false), + problems_solved = table.Column(type: "integer", nullable: false, defaultValue: 0), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_player_solving_ratings", x => x.id); + table.ForeignKey( + name: "FK_player_solving_ratings_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "seasons", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + period_start = table.Column(type: "timestamp with time zone", nullable: false), + period_end = table.Column(type: "timestamp with time zone", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_seasons", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "player_solved_problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + solved_at = table.Column(type: "timestamp with time zone", nullable: false), + player_solving_rating_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_player_solved_problems", x => x.id); + table.ForeignKey( + name: "FK_player_solved_problems_player_solving_ratings_player_solvin~", + column: x => x.player_solving_rating_id, + principalTable: "player_solving_ratings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_player_solved_problems_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "player_mode_ratings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + game_mode_id = table.Column(type: "uuid", nullable: false), + season_id = table.Column(type: "uuid", nullable: false), + rating = table.Column(type: "integer", nullable: false), + games_played = table.Column(type: "integer", nullable: false, defaultValue: 0), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_player_mode_ratings", x => x.id); + table.ForeignKey( + name: "FK_player_mode_ratings_game_modes_game_mode_id", + column: x => x.game_mode_id, + principalTable: "game_modes", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_player_mode_ratings_seasons_season_id", + column: x => x.season_id, + principalTable: "seasons", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_player_mode_ratings_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_player_mode_ratings_game_mode_id_season_id_rating", + table: "player_mode_ratings", + columns: new[] { "game_mode_id", "season_id", "rating" } + ); + + migrationBuilder.CreateIndex( + name: "IX_player_mode_ratings_season_id", + table: "player_mode_ratings", + column: "season_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_player_mode_ratings_user_id_game_mode_id_season_id", + table: "player_mode_ratings", + columns: new[] { "user_id", "game_mode_id", "season_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_player_solved_problems_player_solving_rating_id_problem_id", + table: "player_solved_problems", + columns: new[] { "player_solving_rating_id", "problem_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_player_solved_problems_problem_id", + table: "player_solved_problems", + column: "problem_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_player_solving_ratings_user_id", + table: "player_solving_ratings", + column: "user_id", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_seasons_period_start", + table: "seasons", + column: "period_start", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "player_mode_ratings"); + + migrationBuilder.DropTable(name: "player_solved_problems"); + + migrationBuilder.DropTable(name: "seasons"); + + migrationBuilder.DropTable(name: "player_solving_ratings"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex.Designer.cs b/StackDuel.Infrastructure/Migrations/20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex.Designer.cs new file mode 100644 index 0000000..8fe59ae --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex.Designer.cs @@ -0,0 +1,2952 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex")] + partial class AddSubmissionJobStatusLockedUntilCreatedAtIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerModeRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.HasIndex("GameModeId", "SeasonId", "Rating"); + + b.HasIndex("UserId", "GameModeId", "SeasonId") + .IsUnique(); + + b.ToTable("player_mode_ratings", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvedProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("solved_at"); + + b.Property("player_solving_rating_id") + .HasColumnType("uuid") + .HasColumnName("player_solving_rating_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("player_solving_rating_id", "ProblemId") + .IsUnique(); + + b.ToTable("player_solved_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemsSolved") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("problems_solved"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player_solving_ratings", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("period_start"); + + b.HasKey("Id"); + + b.HasIndex("PeriodStart") + .IsUnique(); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerModeRating", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Ratings.Entities.Season", null) + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvedProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", null) + .WithMany("SolvedProblems") + .HasForeignKey("player_solving_rating_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.Navigation("SolvedProblems"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex.cs b/StackDuel.Infrastructure/Migrations/20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex.cs new file mode 100644 index 0000000..a26ed58 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260915120000_AddSubmissionJobStatusLockedUntilCreatedAtIndex.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddSubmissionJobStatusLockedUntilCreatedAtIndex : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_submission_jobs_Status_LockedUntil_CreatedAt", + table: "submission_jobs", + columns: new[] { "status", "locked_until", "created_at" } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "IX_submission_jobs_Status_LockedUntil_CreatedAt", table: "submission_jobs"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260921031241_RemovePlayerModeRatingsAndSeasons.Designer.cs b/StackDuel.Infrastructure/Migrations/20260921031241_RemovePlayerModeRatingsAndSeasons.Designer.cs new file mode 100644 index 0000000..d5122d4 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260921031241_RemovePlayerModeRatingsAndSeasons.Designer.cs @@ -0,0 +1,2855 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260921031241_RemovePlayerModeRatingsAndSeasons")] + partial class RemovePlayerModeRatingsAndSeasons + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvedProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("solved_at"); + + b.Property("player_solving_rating_id") + .HasColumnType("uuid") + .HasColumnName("player_solving_rating_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("player_solving_rating_id", "ProblemId") + .IsUnique(); + + b.ToTable("player_solved_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProblemsSolved") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("problems_solved"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player_solving_ratings", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvedProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", null) + .WithMany("SolvedProblems") + .HasForeignKey("player_solving_rating_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Ratings.Entities.PlayerSolvingRating", b => + { + b.Navigation("SolvedProblems"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260921031241_RemovePlayerModeRatingsAndSeasons.cs b/StackDuel.Infrastructure/Migrations/20260921031241_RemovePlayerModeRatingsAndSeasons.cs new file mode 100644 index 0000000..fe3d75d --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260921031241_RemovePlayerModeRatingsAndSeasons.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class RemovePlayerModeRatingsAndSeasons : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "player_mode_ratings"); + + migrationBuilder.DropTable(name: "seasons"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "seasons", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + period_end = table.Column(type: "timestamp with time zone", nullable: false), + period_start = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_seasons", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "player_mode_ratings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + game_mode_id = table.Column(type: "uuid", nullable: false), + games_played = table.Column(type: "integer", nullable: false, defaultValue: 0), + rating = table.Column(type: "integer", nullable: false), + season_id = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_player_mode_ratings", x => x.id); + table.ForeignKey( + name: "FK_player_mode_ratings_game_modes_game_mode_id", + column: x => x.game_mode_id, + principalTable: "game_modes", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_player_mode_ratings_seasons_season_id", + column: x => x.season_id, + principalTable: "seasons", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_player_mode_ratings_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_player_mode_ratings_game_mode_id_season_id_rating", + table: "player_mode_ratings", + columns: new[] { "game_mode_id", "season_id", "rating" } + ); + + migrationBuilder.CreateIndex( + name: "IX_player_mode_ratings_season_id", + table: "player_mode_ratings", + column: "season_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_player_mode_ratings_user_id_game_mode_id_season_id", + table: "player_mode_ratings", + columns: new[] { "user_id", "game_mode_id", "season_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_seasons_period_start", + table: "seasons", + column: "period_start", + unique: true + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260921032915_RemovePlayerSolvingRatings.Designer.cs b/StackDuel.Infrastructure/Migrations/20260921032915_RemovePlayerSolvingRatings.Designer.cs new file mode 100644 index 0000000..2cf6c06 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260921032915_RemovePlayerSolvingRatings.Designer.cs @@ -0,0 +1,2760 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260921032915_RemovePlayerSolvingRatings")] + partial class RemovePlayerSolvingRatings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260921032915_RemovePlayerSolvingRatings.cs b/StackDuel.Infrastructure/Migrations/20260921032915_RemovePlayerSolvingRatings.cs new file mode 100644 index 0000000..68fd290 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260921032915_RemovePlayerSolvingRatings.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class RemovePlayerSolvingRatings : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "player_solved_problems"); + + migrationBuilder.DropTable(name: "player_solving_ratings"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "player_solving_ratings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + problems_solved = table.Column(type: "integer", nullable: false, defaultValue: 0), + rating = table.Column(type: "integer", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_player_solving_ratings", x => x.id); + table.ForeignKey( + name: "FK_player_solving_ratings_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateTable( + name: "player_solved_problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + solved_at = table.Column(type: "timestamp with time zone", nullable: false), + player_solving_rating_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_player_solved_problems", x => x.id); + table.ForeignKey( + name: "FK_player_solved_problems_player_solving_ratings_player_solvin~", + column: x => x.player_solving_rating_id, + principalTable: "player_solving_ratings", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_player_solved_problems_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_player_solved_problems_player_solving_rating_id_problem_id", + table: "player_solved_problems", + columns: new[] { "player_solving_rating_id", "problem_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_player_solved_problems_problem_id", + table: "player_solved_problems", + column: "problem_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_player_solving_ratings_user_id", + table: "player_solving_ratings", + column: "user_id", + unique: true + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260924043545_AddProblemPools.Designer.cs b/StackDuel.Infrastructure/Migrations/20260924043545_AddProblemPools.Designer.cs new file mode 100644 index 0000000..a87762e --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260924043545_AddProblemPools.Designer.cs @@ -0,0 +1,2867 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260924043545_AddProblemPools")] + partial class AddProblemPools + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260924043545_AddProblemPools.cs b/StackDuel.Infrastructure/Migrations/20260924043545_AddProblemPools.cs new file mode 100644 index 0000000..6f669a7 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260924043545_AddProblemPools.cs @@ -0,0 +1,174 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddProblemPools : Migration +{ + private const string AllProblemsPoolId = "44444444-4444-4444-4444-444444444444"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "problem_pools", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + description = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_pools", x => x.id); + } + ); + + migrationBuilder.CreateIndex(name: "IX_problem_pools_key", table: "problem_pools", column: "key", unique: true); + + migrationBuilder.CreateTable( + name: "problem_pool_problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + pool_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_pool_problems", x => x.id); + table.ForeignKey( + name: "FK_problem_pool_problems_problem_pools_pool_id", + column: x => x.pool_id, + principalTable: "problem_pools", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_problem_pool_problems_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_pool_problems_pool_id_problem_id", + table: "problem_pool_problems", + columns: new[] { "pool_id", "problem_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_pool_problems_problem_id", + table: "problem_pool_problems", + column: "problem_id" + ); + + // Seeds the catch-all pool every pre-existing game mode and game gets backfilled to + // below, and populates it with every problem that already exists — mirrors the + // AddTracks migration backfilling pre-existing games into "general-purpose". + migrationBuilder.Sql( + $""" + INSERT INTO problem_pools (id, key, name, description, created_at) + VALUES ( + '{AllProblemsPoolId}', + 'all-problems', + 'All Problems', + 'Every published problem. Used as the default pool for built-in game modes.', + now()); + """ + ); + + migrationBuilder.Sql( + $""" + INSERT INTO problem_pool_problems (id, pool_id, problem_id) + SELECT gen_random_uuid(), '{AllProblemsPoolId}', id + FROM problems; + """ + ); + + migrationBuilder.AddColumn(name: "pool_id", table: "games", type: "uuid", nullable: true); + + migrationBuilder.Sql($"UPDATE games SET pool_id = '{AllProblemsPoolId}' WHERE pool_id IS NULL"); + + migrationBuilder.AlterColumn( + name: "pool_id", + table: "games", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true + ); + + migrationBuilder.AddColumn(name: "default_pool_id", table: "game_modes", type: "uuid", nullable: true); + + migrationBuilder.Sql( + $"UPDATE game_modes SET default_pool_id = '{AllProblemsPoolId}' WHERE default_pool_id IS NULL" + ); + + migrationBuilder.AlterColumn( + name: "default_pool_id", + table: "game_modes", + type: "uuid", + nullable: false, + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true + ); + + migrationBuilder.CreateIndex(name: "IX_games_pool_id", table: "games", column: "pool_id"); + + migrationBuilder.CreateIndex( + name: "IX_game_modes_default_pool_id", + table: "game_modes", + column: "default_pool_id" + ); + + migrationBuilder.AddForeignKey( + name: "FK_game_modes_problem_pools_default_pool_id", + table: "game_modes", + column: "default_pool_id", + principalTable: "problem_pools", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_games_problem_pools_pool_id", + table: "games", + column: "pool_id", + principalTable: "problem_pools", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey(name: "FK_game_modes_problem_pools_default_pool_id", table: "game_modes"); + + migrationBuilder.DropForeignKey(name: "FK_games_problem_pools_pool_id", table: "games"); + + migrationBuilder.DropTable(name: "problem_pool_problems"); + + migrationBuilder.DropTable(name: "problem_pools"); + + migrationBuilder.DropIndex(name: "IX_games_pool_id", table: "games"); + + migrationBuilder.DropIndex(name: "IX_game_modes_default_pool_id", table: "game_modes"); + + migrationBuilder.DropColumn(name: "pool_id", table: "games"); + + migrationBuilder.DropColumn(name: "default_pool_id", table: "game_modes"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260925034603_AddGameSkips.Designer.cs b/StackDuel.Infrastructure/Migrations/20260925034603_AddGameSkips.Designer.cs new file mode 100644 index 0000000..8228d6b --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260925034603_AddGameSkips.Designer.cs @@ -0,0 +1,2879 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260925034603_AddGameSkips")] + partial class AddGameSkips + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260925034603_AddGameSkips.cs b/StackDuel.Infrastructure/Migrations/20260925034603_AddGameSkips.cs new file mode 100644 index 0000000..ed63396 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260925034603_AddGameSkips.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameSkips : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "skips_enabled", + table: "games", + type: "boolean", + nullable: false, + defaultValue: true + ); + + migrationBuilder.AddColumn( + name: "skips_remaining", + table: "game_participants", + type: "integer", + nullable: false, + defaultValue: 3 + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "skips_enabled", table: "games"); + + migrationBuilder.DropColumn(name: "skips_remaining", table: "game_participants"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260926052424_AddAchievements.Designer.cs b/StackDuel.Infrastructure/Migrations/20260926052424_AddAchievements.Designer.cs new file mode 100644 index 0000000..b85630c --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260926052424_AddAchievements.Designer.cs @@ -0,0 +1,3142 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260926052424_AddAchievements")] + partial class AddAchievements + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260926052424_AddAchievements.cs b/StackDuel.Infrastructure/Migrations/20260926052424_AddAchievements.cs new file mode 100644 index 0000000..3b32d65 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260926052424_AddAchievements.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddAchievements : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "achievement_definitions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + category = table.Column(type: "integer", nullable: false), + tier = table.Column(type: "integer", nullable: false), + icon_key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + is_secret = table.Column(type: "boolean", nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + criteria_type = table.Column(type: "integer", nullable: false), + criteria_stat = table.Column(type: "integer", nullable: true), + criteria_threshold = table.Column(type: "integer", nullable: true), + custom_rule_key = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_achievement_definitions", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "user_achievement_stats", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + accepted_solve_count = table.Column(type: "integer", nullable: false, defaultValue: 0), + current_solve_streak = table.Column(type: "integer", nullable: false, defaultValue: 0), + longest_solve_streak = table.Column(type: "integer", nullable: false, defaultValue: 0), + last_solve_date_utc = table.Column(type: "date", nullable: true), + games_played = table.Column(type: "integer", nullable: false, defaultValue: 0), + games_won = table.Column(type: "integer", nullable: false, defaultValue: 0), + games_lost = table.Column(type: "integer", nullable: false, defaultValue: 0), + games_drawn = table.Column(type: "integer", nullable: false, defaultValue: 0), + current_win_streak = table.Column(type: "integer", nullable: false, defaultValue: 0), + longest_win_streak = table.Column(type: "integer", nullable: false, defaultValue: 0), + bug_reports_submitted = table.Column(type: "integer", nullable: false, defaultValue: 0), + solved_difficulty_mask = table.Column(type: "integer", nullable: false, defaultValue: 0), + }, + constraints: table => + { + table.PrimaryKey("PK_user_achievement_stats", x => x.id); + table.ForeignKey( + name: "FK_user_achievement_stats_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "user_achievements", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + achievement_definition_id = table.Column(type: "uuid", nullable: false), + earned_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_user_achievements", x => x.id); + table.ForeignKey( + name: "FK_user_achievements_achievement_definitions_achievement_defin~", + column: x => x.achievement_definition_id, + principalTable: "achievement_definitions", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_user_achievements_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "user_achievement_stat_languages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + language_id = table.Column(type: "uuid", nullable: false), + user_achievement_stats_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_user_achievement_stat_languages", x => x.id); + table.ForeignKey( + name: "FK_user_achievement_stat_languages_languages_language_id", + column: x => x.language_id, + principalTable: "languages", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + table.ForeignKey( + name: "FK_user_achievement_stat_languages_user_achievement_stats_user~", + column: x => x.user_achievement_stats_id, + principalTable: "user_achievement_stats", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_achievement_definitions_code", + table: "achievement_definitions", + column: "code", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_user_achievement_stat_languages_language_id", + table: "user_achievement_stat_languages", + column: "language_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_user_achievement_stat_languages_user_achievement_stats_id_l~", + table: "user_achievement_stat_languages", + columns: new[] { "user_achievement_stats_id", "language_id" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_user_achievement_stats_user_id", + table: "user_achievement_stats", + column: "user_id", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_user_achievements_achievement_definition_id", + table: "user_achievements", + column: "achievement_definition_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_user_achievements_user_id_achievement_definition_id", + table: "user_achievements", + columns: new[] { "user_id", "achievement_definition_id" }, + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "user_achievement_stat_languages"); + + migrationBuilder.DropTable(name: "user_achievements"); + + migrationBuilder.DropTable(name: "user_achievement_stats"); + + migrationBuilder.DropTable(name: "achievement_definitions"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260926195116_AddProblemValidationWorkflow.Designer.cs b/StackDuel.Infrastructure/Migrations/20260926195116_AddProblemValidationWorkflow.Designer.cs new file mode 100644 index 0000000..8cd6eb8 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260926195116_AddProblemValidationWorkflow.Designer.cs @@ -0,0 +1,3239 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260926195116_AddProblemValidationWorkflow")] + partial class AddProblemValidationWorkflow + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("GenerationOutputValueType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("generation_output_value_type"); + + b.Property("GenerationParameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("generation_parameters"); + + b.Property("GenerationSeed") + .HasColumnType("integer") + .HasColumnName("generation_seed"); + + b.Property("GenerationTargetCaseCount") + .HasColumnType("integer") + .HasColumnName("generation_target_case_count"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260926195116_AddProblemValidationWorkflow.cs b/StackDuel.Infrastructure/Migrations/20260926195116_AddProblemValidationWorkflow.cs new file mode 100644 index 0000000..433f5ef --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260926195116_AddProblemValidationWorkflow.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddProblemValidationWorkflow : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "generation_output_value_type", + table: "problems", + type: "character varying(100)", + maxLength: 100, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "generation_parameters", + table: "problems", + type: "jsonb", + nullable: false, + defaultValue: "[]" + ); + + migrationBuilder.AddColumn(name: "generation_seed", table: "problems", type: "integer", nullable: true); + + migrationBuilder.AddColumn( + name: "generation_target_case_count", + table: "problems", + type: "integer", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "validation_failure_reason", + table: "problems", + type: "character varying(4000)", + maxLength: 4000, + nullable: true + ); + + migrationBuilder.CreateTable( + name: "problem_validation_jobs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + status = table.Column(type: "integer", nullable: false), + test_case_generation_job_ids = table.Column(type: "jsonb", nullable: false), + result_summary = table.Column(type: "text", nullable: true), + failure_reason = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + locked_until = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_validation_jobs", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "required_problem_languages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + language_version_id = table.Column(type: "uuid", nullable: false), + sort_order = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_required_problem_languages", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_problem_validation_jobs_status_locked_until", + table: "problem_validation_jobs", + columns: new[] { "status", "locked_until" } + ); + + migrationBuilder.CreateIndex( + name: "IX_required_problem_languages_language_version_id", + table: "required_problem_languages", + column: "language_version_id", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "problem_validation_jobs"); + + migrationBuilder.DropTable(name: "required_problem_languages"); + + migrationBuilder.DropColumn(name: "generation_output_value_type", table: "problems"); + + migrationBuilder.DropColumn(name: "generation_parameters", table: "problems"); + + migrationBuilder.DropColumn(name: "generation_seed", table: "problems"); + + migrationBuilder.DropColumn(name: "generation_target_case_count", table: "problems"); + + migrationBuilder.DropColumn(name: "validation_failure_reason", table: "problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260926213420_MoveProblemGenerationParametersToOwnTable.Designer.cs b/StackDuel.Infrastructure/Migrations/20260926213420_MoveProblemGenerationParametersToOwnTable.Designer.cs new file mode 100644 index 0000000..13663b4 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260926213420_MoveProblemGenerationParametersToOwnTable.Designer.cs @@ -0,0 +1,3255 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260926213420_MoveProblemGenerationParametersToOwnTable")] + partial class MoveProblemGenerationParametersToOwnTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260926213420_MoveProblemGenerationParametersToOwnTable.cs b/StackDuel.Infrastructure/Migrations/20260926213420_MoveProblemGenerationParametersToOwnTable.cs new file mode 100644 index 0000000..0502137 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260926213420_MoveProblemGenerationParametersToOwnTable.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class MoveProblemGenerationParametersToOwnTable : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "generation_output_value_type", table: "problems"); + + migrationBuilder.DropColumn(name: "generation_parameters", table: "problems"); + + migrationBuilder.DropColumn(name: "generation_seed", table: "problems"); + + migrationBuilder.DropColumn(name: "generation_target_case_count", table: "problems"); + + migrationBuilder.CreateTable( + name: "problem_generation_parameters", + columns: table => new + { + ProblemId = table.Column(type: "uuid", nullable: false), + seed = table.Column(type: "integer", nullable: false), + parameters = table.Column(type: "jsonb", nullable: false), + output_value_type = table.Column( + type: "character varying(100)", + maxLength: 100, + nullable: false + ), + target_case_count = table.Column(type: "integer", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_problem_generation_parameters", x => x.ProblemId); + table.ForeignKey( + name: "FK_problem_generation_parameters_problems_ProblemId", + column: x => x.ProblemId, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "problem_generation_parameters"); + + migrationBuilder.AddColumn( + name: "generation_output_value_type", + table: "problems", + type: "character varying(100)", + maxLength: 100, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "generation_parameters", + table: "problems", + type: "jsonb", + nullable: false, + defaultValue: "[]" + ); + + migrationBuilder.AddColumn(name: "generation_seed", table: "problems", type: "integer", nullable: true); + + migrationBuilder.AddColumn( + name: "generation_target_case_count", + table: "problems", + type: "integer", + nullable: true + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260927031209_AddProblemTrack.Designer.cs b/StackDuel.Infrastructure/Migrations/20260927031209_AddProblemTrack.Designer.cs new file mode 100644 index 0000000..f73c0c4 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260927031209_AddProblemTrack.Designer.cs @@ -0,0 +1,3266 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260927031209_AddProblemTrack")] + partial class AddProblemTrack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260927031209_AddProblemTrack.cs b/StackDuel.Infrastructure/Migrations/20260927031209_AddProblemTrack.cs new file mode 100644 index 0000000..f8dda73 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260927031209_AddProblemTrack.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddProblemTrack : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "track_id", table: "problems", type: "uuid", nullable: true); + + migrationBuilder.CreateIndex(name: "IX_problems_track_id", table: "problems", column: "track_id"); + + migrationBuilder.AddForeignKey( + name: "FK_problems_tracks_track_id", + table: "problems", + column: "track_id", + principalTable: "tracks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey(name: "FK_problems_tracks_track_id", table: "problems"); + + migrationBuilder.DropIndex(name: "IX_problems_track_id", table: "problems"); + + migrationBuilder.DropColumn(name: "track_id", table: "problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260927202110_AddDailyChallenges.Designer.cs b/StackDuel.Infrastructure/Migrations/20260927202110_AddDailyChallenges.Designer.cs new file mode 100644 index 0000000..0f55a84 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260927202110_AddDailyChallenges.Designer.cs @@ -0,0 +1,3300 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260927202110_AddDailyChallenges")] + partial class AddDailyChallenges + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260927202110_AddDailyChallenges.cs b/StackDuel.Infrastructure/Migrations/20260927202110_AddDailyChallenges.cs new file mode 100644 index 0000000..08b26f0 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260927202110_AddDailyChallenges.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddDailyChallenges : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "daily_challenges", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + challenge_date = table.Column(type: "date", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_daily_challenges", x => x.id); + table.ForeignKey( + name: "FK_daily_challenges_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_daily_challenges_challenge_date", + table: "daily_challenges", + column: "challenge_date", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_daily_challenges_problem_id", + table: "daily_challenges", + column: "problem_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "daily_challenges"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260928032526_AddAddedAtToProblemPoolItems.Designer.cs b/StackDuel.Infrastructure/Migrations/20260928032526_AddAddedAtToProblemPoolItems.Designer.cs new file mode 100644 index 0000000..f94494e --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260928032526_AddAddedAtToProblemPoolItems.Designer.cs @@ -0,0 +1,3306 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260928032526_AddAddedAtToProblemPoolItems")] + partial class AddAddedAtToProblemPoolItems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260928032526_AddAddedAtToProblemPoolItems.cs b/StackDuel.Infrastructure/Migrations/20260928032526_AddAddedAtToProblemPoolItems.cs new file mode 100644 index 0000000..2d895da --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260928032526_AddAddedAtToProblemPoolItems.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddAddedAtToProblemPoolItems : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "added_at", + table: "problem_pool_problems", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "added_at", table: "problem_pool_problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20260930041130_AddGameProblemSequence.Designer.cs b/StackDuel.Infrastructure/Migrations/20260930041130_AddGameProblemSequence.Designer.cs new file mode 100644 index 0000000..4f39c79 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260930041130_AddGameProblemSequence.Designer.cs @@ -0,0 +1,3352 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20260930041130_AddGameProblemSequence")] + partial class AddGameProblemSequence + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("game_id", "Position") + .IsUnique(); + + b.ToTable("game_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("ProblemSequence") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("ProblemSequence"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20260930041130_AddGameProblemSequence.cs b/StackDuel.Infrastructure/Migrations/20260930041130_AddGameProblemSequence.cs new file mode 100644 index 0000000..25e7d24 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20260930041130_AddGameProblemSequence.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddGameProblemSequence : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "game_problems", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + position = table.Column(type: "integer", nullable: false), + problem_id = table.Column(type: "uuid", nullable: false), + game_id = table.Column(type: "uuid", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_game_problems", x => x.id); + table.ForeignKey( + name: "FK_game_problems_games_game_id", + column: x => x.game_id, + principalTable: "games", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_game_problems_problems_problem_id", + column: x => x.problem_id, + principalTable: "problems", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_game_problems_game_id_position", + table: "game_problems", + columns: new[] { "game_id", "position" }, + unique: true + ); + + migrationBuilder.CreateIndex(name: "IX_game_problems_problem_id", table: "game_problems", column: "problem_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "game_problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20261002023615_AddTenantToUsers.Designer.cs b/StackDuel.Infrastructure/Migrations/20261002023615_AddTenantToUsers.Designer.cs new file mode 100644 index 0000000..b4a9e19 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261002023615_AddTenantToUsers.Designer.cs @@ -0,0 +1,3357 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20261002023615_AddTenantToUsers")] + partial class AddTenantToUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("game_id", "Position") + .IsUnique(); + + b.ToTable("game_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Tenant") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("tenant"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("ProblemSequence") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("ProblemSequence"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20261002023615_AddTenantToUsers.cs b/StackDuel.Infrastructure/Migrations/20261002023615_AddTenantToUsers.cs new file mode 100644 index 0000000..b96129d --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261002023615_AddTenantToUsers.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddTenantToUsers : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "tenant", + table: "users", + type: "character varying(64)", + maxLength: 64, + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "tenant", table: "users"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20261002033625_AddPositionToProblemPoolItems.Designer.cs b/StackDuel.Infrastructure/Migrations/20261002033625_AddPositionToProblemPoolItems.Designer.cs new file mode 100644 index 0000000..b7a2c56 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261002033625_AddPositionToProblemPoolItems.Designer.cs @@ -0,0 +1,3361 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20261002033625_AddPositionToProblemPoolItems")] + partial class AddPositionToProblemPoolItems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("game_id", "Position") + .IsUnique(); + + b.ToTable("game_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Tenant") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("tenant"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("ProblemSequence") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("ProblemSequence"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20261002033625_AddPositionToProblemPoolItems.cs b/StackDuel.Infrastructure/Migrations/20261002033625_AddPositionToProblemPoolItems.cs new file mode 100644 index 0000000..4765848 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261002033625_AddPositionToProblemPoolItems.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddPositionToProblemPoolItems : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "position", + table: "problem_pool_problems", + type: "integer", + nullable: false, + defaultValue: 0 + ); + + // Backfill existing rows so current membership order (by insertion time) is preserved + // instead of every row defaulting to position 0. + migrationBuilder.Sql( + """ + UPDATE problem_pool_problems AS ppp + SET position = ranked.position + FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY pool_id ORDER BY added_at) - 1 AS position + FROM problem_pool_problems + ) AS ranked + WHERE ppp.id = ranked.id; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "position", table: "problem_pool_problems"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20261003023818_FixTrackIdsToRfc4122Guids.Designer.cs b/StackDuel.Infrastructure/Migrations/20261003023818_FixTrackIdsToRfc4122Guids.Designer.cs new file mode 100644 index 0000000..f66c71d --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261003023818_FixTrackIdsToRfc4122Guids.Designer.cs @@ -0,0 +1,3361 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20261003023818_FixTrackIdsToRfc4122Guids")] + partial class FixTrackIdsToRfc4122Guids + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("game_id", "Position") + .IsUnique(); + + b.ToTable("game_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Tenant") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("tenant"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("ProblemSequence") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("ProblemSequence"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20261003023818_FixTrackIdsToRfc4122Guids.cs b/StackDuel.Infrastructure/Migrations/20261003023818_FixTrackIdsToRfc4122Guids.cs new file mode 100644 index 0000000..b4910dd --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261003023818_FixTrackIdsToRfc4122Guids.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class FixTrackIdsToRfc4122Guids : Migration +{ + private const string OldGeneralPurposeId = "11111111-1111-1111-1111-111111111111"; + private const string OldSqlId = "22222222-2222-2222-2222-222222222222"; + private const string OldFrontendId = "33333333-3333-3333-3333-333333333333"; + + private const string NewGeneralPurposeId = "c4ca3593-673d-4440-94ed-a59410479c4c"; + private const string NewSqlId = "b33287e4-153d-4f8a-a3e3-8cbfbcea1454"; + private const string NewFrontendId = "06a25f31-197f-4931-87f3-686e3b4bc153"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + RenumberTracks(migrationBuilder, fromGeneralPurpose: OldGeneralPurposeId, fromSql: OldSqlId, fromFrontend: OldFrontendId, toGeneralPurpose: NewGeneralPurposeId, toSql: NewSqlId, toFrontend: NewFrontendId); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + RenumberTracks(migrationBuilder, fromGeneralPurpose: NewGeneralPurposeId, fromSql: NewSqlId, fromFrontend: NewFrontendId, toGeneralPurpose: OldGeneralPurposeId, toSql: OldSqlId, toFrontend: OldFrontendId); + } + + // The three FKs on tracks.id are RESTRICT and non-deferrable, so Postgres checks + // each one immediately after the statement that touches it — updating tracks.id + // directly would fail while children still point at the old value. Dropping the + // FKs for the duration of the data fix avoids that. + private static void RenumberTracks( + MigrationBuilder migrationBuilder, + string fromGeneralPurpose, + string fromSql, + string fromFrontend, + string toGeneralPurpose, + string toSql, + string toFrontend + ) + { + migrationBuilder.DropForeignKey(name: "FK_languages_tracks_track_id", table: "languages"); + migrationBuilder.DropForeignKey(name: "FK_game_tracks_tracks_track_id", table: "game_tracks"); + migrationBuilder.DropForeignKey(name: "FK_problems_tracks_track_id", table: "problems"); + + foreach ((string from, string to) in new[] + { + (fromGeneralPurpose, toGeneralPurpose), + (fromSql, toSql), + (fromFrontend, toFrontend), + }) + { + migrationBuilder.Sql($"UPDATE tracks SET id = '{to}' WHERE id = '{from}';"); + migrationBuilder.Sql($"UPDATE languages SET track_id = '{to}' WHERE track_id = '{from}';"); + migrationBuilder.Sql($"UPDATE game_tracks SET track_id = '{to}' WHERE track_id = '{from}';"); + migrationBuilder.Sql($"UPDATE problems SET track_id = '{to}' WHERE track_id = '{from}';"); + } + + migrationBuilder.AddForeignKey( + name: "FK_languages_tracks_track_id", + table: "languages", + column: "track_id", + principalTable: "tracks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_game_tracks_tracks_track_id", + table: "game_tracks", + column: "track_id", + principalTable: "tracks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + + migrationBuilder.AddForeignKey( + name: "FK_problems_tracks_track_id", + table: "problems", + column: "track_id", + principalTable: "tracks", + principalColumn: "id", + onDelete: ReferentialAction.Restrict + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/20261003042707_AddLanguageVersionExecutionMode.Designer.cs b/StackDuel.Infrastructure/Migrations/20261003042707_AddLanguageVersionExecutionMode.Designer.cs new file mode 100644 index 0000000..d2deab6 --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261003042707_AddLanguageVersionExecutionMode.Designer.cs @@ -0,0 +1,3367 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + [Migration("20261003042707_AddLanguageVersionExecutionMode")] + partial class AddLanguageVersionExecutionMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("game_id", "Position") + .IsUnique(); + + b.ToTable("game_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExecutionMode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("execution_mode"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Tenant") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("tenant"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("ProblemSequence") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("ProblemSequence"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Migrations/20261003042707_AddLanguageVersionExecutionMode.cs b/StackDuel.Infrastructure/Migrations/20261003042707_AddLanguageVersionExecutionMode.cs new file mode 100644 index 0000000..36646bd --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/20261003042707_AddLanguageVersionExecutionMode.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations; + +/// +public partial class AddLanguageVersionExecutionMode : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "execution_mode", + table: "language_versions", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "execution_mode", + table: "language_versions"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Migrations/StackDuelDbContextModelSnapshot.cs b/StackDuel.Infrastructure/Migrations/StackDuelDbContextModelSnapshot.cs new file mode 100644 index 0000000..d1f7dfb --- /dev/null +++ b/StackDuel.Infrastructure/Migrations/StackDuelDbContextModelSnapshot.cs @@ -0,0 +1,3364 @@ +// +using System; +using StackDuel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace StackDuel.Infrastructure.Migrations +{ + [DbContext(typeof(StackDuelDbContext))] + partial class StackDuelDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.AchievementDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("integer") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CriteriaStat") + .HasColumnType("integer") + .HasColumnName("criteria_stat"); + + b.Property("CriteriaThreshold") + .HasColumnType("integer") + .HasColumnName("criteria_threshold"); + + b.Property("CriteriaType") + .HasColumnType("integer") + .HasColumnName("criteria_type"); + + b.Property("CustomRuleKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("custom_rule_key"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon_key"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSecret") + .HasColumnType("boolean") + .HasColumnName("is_secret"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("achievement_definitions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementDefinitionId") + .HasColumnType("uuid") + .HasColumnName("achievement_definition_id"); + + b.Property("EarnedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("earned_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementDefinitionId"); + + b.HasIndex("UserId", "AchievementDefinitionId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("user_achievement_stats_id") + .HasColumnType("uuid") + .HasColumnName("user_achievement_stats_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("user_achievement_stats_id", "LanguageId") + .IsUnique(); + + b.ToTable("user_achievement_stat_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AcceptedSolveCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("accepted_solve_count"); + + b.Property("BugReportsSubmitted") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bug_reports_submitted"); + + b.Property("CurrentSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_solve_streak"); + + b.Property("CurrentWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_win_streak"); + + b.Property("GamesDrawn") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_drawn"); + + b.Property("GamesLost") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_lost"); + + b.Property("GamesPlayed") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_played"); + + b.Property("GamesWon") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("games_won"); + + b.Property("LastSolveDateUtc") + .HasColumnType("date") + .HasColumnName("last_solve_date_utc"); + + b.Property("LongestSolveStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_solve_streak"); + + b.Property("LongestWinStreak") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("longest_win_streak"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("_solvedDifficultyMask") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("solved_difficulty_mask"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_achievement_stats", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Audit.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("ActorUsername") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("actor_username"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DetailsJson") + .HasColumnType("jsonb") + .HasColumnName("details_json"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_type"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_log_entries_actor_user_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_log_entries_created_at"); + + b.ToTable("audit_log_entries", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Group", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.Property("PermissionId") + .HasColumnType("uuid") + .HasColumnName("permission_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("PermissionId", "role_id"); + + b.HasIndex("PermissionId") + .HasDatabaseName("IX_role_permissions_permission_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_role_permissions_role_id"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Security.Entities.SecurityRestriction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("DetectionEventId") + .HasColumnType("uuid"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PermissionCode"); + + b.ToTable("security_restrictions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("IconKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("icon_key"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("EnrolledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("UserId", "CampaignId") + .IsUnique(); + + b.ToTable("campaign_enrollments", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_id", "SortOrder"); + + b.ToTable("campaign_modules", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("RequiredCampaignId") + .HasColumnType("uuid") + .HasColumnName("required_campaign_id"); + + b.Property("campaign_id") + .HasColumnType("uuid") + .HasColumnName("campaign_id"); + + b.HasKey("Id"); + + b.HasIndex("RequiredCampaignId"); + + b.HasIndex("campaign_id", "RequiredCampaignId") + .IsUnique(); + + b.ToTable("campaign_prerequisites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignModuleId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)") + .HasColumnName("content"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasColumnName("estimated_minutes"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UnitType") + .HasColumnType("integer") + .HasColumnName("unit_type"); + + b.Property("campaign_module_id") + .HasColumnType("uuid") + .HasColumnName("campaign_module_id"); + + b.HasKey("Id"); + + b.HasIndex("campaign_module_id", "SortOrder"); + + b.ToTable("campaign_units", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CampaignUnitId"); + + b.HasIndex("UserId", "CampaignUnitId") + .IsUnique(); + + b.ToTable("unit_completions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CampaignUnitId") + .HasColumnType("uuid"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("campaign_unit_id") + .HasColumnType("uuid") + .HasColumnName("campaign_unit_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("campaign_unit_id", "ProblemId") + .IsUnique(); + + b.ToTable("unit_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChallengeDate") + .HasColumnType("date") + .HasColumnName("challenge_date"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeDate") + .IsUnique(); + + b.HasIndex("ProblemId"); + + b.ToTable("daily_challenges", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("additional_file_bundles", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IsPolling") + .HasColumnType("boolean") + .HasColumnName("is_polling"); + + b.Property("MaxAttempts") + .HasColumnType("integer") + .HasColumnName("max_attempts"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("StepOrder") + .HasColumnType("integer") + .HasColumnName("step_order"); + + b.Property("StepType") + .HasColumnType("integer") + .HasColumnName("step_type"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("pipeline_id") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.HasKey("Id"); + + b.HasIndex("pipeline_id"); + + b.ToTable("execution_pipeline_steps", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.ToTable("execution_pipelines", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean") + .HasColumnName("default_enabled"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RolloutPercentage") + .HasColumnType("integer") + .HasColumnName("rollout_percentage"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("GroupId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + + b.ToTable("feature_flag_group_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("feature_flag_id") + .HasColumnType("uuid") + .HasColumnName("feature_flag_id"); + + b.Property("Effect") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effect"); + + b.HasKey("UserId", "feature_flag_id"); + + b.HasIndex("feature_flag_id") + .HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + + b.ToTable("feature_flag_user_overrides", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("admin_note"); + + b.Property("ContextEntityId") + .HasColumnType("uuid") + .HasColumnName("context_entity_id"); + + b.Property("ContextType") + .HasColumnType("integer") + .HasColumnName("context_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("PageUrl") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("page_url"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("join_code"); + + b.Property("LobbyId") + .HasColumnType("uuid") + .HasColumnName("lobby_id"); + + b.Property("PoolId") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.Property("ScheduledExpirySequenceNumber") + .HasColumnType("bigint"); + + b.Property("SkipsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("skips_enabled"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.HasIndex("PoolId"); + + b.ToTable("games", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPoolId") + .HasColumnType("uuid") + .HasColumnName("default_pool_id"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsBuiltIn") + .HasColumnType("boolean") + .HasColumnName("is_built_in"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("MinPlayers") + .HasColumnType("integer") + .HasColumnName("min_players"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("DefaultPoolId"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("game_modes", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("game_mode_id") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.HasKey("Id"); + + b.HasIndex("game_mode_id", "DurationSeconds") + .IsUnique(); + + b.ToTable("game_mode_time_options", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ForfeitedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("forfeited_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("ProblemSession") + .HasColumnType("jsonb") + .HasColumnName("problem_session"); + + b.Property("Score") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("score"); + + b.Property("SeatNo") + .HasColumnType("integer") + .HasColumnName("seat_no"); + + b.Property("SkipsRemaining") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("skips_remaining"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("game_id", "SeatNo") + .IsUnique(); + + b.HasIndex("game_id", "UserId") + .IsUnique(); + + b.ToTable("game_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("game_id", "Position") + .IsUnique(); + + b.ToTable("game_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("game_id") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("game_id", "TrackId") + .IsUnique(); + + b.ToTable("game_tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LanguageId") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("game_track_id") + .HasColumnType("uuid") + .HasColumnName("game_track_id"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("game_track_id", "LanguageId") + .IsUnique(); + + b.ToTable("game_track_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("GameModeId") + .HasColumnType("uuid") + .HasColumnName("game_mode_id"); + + b.Property("TimeLimitInSeconds") + .HasColumnType("integer") + .HasColumnName("time_limit_in_seconds"); + + b.HasKey("Id"); + + b.HasIndex("GameModeId", "TimeLimitInSeconds") + .IsUnique(); + + b.ToTable("leaderboards", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("high_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("leaderboard_id") + .HasColumnType("uuid") + .HasColumnName("leaderboard_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("leaderboard_id", "UserId") + .IsUnique(); + + b.ToTable("leaderboard_participants", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.ToTable("languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExecutionMode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("execution_mode"); + + b.Property("Judge0Id") + .HasColumnType("integer") + .HasColumnName("judge0_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("version"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.HasKey("Id"); + + b.HasIndex("language_id"); + + b.ToTable("language_versions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.ProblemValidation.Entities.ProblemValidationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseGenerationJobIds") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("test_case_generation_job_ids"); + + b.HasKey("Id"); + + b.HasIndex("Status", "LockedUntil"); + + b.ToTable("problem_validation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("TrackId") + .HasColumnType("uuid") + .HasColumnName("track_id"); + + b.Property("ValidationFailureReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("validation_failure_reason"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("TrackId"); + + b.ToTable("problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("MemoryLimit") + .HasColumnType("integer") + .HasColumnName("memory_limit"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("question"); + + b.Property("TimeLimit") + .HasColumnType("integer") + .HasColumnName("time_limit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_history", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_pools", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("pool_id") + .HasColumnType("uuid") + .HasColumnName("pool_id"); + + b.HasKey("Id"); + + b.HasIndex("ProblemId"); + + b.HasIndex("pool_id", "ProblemId") + .IsUnique(); + + b.ToTable("problem_pool_problems", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ProblemId") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.Property("ReactionTypeId") + .HasColumnType("uuid") + .HasColumnName("reaction_type_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReactionTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("ProblemId", "UserId") + .IsUnique() + .HasDatabaseName("ix_problem_reactions_active_per_user") + .HasFilter("is_active = true"); + + b.HasIndex("ProblemId", "UserId", "ReactionTypeId") + .IsUnique(); + + b.ToTable("problem_reactions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Emoji") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("emoji"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("problem_reaction_types", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFileBundleId") + .HasColumnType("uuid") + .HasColumnName("additional_file_bundle_id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("FunctionName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("function_name"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("initial_code"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("ReferenceSolutionCode") + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("problem_id") + .HasColumnType("uuid") + .HasColumnName("problem_id"); + + b.HasKey("Id"); + + b.HasIndex("AdditionalFileBundleId"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("LanguageVersionId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("problem_id"); + + b.ToTable("problem_setups", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.RequiredLanguages.Entities.RequiredProblemLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("LanguageVersionId") + .HasColumnType("uuid") + .HasColumnName("language_version_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("LanguageVersionId") + .IsUnique(); + + b.ToTable("required_problem_languages", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("RequestPayload") + .HasColumnType("text") + .HasColumnName("request_payload"); + + b.Property("ResponsePayload") + .HasColumnType("text") + .HasColumnName("response_payload"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("job_id") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId"); + + b.HasIndex("job_id"); + + b.ToTable("submission_job_attempts", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStepId") + .HasColumnType("uuid") + .HasColumnName("current_step_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("PipelineId") + .HasColumnType("uuid") + .HasColumnName("pipeline_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubmissionId") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("CurrentStepId"); + + b.HasIndex("PipelineId"); + + b.HasIndex("SubmissionId") + .IsUnique(); + + b.HasIndex("Status", "LockedUntil", "CreatedAt"); + + b.ToTable("submission_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdditionalFiles") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_files"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GameId") + .HasColumnType("uuid") + .HasColumnName("game_id"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("SourceCode") + .IsRequired() + .HasMaxLength(65536) + .HasColumnType("character varying(65536)") + .HasColumnName("source_code"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("ProblemSetupId"); + + b.HasIndex("UserId"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActualOutput") + .HasColumnType("text") + .HasColumnName("actual_output"); + + b.Property("CompileOutput") + .HasColumnType("text") + .HasColumnName("compile_output"); + + b.Property("MemoryUsed") + .HasColumnType("integer") + .HasColumnName("memory_used"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("StandardError") + .HasColumnType("text") + .HasColumnName("standard_error"); + + b.Property("StandardOutput") + .HasColumnType("text") + .HasColumnName("standard_output"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TestCaseId") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.Property("evaluation_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("evaluation_id"); + + b.Property("execution_id") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("execution_id"); + + b.Property("submission_id") + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.HasKey("Id"); + + b.HasIndex("TestCaseId"); + + b.HasIndex("submission_id"); + + b.ToTable("submission_results", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("ReferenceSolutionCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference_solution_code"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("test_case_generation_jobs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b.Property("ProblemSetupId") + .HasColumnType("uuid") + .HasColumnName("problem_setup_id"); + + b.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id"); + + b.ToTable("test_case_generation_specs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("GenerationCaseIndex") + .HasColumnType("integer") + .HasColumnName("generation_case_index"); + + b.Property("GenerationSpecId") + .HasColumnType("uuid") + .HasColumnName("generation_spec_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("Source") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("source"); + + b.Property("test_suite_id") + .HasColumnType("uuid") + .HasColumnName("test_suite_id"); + + b.HasKey("Id"); + + b.HasIndex("GenerationSpecId"); + + b.HasIndex("test_suite_id"); + + b.ToTable("test_cases", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_expected_outputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("value_type"); + + b.Property("test_case_id") + .HasColumnType("uuid") + .HasColumnName("test_case_id"); + + b.HasKey("Id"); + + b.HasIndex("test_case_id"); + + b.ToTable("test_case_inputs", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.ToTable("test_suites", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Tracks.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowsLanguageSelection") + .HasColumnType("boolean") + .HasColumnName("allows_language_selection"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("SetupCompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("setup_completed_at"); + + b.Property("Sub") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("sub"); + + b.Property("Tenant") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("tenant"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("username"); + + b.Property("UsernameLastChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("username_last_changed_at"); + + b.HasKey("Id"); + + b.HasIndex("Sub") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("image_url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("IX_user_avatars_user_id_created_at"); + + b.ToTable("user_avatars", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CaseSensitive") + .HasColumnType("boolean") + .HasColumnName("case_sensitive"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("Strategy") + .HasColumnType("integer") + .HasColumnName("strategy"); + + b.Property("Tolerance") + .HasColumnType("numeric") + .HasColumnName("tolerance"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("assert_step_configurations", (string)null); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DefaultTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("default_timeout_seconds"); + + b.Property("IsEncoded") + .HasColumnType("boolean") + .HasColumnName("is_encoded"); + + b.Property("PipelineStepId") + .HasColumnType("uuid") + .HasColumnName("pipeline_step_id"); + + b.Property("ShouldWait") + .HasColumnType("boolean") + .HasColumnName("should_wait"); + + b.Property("StripWhitespace") + .HasColumnType("boolean") + .HasColumnName("strip_whitespace"); + + b.HasKey("Id"); + + b.HasIndex("PipelineStepId") + .IsUnique(); + + b.ToTable("judge0_step_configurations", (string)null); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.Property("ProblemsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("ProblemsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("problem_tags", (string)null); + }); + + modelBuilder.Entity("group_roles", b => + { + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("role_id") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("group_id", "role_id"); + + b.HasIndex("role_id") + .HasDatabaseName("IX_group_roles_role_id"); + + b.ToTable("group_roles", (string)null); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.Property("problem_setup_id") + .HasColumnType("uuid"); + + b.Property("test_suite_id") + .HasColumnType("uuid"); + + b.HasKey("problem_setup_id", "test_suite_id"); + + b.HasIndex("test_suite_id"); + + b.ToTable("problem_setup_test_suites", (string)null); + }); + + modelBuilder.Entity("user_groups", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("group_id") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.HasKey("user_id", "group_id"); + + b.HasIndex("group_id") + .HasDatabaseName("IX_user_groups_group_id"); + + b.ToTable("user_groups", (string)null); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.Property("user_id") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("language_id") + .HasColumnType("uuid") + .HasColumnName("language_id"); + + b.Property("position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.HasKey("user_id", "language_id"); + + b.HasIndex("language_id") + .HasDatabaseName("IX_user_language_preferences_language_id"); + + b.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + + b.ToTable("user_language_preferences", (string)null); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievement", b => + { + b.HasOne("StackDuel.Domain.Achievements.Entities.AchievementDefinition", null) + .WithMany() + .HasForeignKey("AchievementDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStatLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Achievements.Entities.UserAchievementStats", null) + .WithMany("Languages") + .HasForeignKey("user_achievement_stats_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.RolePermission", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Permission", null) + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany("Permissions") + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignEnrollment", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Modules") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignPrerequisite", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany() + .HasForeignKey("RequiredCampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.Campaign", null) + .WithMany("Prerequisites") + .HasForeignKey("campaign_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignModule", null) + .WithMany("Units") + .HasForeignKey("campaign_module_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitCompletion", b => + { + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany() + .HasForeignKey("CampaignUnitId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.UnitProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Campaigns.Entities.CampaignUnit", null) + .WithMany("Problems") + .HasForeignKey("campaign_unit_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.DailyChallenges.Entities.DailyChallenge", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany("Steps") + .HasForeignKey("pipeline_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagGroupOverride", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("GroupOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlagUserOverride", b => + { + b.HasOne("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", null) + .WithMany("UserOverrides") + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Feedback.Entities.FeedbackSubmission", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany() + .HasForeignKey("DefaultPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameModeTimeOption", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany("TimeOptions") + .HasForeignKey("game_mode_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Participants") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameProblem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("ProblemSequence") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany("Tracks") + .HasForeignKey("game_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrackLanguage", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.GameTrack", null) + .WithMany("Languages") + .HasForeignKey("game_track_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.GameMode", null) + .WithMany() + .HasForeignKey("GameModeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.LeaderboardParticipant", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Games.Entities.Leaderboard", null) + .WithMany("Participants") + .HasForeignKey("leaderboard_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany("Versions") + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.Tracks.Entities.Track", null) + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Difficulty", "Difficulty", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.ProblemGenerationSpec", "GenerationSpec", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("OutputValueType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("output_value_type"); + + b1.Property("Parameters") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("parameters"); + + b1.Property("Seed") + .HasColumnType("integer") + .HasColumnName("seed"); + + b1.Property("TargetCaseCount") + .HasColumnType("integer") + .HasColumnName("target_case_count"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problem_generation_parameters", (string)null); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Slug", "Slug", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("slug"); + + b1.HasKey("ProblemId"); + + b1.HasIndex("Value") + .IsUnique(); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.OwnsOne("StackDuel.Domain.Problems.ValueObjects.Title", "Title", b1 => + { + b1.Property("ProblemId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b1.HasKey("ProblemId"); + + b1.ToTable("problems"); + + b1.WithOwner() + .HasForeignKey("ProblemId"); + }); + + b.Navigation("CreatedBy"); + + b.Navigation("Difficulty") + .IsRequired(); + + b.Navigation("GenerationSpec"); + + b.Navigation("Slug") + .IsRequired(); + + b.Navigation("Title") + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemHistory", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("History") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPoolItem", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemPool", null) + .WithMany("_items") + .HasForeignKey("pool_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemReaction", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemReactionType", null) + .WithMany() + .HasForeignKey("ReactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemSetup", b => + { + b.HasOne("StackDuel.Domain.ExecutionAssets.Entities.AdditionalFileBundle", null) + .WithMany() + .HasForeignKey("AdditionalFileBundleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Languages.Entities.LanguageVersionEntry", null) + .WithMany() + .HasForeignKey("LanguageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany("Setups") + .HasForeignKey("problem_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.Entities.SubmissionJobAttempt", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("PipelineStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.SubmissionJobs.SubmissionJob", null) + .WithMany("Attempts") + .HasForeignKey("job_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithMany() + .HasForeignKey("CurrentStepId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", null) + .WithMany() + .HasForeignKey("PipelineId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithOne() + .HasForeignKey("StackDuel.Domain.SubmissionJobs.SubmissionJob", "SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.HasOne("StackDuel.Domain.Games.Entities.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("ProblemSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.SubmissionResult", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany() + .HasForeignKey("TestCaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Submissions.Entities.Submission", null) + .WithMany("Results") + .HasForeignKey("submission_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.HasOne("StackDuel.Domain.TestCaseGeneration.Entities.TestCaseGenerationSpec", null) + .WithMany() + .HasForeignKey("GenerationSpecId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany("TestCases") + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseExpectedOutput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("ExpectedOutputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCaseInput", b => + { + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestCase", null) + .WithMany("Inputs") + .HasForeignKey("test_case_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Users.Entities.UserAvatar", b => + { + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Assert.AssertStepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", b => + { + b.HasOne("StackDuel.Domain.ExecutionPipelines.Entities.ExecutionPipelineStep", null) + .WithOne() + .HasForeignKey("StackDuel.Infrastructure.ExecutionEngine.Judge0.Judge0StepConfiguration", "PipelineStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProblemProblemTag", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.Problem", null) + .WithMany() + .HasForeignKey("ProblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("group_roles", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Role", null) + .WithMany() + .HasForeignKey("role_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("problem_setup_test_suites", b => + { + b.HasOne("StackDuel.Domain.Problems.Entities.ProblemSetup", null) + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.TestSuites.Entities.TestSuite", null) + .WithMany() + .HasForeignKey("test_suite_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_groups", b => + { + b.HasOne("StackDuel.Domain.Authorization.Rbac.Entities.Group", null) + .WithMany() + .HasForeignKey("group_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("user_language_preferences", b => + { + b.HasOne("StackDuel.Domain.Languages.Entities.Language", null) + .WithMany() + .HasForeignKey("language_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("StackDuel.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("user_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StackDuel.Domain.Achievements.Entities.UserAchievementStats", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Authorization.Rbac.Entities.Role", b => + { + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.Campaign", b => + { + b.Navigation("Modules"); + + b.Navigation("Prerequisites"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignModule", b => + { + b.Navigation("Units"); + }); + + modelBuilder.Entity("StackDuel.Domain.Campaigns.Entities.CampaignUnit", b => + { + b.Navigation("Problems"); + }); + + modelBuilder.Entity("StackDuel.Domain.ExecutionPipelines.ExecutionPipeline", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("StackDuel.Domain.FeatureFlags.Entities.FeatureFlag", b => + { + b.Navigation("GroupOverrides"); + + b.Navigation("UserOverrides"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Game", b => + { + b.Navigation("Participants"); + + b.Navigation("ProblemSequence"); + + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameMode", b => + { + b.Navigation("TimeOptions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.GameTrack", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("StackDuel.Domain.Games.Entities.Leaderboard", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("StackDuel.Domain.Languages.Entities.Language", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.Problem", b => + { + b.Navigation("History"); + + b.Navigation("Setups"); + }); + + modelBuilder.Entity("StackDuel.Domain.Problems.Entities.ProblemPool", b => + { + b.Navigation("_items"); + }); + + modelBuilder.Entity("StackDuel.Domain.SubmissionJobs.SubmissionJob", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("StackDuel.Domain.Submissions.Entities.Submission", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestCase", b => + { + b.Navigation("ExpectedOutputs"); + + b.Navigation("Inputs"); + }); + + modelBuilder.Entity("StackDuel.Domain.TestSuites.Entities.TestSuite", b => + { + b.Navigation("TestCases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Achievements/AchievementDefinitionConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Achievements/AchievementDefinitionConfiguration.cs new file mode 100644 index 0000000..0c08a62 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Achievements/AchievementDefinitionConfiguration.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Achievements.Entities; +using StackDuel.Domain.Achievements.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Achievements; + +internal sealed class AchievementDefinitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("achievement_definitions"); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.Id).HasColumnName("id"); + + builder + .Property(a => a.Code) + .HasColumnName("code") + .HasConversion(code => code.Value, value => new AchievementCode(value)) + .HasMaxLength(AchievementCode.MaxLength) + .IsRequired(); + + builder.HasIndex(a => a.Code).IsUnique(); + + builder.Property(a => a.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(a => a.Description).HasColumnName("description").HasMaxLength(1000).IsRequired(); + + builder.Property(a => a.Category).HasColumnName("category").HasConversion().IsRequired(); + + builder.Property(a => a.Tier).HasColumnName("tier").HasConversion().IsRequired(); + + builder.Property(a => a.IconKey).HasColumnName("icon_key").HasMaxLength(100).IsRequired(); + + builder.Property(a => a.IsSecret).HasColumnName("is_secret").IsRequired(); + + builder.Property(a => a.IsActive).HasColumnName("is_active").IsRequired(); + + builder.Property(a => a.CriteriaType).HasColumnName("criteria_type").HasConversion().IsRequired(); + + builder.Property(a => a.CriteriaStat).HasColumnName("criteria_stat").HasConversion().IsRequired(false); + + builder.Property(a => a.CriteriaThreshold).HasColumnName("criteria_threshold").IsRequired(false); + + builder.Property(a => a.CustomRuleKey).HasColumnName("custom_rule_key").HasMaxLength(100).IsRequired(false); + + builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Achievements/UserAchievementConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Achievements/UserAchievementConfiguration.cs new file mode 100644 index 0000000..362ba0a --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Achievements/UserAchievementConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Achievements.Entities; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Achievements; + +internal sealed class UserAchievementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_achievements"); + + builder.HasKey(ua => ua.Id); + + builder.Property(ua => ua.Id).HasColumnName("id"); + + builder.Property(ua => ua.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(ua => ua.AchievementDefinitionId).HasColumnName("achievement_definition_id").IsRequired(); + + builder.Property(ua => ua.EarnedAt).HasColumnName("earned_at").IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(ua => ua.UserId).IsRequired().OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne() + .WithMany() + .HasForeignKey(ua => ua.AchievementDefinitionId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(ua => new { ua.UserId, ua.AchievementDefinitionId }).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Achievements/UserAchievementStatsConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Achievements/UserAchievementStatsConfiguration.cs new file mode 100644 index 0000000..62fbbcd --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Achievements/UserAchievementStatsConfiguration.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Achievements.Entities; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Achievements; + +internal sealed class UserAchievementStatsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_achievement_stats"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property(s => s.UserId).HasColumnName("user_id").IsRequired(); + + builder.HasIndex(s => s.UserId).IsUnique(); + + builder + .Property(s => s.AcceptedSolveCount) + .HasColumnName("accepted_solve_count") + .IsRequired() + .HasDefaultValue(0); + + builder + .Property("_solvedDifficultyMask") + .HasColumnName("solved_difficulty_mask") + .IsRequired() + .HasDefaultValue(0); + + builder.Ignore(s => s.DistinctDifficultiesSolved); + builder.Ignore(s => s.DistinctLanguagesUsed); + + builder + .Property(s => s.CurrentSolveStreak) + .HasColumnName("current_solve_streak") + .IsRequired() + .HasDefaultValue(0); + + builder + .Property(s => s.LongestSolveStreak) + .HasColumnName("longest_solve_streak") + .IsRequired() + .HasDefaultValue(0); + + builder.Property(s => s.LastSolveDateUtc).HasColumnName("last_solve_date_utc").IsRequired(false); + + builder.Property(s => s.GamesPlayed).HasColumnName("games_played").IsRequired().HasDefaultValue(0); + + builder.Property(s => s.GamesWon).HasColumnName("games_won").IsRequired().HasDefaultValue(0); + + builder.Property(s => s.GamesLost).HasColumnName("games_lost").IsRequired().HasDefaultValue(0); + + builder.Property(s => s.GamesDrawn).HasColumnName("games_drawn").IsRequired().HasDefaultValue(0); + + builder.Property(s => s.CurrentWinStreak).HasColumnName("current_win_streak").IsRequired().HasDefaultValue(0); + + builder.Property(s => s.LongestWinStreak).HasColumnName("longest_win_streak").IsRequired().HasDefaultValue(0); + + builder + .Property(s => s.BugReportsSubmitted) + .HasColumnName("bug_reports_submitted") + .IsRequired() + .HasDefaultValue(0); + + builder.HasOne().WithMany().HasForeignKey(s => s.UserId).IsRequired().OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(s => s.Languages) + .WithOne() + .HasForeignKey("user_achievement_stats_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(s => s.Languages).HasField("_languages").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class UserAchievementStatLanguageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_achievement_stat_languages"); + + builder.HasKey(l => l.Id); + + builder.Property(l => l.Id).HasColumnName("id"); + + builder.Property("user_achievement_stats_id").HasColumnName("user_achievement_stats_id").IsRequired(); + + builder.Property(l => l.LanguageId).HasColumnName("language_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(l => l.LanguageId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("user_achievement_stats_id", nameof(UserAchievementStatLanguage.LanguageId)).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Audit/AuditLogEntryConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Audit/AuditLogEntryConfiguration.cs new file mode 100644 index 0000000..07f6eea --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Audit/AuditLogEntryConfiguration.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Audit.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Audit; + +internal sealed class AuditLogEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("audit_log_entries"); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.Id).HasColumnName("id"); + + builder.Property(a => a.ActorUserId).HasColumnName("actor_user_id"); + + builder.Property(a => a.ActorUsername).HasColumnName("actor_username").HasMaxLength(256).IsRequired(); + + builder.Property(a => a.Action).HasColumnName("action").HasMaxLength(128).IsRequired(); + + builder.Property(a => a.TargetType).HasColumnName("target_type").HasMaxLength(64); + + builder.Property(a => a.TargetId).HasColumnName("target_id").HasMaxLength(128); + + builder.Property(a => a.DetailsJson).HasColumnName("details_json").HasColumnType("jsonb"); + + builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.HasIndex(a => a.CreatedAt).HasDatabaseName("ix_audit_log_entries_created_at"); + + builder.HasIndex(a => a.ActorUserId).HasDatabaseName("ix_audit_log_entries_actor_user_id"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Authorization/RbacConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Authorization/RbacConfiguration.cs new file mode 100644 index 0000000..3b5428b --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Authorization/RbacConfiguration.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Authorization.Rbac.Entities; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Authorization; + +internal class RolesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("roles"); + + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Id) + .HasColumnName("id") + .HasConversion(r => r.Value, v => new RoleId(v)) + .ValueGeneratedNever(); + + builder + .Property(x => x.Name) + .HasColumnName("name") + .HasConversion(n => n.Value, v => new Name(v)) + .HasMaxLength(Name.MaxLength) + .IsRequired(); + + builder.Navigation(r => r.Permissions).HasField("_permissions").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(r => r.Permissions).WithOne().HasForeignKey("role_id").OnDelete(DeleteBehavior.Cascade); + } +} + +internal class PermissionsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("permissions"); + + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Id) + .HasColumnName("id") + .HasConversion(p => p.Value, v => new PermissionId(v)) + .ValueGeneratedNever(); + + builder + .Property(x => x.Code) + .HasColumnName("code") + .HasConversion(c => c.Value, v => new PermissionCode(v)) + .IsRequired(); + + builder.HasIndex(x => x.Code).IsUnique(); + + builder.Property(x => x.Description).HasColumnName("description").HasMaxLength(500).IsRequired(); + } +} + +internal class RolePermissionsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("role_permissions"); + + builder + .Property(x => x.PermissionId) + .HasColumnName("permission_id") + .HasConversion(p => p.Value, v => new PermissionId(v)) + .IsRequired(); + + builder + .Property("role_id") + .HasColumnName("role_id") + .HasConversion(r => r.Value, v => new RoleId(v)) + .IsRequired(); + + builder.HasKey(nameof(RolePermission.PermissionId), "role_id"); + + builder.Property(x => x.Effect).HasColumnName("effect").HasConversion().IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(x => x.PermissionId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => x.PermissionId).HasDatabaseName("IX_role_permissions_permission_id"); + + builder.HasIndex("role_id").HasDatabaseName("IX_role_permissions_role_id"); + } +} + +internal class GroupsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("groups"); + + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Id) + .HasColumnName("id") + .HasConversion(g => g.Value, v => new GroupId(v)) + .ValueGeneratedNever(); + + builder + .Property(x => x.Name) + .HasColumnName("name") + .HasConversion(n => n.Value, v => new Name(v)) + .HasMaxLength(Name.MaxLength) + .IsRequired(); + + builder + .HasMany(g => g.RoleGrants) + .WithMany() + .UsingEntity>( + "group_roles", + right => right.HasOne().WithMany().HasForeignKey("role_id").OnDelete(DeleteBehavior.Cascade), + left => left.HasOne().WithMany().HasForeignKey("group_id").OnDelete(DeleteBehavior.Cascade), + join => + { + join.ToTable("group_roles"); + + join.Property("group_id") + .HasColumnName("group_id") + .HasConversion(g => g.Value, v => new GroupId(v)); + + join.Property("role_id") + .HasColumnName("role_id") + .HasConversion(r => r.Value, v => new RoleId(v)); + + join.HasKey("group_id", "role_id"); + join.HasIndex("role_id").HasDatabaseName("IX_group_roles_role_id"); + } + ); + + builder.Navigation(g => g.RoleGrants).HasField("_roleGrants").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Authorization/SecurityConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Authorization/SecurityConfiguration.cs new file mode 100644 index 0000000..6b689c0 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Authorization/SecurityConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.Authorization.Security.Entities; +using StackDuel.Domain.Authorization.Security.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Authorization; + +internal class SecurityRestrictionsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("security_restrictions"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).ValueGeneratedNever(); + + builder.Property(x => x.UserId).HasConversion(u => u.Value, v => new UserId(v)).IsRequired(); + + builder.Property(x => x.PermissionCode).HasConversion(p => p.Value, v => new PermissionCode(v)).IsRequired(); + + builder.Property(x => x.Effect).HasConversion().IsRequired(); + + builder.Property(x => x.ExpiresAt).IsRequired(); + + builder.Property(x => x.Reason).HasConversion(r => r.Value, v => new Reason(v)).IsRequired(); + + builder + .Property(x => x.DetectionEventId) + .HasConversion(d => d.Value, v => new DetectionEventId(v)) + .IsRequired(); + + builder.HasIndex(x => new { x.UserId, x.PermissionCode }); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Campaigns/CampaignConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Campaigns/CampaignConfiguration.cs new file mode 100644 index 0000000..963ddea --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Campaigns/CampaignConfiguration.cs @@ -0,0 +1,183 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Campaigns; + +internal sealed class CampaignConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("campaigns"); + + builder.HasKey(campaign => campaign.Id); + + builder.Property(campaign => campaign.Id).HasColumnName("id"); + + builder.Property(campaign => campaign.Slug).HasColumnName("slug").HasMaxLength(200).IsRequired(); + + builder.HasIndex(campaign => campaign.Slug).IsUnique(); + + builder.Property(campaign => campaign.Title).HasColumnName("title").HasMaxLength(200).IsRequired(); + + builder.Property(campaign => campaign.Description).HasColumnName("description").HasMaxLength(4000).IsRequired(); + + builder.Property(campaign => campaign.Difficulty).HasColumnName("difficulty").HasConversion().IsRequired(); + + builder.Property(campaign => campaign.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(campaign => campaign.IconKey).HasColumnName("icon_key").HasMaxLength(200).IsRequired(false); + + builder.Property(campaign => campaign.SortOrder).HasColumnName("sort_order").IsRequired(); + + builder.Property(campaign => campaign.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(campaign => campaign.PublishedAt).HasColumnName("published_at").IsRequired(false); + + builder + .HasMany(campaign => campaign.Modules) + .WithOne() + .HasForeignKey("campaign_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(campaign => campaign.Modules) + .HasField("_modules") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(campaign => campaign.Prerequisites) + .WithOne() + .HasForeignKey("campaign_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(campaign => campaign.Prerequisites) + .HasField("_prerequisites") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class CampaignModuleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("campaign_modules"); + + builder.HasKey(module => module.Id); + + builder.Property(module => module.Id).HasColumnName("id"); + + builder.Property("campaign_id").HasColumnName("campaign_id").IsRequired(); + + builder.Property(module => module.Title).HasColumnName("title").HasMaxLength(200).IsRequired(); + + builder.Property(module => module.Description).HasColumnName("description").HasMaxLength(4000).IsRequired(); + + builder.Property(module => module.SortOrder).HasColumnName("sort_order").IsRequired(); + + builder + .HasMany(module => module.Units) + .WithOne() + .HasForeignKey("campaign_module_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(module => module.Units).HasField("_units").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasIndex("campaign_id", nameof(CampaignModule.SortOrder)); + } +} + +internal sealed class CampaignUnitConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("campaign_units"); + + builder.HasKey(unit => unit.Id); + + builder.Property(unit => unit.Id).HasColumnName("id"); + + builder.Property("campaign_module_id").HasColumnName("campaign_module_id").IsRequired(); + + builder.Property(unit => unit.Title).HasColumnName("title").HasMaxLength(200).IsRequired(); + + builder.Property(unit => unit.Content).HasColumnName("content").HasMaxLength(20000).IsRequired(); + + builder.Property(unit => unit.UnitType).HasColumnName("unit_type").HasConversion().IsRequired(); + + builder.Property(unit => unit.EstimatedMinutes).HasColumnName("estimated_minutes").IsRequired(); + + builder.Property(unit => unit.SortOrder).HasColumnName("sort_order").IsRequired(); + + builder + .HasMany(unit => unit.Problems) + .WithOne() + .HasForeignKey("campaign_unit_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(unit => unit.Problems).HasField("_problems").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasIndex("campaign_module_id", nameof(CampaignUnit.SortOrder)); + } +} + +internal sealed class UnitProblemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("unit_problems"); + + builder.HasKey(unitProblem => unitProblem.Id); + + builder.Property(unitProblem => unitProblem.Id).HasColumnName("id"); + + builder.Property("campaign_unit_id").HasColumnName("campaign_unit_id").IsRequired(); + + builder.Property(unitProblem => unitProblem.ProblemId).HasColumnName("problem_id").IsRequired(); + + builder.Property(unitProblem => unitProblem.SortOrder).HasColumnName("sort_order").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(unitProblem => unitProblem.ProblemId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("campaign_unit_id", nameof(UnitProblem.ProblemId)).IsUnique(); + } +} + +internal sealed class CampaignPrerequisiteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("campaign_prerequisites"); + + builder.HasKey(prerequisite => prerequisite.Id); + + builder.Property(prerequisite => prerequisite.Id).HasColumnName("id"); + + builder.Property("campaign_id").HasColumnName("campaign_id").IsRequired(); + + builder + .Property(prerequisite => prerequisite.RequiredCampaignId) + .HasColumnName("required_campaign_id") + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(prerequisite => prerequisite.RequiredCampaignId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("campaign_id", nameof(CampaignPrerequisite.RequiredCampaignId)).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Campaigns/CampaignEnrollmentConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Campaigns/CampaignEnrollmentConfiguration.cs new file mode 100644 index 0000000..2b17363 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Campaigns/CampaignEnrollmentConfiguration.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Campaigns; + +internal sealed class CampaignEnrollmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("campaign_enrollments"); + + builder.HasKey(enrollment => enrollment.Id); + + builder.Property(enrollment => enrollment.Id).HasColumnName("id"); + + builder.Property(enrollment => enrollment.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(enrollment => enrollment.CampaignId).HasColumnName("campaign_id").IsRequired(); + + builder.Property(enrollment => enrollment.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(enrollment => enrollment.EnrolledAt).HasColumnName("enrolled_at").IsRequired(); + + builder.Property(enrollment => enrollment.CompletedAt).HasColumnName("completed_at").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(enrollment => enrollment.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(enrollment => enrollment.CampaignId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(enrollment => new { enrollment.UserId, enrollment.CampaignId }).IsUnique(); + } +} + +internal sealed class UnitCompletionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("unit_completions"); + + builder.HasKey(completion => completion.Id); + + builder.Property(completion => completion.Id).HasColumnName("id"); + + builder.Property(completion => completion.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(completion => completion.CampaignUnitId).HasColumnName("campaign_unit_id").IsRequired(); + + builder.Property(completion => completion.CompletedAt).HasColumnName("completed_at").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(completion => completion.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(completion => completion.CampaignUnitId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(completion => new { completion.UserId, completion.CampaignUnitId }).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/DailyChallenges/DailyChallengeConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/DailyChallenges/DailyChallengeConfiguration.cs new file mode 100644 index 0000000..2eb159b --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/DailyChallenges/DailyChallengeConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.DailyChallenges; + +internal sealed class DailyChallengeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("daily_challenges"); + + builder.HasKey(challenge => challenge.Id); + + builder.Property(challenge => challenge.Id).HasColumnName("id"); + + builder.Property(challenge => challenge.ChallengeDate).HasColumnName("challenge_date").IsRequired(); + + builder.HasIndex(challenge => challenge.ChallengeDate).IsUnique(); + + builder.Property(challenge => challenge.ProblemId).HasColumnName("problem_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(challenge => challenge.ProblemId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Execution/AssertStepConfigurationConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Execution/AssertStepConfigurationConfiguration.cs new file mode 100644 index 0000000..250cfe0 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Execution/AssertStepConfigurationConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Infrastructure.ExecutionEngine.Assert; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Execution; + +internal sealed class AssertStepConfigurationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("assert_step_configurations"); + + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasColumnName("id"); + + builder.Property(c => c.PipelineStepId).HasColumnName("pipeline_step_id").IsRequired(); + + builder.HasIndex(c => c.PipelineStepId).IsUnique(); + + builder + .HasOne() + .WithOne() + .HasForeignKey(c => c.PipelineStepId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(c => c.Strategy).HasColumnName("strategy").HasConversion().IsRequired(); + + builder.Property(c => c.Tolerance).HasColumnName("tolerance"); + builder.Property(c => c.CaseSensitive).HasColumnName("case_sensitive").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Execution/ExecutionPipelineConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Execution/ExecutionPipelineConfiguration.cs new file mode 100644 index 0000000..a756136 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Execution/ExecutionPipelineConfiguration.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.ExecutionPipelines.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Execution; + +internal sealed class ExecutionPipelineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("execution_pipelines"); + + builder.HasKey(p => p.Id); + builder.Property(p => p.Id).HasColumnName("id"); + + builder.Property(p => p.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(p => p.Description).HasColumnName("description").IsRequired(false); + + builder + .HasMany(p => p.Steps) + .WithOne() + .HasForeignKey("pipeline_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(p => p.Steps).HasField("_steps").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class ExecutionPipelineStepConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("execution_pipeline_steps"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property("pipeline_id").HasColumnName("pipeline_id").IsRequired(); + + builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(s => s.StepType).HasColumnName("step_type").HasConversion().IsRequired(); + + builder.Property(s => s.StepOrder).HasColumnName("step_order").IsRequired(); + builder.Property(s => s.MaxAttempts).HasColumnName("max_attempts").IsRequired(); + builder.Property(s => s.TimeoutSeconds).HasColumnName("timeout_seconds").IsRequired(); + builder.Property(s => s.IsPolling).HasColumnName("is_polling").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Execution/Judge0StepConfigurationConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Execution/Judge0StepConfigurationConfiguration.cs new file mode 100644 index 0000000..b764494 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Execution/Judge0StepConfigurationConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Infrastructure.ExecutionEngine.Judge0; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Execution; + +internal sealed class Judge0StepConfigurationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("judge0_step_configurations"); + + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasColumnName("id"); + + builder.Property(c => c.PipelineStepId).HasColumnName("pipeline_step_id").IsRequired(); + + builder.HasIndex(c => c.PipelineStepId).IsUnique(); + + builder + .HasOne() + .WithOne() + .HasForeignKey(c => c.PipelineStepId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(c => c.IsEncoded).HasColumnName("is_encoded").IsRequired(); + builder.Property(c => c.ShouldWait).HasColumnName("should_wait").IsRequired(); + builder.Property(c => c.StripWhitespace).HasColumnName("strip_whitespace").IsRequired(); + builder.Property(c => c.DefaultTimeoutSeconds).HasColumnName("default_timeout_seconds").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/ExecutionAssets/AdditionalFileBundleConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/ExecutionAssets/AdditionalFileBundleConfiguration.cs new file mode 100644 index 0000000..6ed1c8b --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/ExecutionAssets/AdditionalFileBundleConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ExecutionAssets.Entities; +using StackDuel.Domain.ExecutionAssets.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.ExecutionAssets; + +internal sealed class AdditionalFileBundleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("additional_file_bundles"); + + builder.HasKey(b => b.Id); + + builder.Property(b => b.Id).HasColumnName("id"); + + builder + .Property(b => b.Name) + .HasColumnName("name") + .HasMaxLength(AdditionalFileBundleName.MaxLength) + .IsRequired() + .HasConversion(n => n.Value, v => new AdditionalFileBundleName(v)); + + builder.HasIndex(b => b.Name).IsUnique(); + + builder.Property(b => b.Content).HasColumnName("content").IsRequired(); + + builder.Property(b => b.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/FeatureFlags/FeatureFlagConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/FeatureFlags/FeatureFlagConfiguration.cs new file mode 100644 index 0000000..3676d88 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/FeatureFlags/FeatureFlagConfiguration.cs @@ -0,0 +1,125 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Authorization.Rbac.Entities; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.FeatureFlags; + +internal class FeatureFlagsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("feature_flags"); + + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Id) + .HasColumnName("id") + .HasConversion(id => id.Value, v => new FeatureFlagId(v)) + .ValueGeneratedNever(); + + builder + .Property(x => x.Key) + .HasColumnName("key") + .HasConversion(k => k.Value, v => new FeatureFlagKey(v)) + .HasMaxLength(FeatureFlagKey.MaxLength) + .IsRequired(); + + builder.HasIndex(x => x.Key).IsUnique(); + + builder.Property(x => x.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(x => x.Description).HasColumnName("description").HasMaxLength(1000).IsRequired(); + + builder.Property(x => x.DefaultEnabled).HasColumnName("default_enabled").IsRequired(); + + builder + .Property(x => x.RolloutPercentage) + .HasColumnName("rollout_percentage") + .HasConversion(r => r.Value, v => new RolloutPercentage(v)) + .IsRequired(); + + builder.Property(x => x.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(x => x.UpdatedAt).HasColumnName("updated_at").IsRequired(); + + builder + .Navigation(x => x.UserOverrides) + .HasField("_userOverrides") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .Navigation(x => x.GroupOverrides) + .HasField("_groupOverrides") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(x => x.UserOverrides) + .WithOne() + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasMany(x => x.GroupOverrides) + .WithOne() + .HasForeignKey("feature_flag_id") + .OnDelete(DeleteBehavior.Cascade); + } +} + +internal class FeatureFlagUserOverridesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("feature_flag_user_overrides"); + + builder + .Property(x => x.UserId) + .HasColumnName("user_id") + .HasConversion(u => u.Value, v => new UserId(v)) + .IsRequired(); + + builder + .Property("feature_flag_id") + .HasColumnName("feature_flag_id") + .HasConversion(f => f.Value, v => new FeatureFlagId(v)) + .IsRequired(); + + builder.HasKey(nameof(FeatureFlagUserOverride.UserId), "feature_flag_id"); + + builder.Property(x => x.Effect).HasColumnName("effect").HasConversion().IsRequired(); + + builder.HasIndex("feature_flag_id").HasDatabaseName("IX_feature_flag_user_overrides_feature_flag_id"); + } +} + +internal class FeatureFlagGroupOverridesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("feature_flag_group_overrides"); + + builder + .Property(x => x.GroupId) + .HasColumnName("group_id") + .HasConversion(g => g.Value, v => new GroupId(v)) + .IsRequired(); + + builder + .Property("feature_flag_id") + .HasColumnName("feature_flag_id") + .HasConversion(f => f.Value, v => new FeatureFlagId(v)) + .IsRequired(); + + builder.HasKey(nameof(FeatureFlagGroupOverride.GroupId), "feature_flag_id"); + + builder.Property(x => x.Effect).HasColumnName("effect").HasConversion().IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex("feature_flag_id").HasDatabaseName("IX_feature_flag_group_overrides_feature_flag_id"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Feedback/FeedbackSubmissionConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Feedback/FeedbackSubmissionConfiguration.cs new file mode 100644 index 0000000..89d65f0 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Feedback/FeedbackSubmissionConfiguration.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.ValueObjects; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Feedback; + +internal sealed class FeedbackSubmissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("feedback_submissions"); + + builder.HasKey(f => f.Id); + + builder.Property(f => f.Id).HasColumnName("id"); + + builder.Property(f => f.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(f => f.Type).HasColumnName("type").HasConversion().IsRequired(); + + builder.Property(f => f.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder + .Property(f => f.Message) + .HasColumnName("message") + .HasMaxLength(FeedbackMessage.MaxLength) + .HasConversion(m => m.Value, v => new FeedbackMessage(v)) + .IsRequired(); + + builder + .Property(f => f.Rating) + .HasColumnName("rating") + .HasConversion(r => r != null ? (int?)r.Value : null, v => v != null ? new FeedbackRating(v.Value) : null); + + builder.Property(f => f.ContextType).HasColumnName("context_type").HasConversion().IsRequired(); + + builder.Property(f => f.ContextEntityId).HasColumnName("context_entity_id"); + + builder.Property(f => f.AdminNote).HasColumnName("admin_note").HasMaxLength(2000); + + builder.Property(f => f.PageUrl).HasColumnName("page_url").HasMaxLength(2000); + + builder.Property(f => f.UserAgent).HasColumnName("user_agent").HasMaxLength(500); + + builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(f => f.UpdatedAt).HasColumnName("updated_at"); + + builder.HasOne().WithMany().HasForeignKey(f => f.UserId).IsRequired().OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Games/GameConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Games/GameConfiguration.cs new file mode 100644 index 0000000..03d5fa3 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Games/GameConfiguration.cs @@ -0,0 +1,408 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.ValueObjects; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Tracks.Entities; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Games; + +internal sealed class GameConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("games"); + + builder.HasKey(game => game.Id); + + builder.Property(game => game.Id).HasColumnName("id"); + + builder.Property(game => game.LobbyId).HasColumnName("lobby_id").IsRequired(false); + + builder.Property(game => game.GameModeId).HasColumnName("game_mode_id").IsRequired(); + + builder.Property(game => game.PoolId).HasColumnName("pool_id").IsRequired(); + + builder.Property(game => game.JoinCode).HasColumnName("join_code").HasMaxLength(7).IsRequired(); + + builder.HasIndex(game => game.JoinCode).IsUnique(); + + builder.Property(game => game.TimeLimitInSeconds).HasColumnName("time_limit_in_seconds").IsRequired(); + + builder.Property(game => game.SkipsEnabled).HasColumnName("skips_enabled").IsRequired().HasDefaultValue(true); + + builder.Property(game => game.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(game => game.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(game => game.StartedAt).HasColumnName("started_at").IsRequired(false); + + builder.Property(game => game.EndedAt).HasColumnName("ended_at").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(game => game.GameModeId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(game => game.PoolId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasMany(game => game.Participants) + .WithOne() + .HasForeignKey("game_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(game => game.Participants) + .HasField("_participants") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(game => game.Tracks) + .WithOne() + .HasForeignKey("game_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(game => game.Tracks).HasField("_tracks").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(game => game.ProblemSequence) + .WithOne() + .HasForeignKey("game_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(game => game.ProblemSequence) + .HasField("_problemSequence") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class GameProblemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_problems"); + + builder.HasKey(gp => gp.Id); + + builder.Property(gp => gp.Id).HasColumnName("id"); + + builder.Property("game_id").HasColumnName("game_id").IsRequired(); + + builder.Property(gp => gp.Position).HasColumnName("position").IsRequired(); + + builder.Property(gp => gp.ProblemId).HasColumnName("problem_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(gp => gp.ProblemId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + // The concurrency safety net: when two participants race to be the first to reach a new + // position, both build the same next-position GameProblem in memory, but only one + // SaveChangesAsync can win this unique index. The loser's save fails cleanly instead of + // silently persisting two different problems at the same position. + builder.HasIndex("game_id", nameof(GameProblem.Position)).IsUnique(); + } +} + +internal sealed class GameTrackConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_tracks"); + + builder.HasKey(gt => gt.Id); + + builder.Property(gt => gt.Id).HasColumnName("id"); + + builder.Property("game_id").HasColumnName("game_id").IsRequired(); + + builder.Property(gt => gt.TrackId).HasColumnName("track_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(gt => gt.TrackId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("game_id", nameof(GameTrack.TrackId)).IsUnique(); + + builder + .HasMany(gt => gt.Languages) + .WithOne() + .HasForeignKey("game_track_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(gt => gt.Languages).HasField("_languages").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class GameTrackLanguageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_track_languages"); + + builder.HasKey(gtl => gtl.Id); + + builder.Property(gtl => gtl.Id).HasColumnName("id"); + + builder.Property("game_track_id").HasColumnName("game_track_id").IsRequired(); + + builder.Property(gtl => gtl.LanguageId).HasColumnName("language_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(gtl => gtl.LanguageId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("game_track_id", nameof(GameTrackLanguage.LanguageId)).IsUnique(); + } +} + +internal sealed class GameParticipantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_participants"); + + builder.HasKey(participant => participant.Id); + + builder.Property(participant => participant.Id).HasColumnName("id"); + + builder.Property("game_id").HasColumnName("game_id").IsRequired(); + + builder.Property(participant => participant.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(participant => participant.SeatNo).HasColumnName("seat_no").IsRequired(); + + builder.Property(participant => participant.JoinedAt).HasColumnName("joined_at").IsRequired(); + + builder.Property(participant => participant.Score).HasColumnName("score").IsRequired().HasDefaultValue(0); + + builder + .Property(participant => participant.SkipsRemaining) + .HasColumnName("skips_remaining") + .IsRequired() + .HasDefaultValue(GameParticipant.TotalSkips); + + builder.Property(participant => participant.ForfeitedAt).HasColumnName("forfeited_at").IsRequired(false); + + builder.Property(participant => participant.FinishedAt).HasColumnName("finished_at").IsRequired(false); + + builder + .Property(participant => participant.ProblemSession) + .HasColumnName("problem_session") + .HasColumnType("jsonb") + .IsRequired(false) + .HasConversion(session => SerializeProblemSession(session), json => DeserializeProblemSession(json)); + + builder + .HasOne() + .WithMany() + .HasForeignKey(participant => participant.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("game_id", nameof(GameParticipant.UserId)).IsUnique(); + + builder.HasIndex("game_id", nameof(GameParticipant.SeatNo)).IsUnique(); + } + + private static string SerializeProblemSession(GameProblemSession session) + { + if (session == null) + return null; + + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + }; + + return System.Text.Json.JsonSerializer.Serialize( + new + { + session.CurrentProblemId, + SolvedProblemIds = session.SolvedProblemIds.ToList(), + SkippedProblemIds = session.SkippedProblemIds.ToList(), + session.ActiveSubmissionId, + SolvedProblemSubmissions = session + .SolvedProblemSubmissionIds.Select(kvp => new { ProblemId = kvp.Key, SubmissionId = kvp.Value }) + .ToList(), + }, + options + ); + } + + private static GameProblemSession DeserializeProblemSession(string json) + { + if (string.IsNullOrEmpty(json)) + return null; + + try + { + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + }; + + var document = System.Text.Json.JsonDocument.Parse( + json, + new System.Text.Json.JsonDocumentOptions { AllowTrailingCommas = true } + ); + var root = document.RootElement; + + if ( + root.TryGetProperty("currentProblemId", out var problemIdElement) + && problemIdElement.TryGetGuid(out var problemId) + ) + { + var session = new GameProblemSession(problemId); + + foreach (var solvedId in ParseGuidArray(root, "solvedProblemIds")) + session.AddSolvedProblem(solvedId); + + foreach (var skippedId in ParseGuidArray(root, "skippedProblemIds")) + session.AddSkippedProblem(skippedId); + + if ( + root.TryGetProperty("activeSubmissionId", out var activeSubEl) + && activeSubEl.ValueKind != System.Text.Json.JsonValueKind.Null + && activeSubEl.TryGetGuid(out var activeSubId) + ) + { + session.SetActiveSubmission(activeSubId); + } + + if ( + root.TryGetProperty("solvedProblemSubmissions", out var submissionsElement) + && submissionsElement.ValueKind == System.Text.Json.JsonValueKind.Array + ) + { + foreach (var item in submissionsElement.EnumerateArray()) + { + if ( + item.TryGetProperty("problemId", out var problemIdEl) + && problemIdEl.TryGetGuid(out var solvedProblemId) + && item.TryGetProperty("submissionId", out var submissionIdEl) + && submissionIdEl.TryGetGuid(out var solvedSubmissionId) + ) + { + session.RecordSolvedSubmission(solvedProblemId, solvedSubmissionId); + } + } + } + + return session; + } + } + catch + { + return null; + } + + return null; + } + + private static IEnumerable ParseGuidArray(System.Text.Json.JsonElement root, string propertyName) + { + if ( + !root.TryGetProperty(propertyName, out var arrayElement) + || arrayElement.ValueKind != System.Text.Json.JsonValueKind.Array + ) + return []; + + return arrayElement.EnumerateArray().Where(item => item.TryGetGuid(out _)).Select(item => item.GetGuid()); + } +} + +internal sealed class GameModeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_modes"); + + builder.HasKey(mode => mode.Id); + + builder.Property(mode => mode.Id).HasColumnName("id"); + + builder.Property(mode => mode.Key).HasColumnName("key").HasMaxLength(100).IsRequired(); + + builder.HasIndex(mode => mode.Key).IsUnique(); + + builder.Property(mode => mode.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(mode => mode.Description).HasColumnName("description").HasMaxLength(1000).IsRequired(); + + builder.Property(mode => mode.IsBuiltIn).HasColumnName("is_built_in").IsRequired(); + + builder.Property(mode => mode.IsActive).HasColumnName("is_active").IsRequired(); + + builder.Property(mode => mode.MinPlayers).HasColumnName("min_players").IsRequired(); + + builder.Property(mode => mode.MaxPlayers).HasColumnName("max_players").IsRequired(); + + builder.Property(mode => mode.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(mode => mode.DefaultPoolId).HasColumnName("default_pool_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(mode => mode.DefaultPoolId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasMany(mode => mode.TimeOptions) + .WithOne() + .HasForeignKey("game_mode_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(mode => mode.TimeOptions) + .HasField("_timeOptions") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class GameModeTimeOptionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_mode_time_options"); + + builder.HasKey(option => option.Id); + + builder.Property(option => option.Id).HasColumnName("id"); + + builder.Property("game_mode_id").HasColumnName("game_mode_id").IsRequired(); + + builder.Property(option => option.DurationSeconds).HasColumnName("duration_seconds").IsRequired(); + + builder.Property(option => option.IsDefault).HasColumnName("is_default").IsRequired(); + + builder.HasIndex("game_mode_id", nameof(GameModeTimeOption.DurationSeconds)).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Games/LeaderboardConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Games/LeaderboardConfiguration.cs new file mode 100644 index 0000000..9b038f9 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Games/LeaderboardConfiguration.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Games; + +internal sealed class LeaderboardConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("leaderboards"); + + builder.HasKey(leaderboard => leaderboard.Id); + + builder.Property(leaderboard => leaderboard.Id).HasColumnName("id"); + + builder.Property(leaderboard => leaderboard.GameModeId).HasColumnName("game_mode_id").IsRequired(); + + builder + .Property(leaderboard => leaderboard.TimeLimitInSeconds) + .HasColumnName("time_limit_in_seconds") + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(leaderboard => leaderboard.GameModeId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(leaderboard => new { leaderboard.GameModeId, leaderboard.TimeLimitInSeconds }).IsUnique(); + + builder + .HasMany(leaderboard => leaderboard.Participants) + .WithOne() + .HasForeignKey("leaderboard_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(leaderboard => leaderboard.Participants) + .HasField("_participants") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.Ignore(leaderboard => leaderboard.Rankings); + } +} + +internal sealed class LeaderboardParticipantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("leaderboard_participants"); + + builder.HasKey(participant => participant.Id); + + builder.Property(participant => participant.Id).HasColumnName("id"); + + builder.Property("leaderboard_id").HasColumnName("leaderboard_id").IsRequired(); + + builder.Property(participant => participant.UserId).HasColumnName("user_id").IsRequired(); + + builder + .Property(participant => participant.HighScore) + .HasColumnName("high_score") + .IsRequired() + .HasDefaultValue(0); + + builder + .HasOne() + .WithMany() + .HasForeignKey(participant => participant.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex("leaderboard_id", nameof(LeaderboardParticipant.UserId)).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Languages/LanguageConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Languages/LanguageConfiguration.cs new file mode 100644 index 0000000..c4fa0c7 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Languages/LanguageConfiguration.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Languages; + +internal sealed class LanguageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("languages"); + + builder.HasKey(l => l.Id); + + builder.Property(l => l.Id).HasColumnName("id"); + + builder + .Property(l => l.Name) + .HasColumnName("name") + .HasMaxLength(LanguageName.MaxLength) + .IsRequired() + .HasConversion(n => n.Value, v => new LanguageName(v)); + + builder + .Property(l => l.Slug) + .HasColumnName("slug") + .HasMaxLength(LanguageSlug.MaxLength) + .IsRequired() + .HasConversion(s => s.Value, v => new LanguageSlug(v)); + + builder.Property(l => l.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(l => l.TrackId).HasColumnName("track_id").IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(l => l.TrackId).IsRequired().OnDelete(DeleteBehavior.Restrict); + + builder + .HasMany(l => l.Versions) + .WithOne() + .HasForeignKey("language_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(l => l.Versions).HasField("_versions").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Languages/LanguageVersionConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Languages/LanguageVersionConfiguration.cs new file mode 100644 index 0000000..238e52b --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Languages/LanguageVersionConfiguration.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Languages.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Languages; + +internal sealed class LanguageVersionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("language_versions"); + + builder.HasKey(v => v.Id); + + builder.Property(v => v.Id).HasColumnName("id"); + + builder.Property("language_id").HasColumnName("language_id").IsRequired(); + + builder + .Property(v => v.Version) + .HasColumnName("version") + .HasMaxLength(LanguageVersion.MaxLength) + .IsRequired() + .HasConversion(v => v.Value, v => new LanguageVersion(v)); + + builder + .Property(v => v.Judge0Id) + .HasColumnName("judge0_id") + .IsRequired() + .HasConversion(j => j.Value, v => new Judge0Id(v)); + + builder.Property(v => v.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder + .Property(v => v.ExecutionMode) + .HasColumnName("execution_mode") + .HasConversion() + .HasDefaultValue(TestExecutionMode.PerCase) + .IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/ProblemValidation/ProblemValidationJobConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/ProblemValidation/ProblemValidationJobConfiguration.cs new file mode 100644 index 0000000..ac41906 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/ProblemValidation/ProblemValidationJobConfiguration.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ProblemValidation.Entities; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Persistence.Configuration.ProblemValidation; + +internal sealed class ProblemValidationJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_validation_jobs"); + + builder.HasKey(j => j.Id); + + builder.Property(j => j.Id).HasColumnName("id"); + + builder.Property(j => j.ProblemId).HasColumnName("problem_id").IsRequired(); + + builder.Property(j => j.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder + .Property(j => j.TestCaseGenerationJobIds) + .HasColumnName("test_case_generation_job_ids") + .HasColumnType("jsonb") + .HasConversion( + ids => JsonSerializer.Serialize(ids, (JsonSerializerOptions?)null), + json => + JsonSerializer.Deserialize>(json, (JsonSerializerOptions?)null) + ?? new List() + ) + .IsRequired(); + + builder.Property(j => j.ResultSummary).HasColumnName("result_summary").IsRequired(false); + + builder.Property(j => j.FailureReason).HasColumnName("failure_reason").IsRequired(false); + + builder.Property(j => j.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(j => j.CompletedAt).HasColumnName("completed_at").IsRequired(false); + + builder.Property(j => j.LockedUntil).HasColumnName("locked_until").IsRequired(false); + + builder.HasIndex(j => new { j.Status, j.LockedUntil }); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemConfiguration.cs new file mode 100644 index 0000000..5ecd2d9 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemConfiguration.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using StackDuel.Domain.Tracks.Entities; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class ProblemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problems"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id).HasColumnName("id"); + + builder.OwnsOne( + p => p.Slug, + slug => + { + slug.Property(s => s.Value).HasColumnName("slug").HasMaxLength(Slug.MaxLength).IsRequired(); + + slug.HasIndex(s => s.Value).IsUnique(); + } + ); + + builder.OwnsOne( + p => p.Title, + title => + { + title.Property(t => t.Value).HasColumnName("title").HasMaxLength(Title.MaxLength).IsRequired(); + } + ); + + builder + .Property(p => p.Question) + .HasColumnName("question") + .HasMaxLength(Question.MaxLength) + .IsRequired() + .HasConversion(q => q.Value, v => new Question(v)); + + builder.OwnsOne( + p => p.Difficulty, + difficulty => + { + difficulty.Property(d => d.Value).HasColumnName("difficulty").IsRequired(); + } + ); + + builder + .Property(p => p.TimeLimit) + .HasColumnName("time_limit") + .IsRequired() + .HasConversion(t => t.Milliseconds, v => new TimeLimit(v)); + + builder + .Property(p => p.MemoryLimit) + .HasColumnName("memory_limit") + .IsRequired() + .HasConversion(m => m.Megabytes, v => new MemoryLimit(v)); + + builder.Property(p => p.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder + .Property(p => p.ValidationFailureReason) + .HasColumnName("validation_failure_reason") + .HasMaxLength(4000) + .IsRequired(false); + + builder.Property(p => p.TrackId).HasColumnName("track_id").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(p => p.TrackId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder.OwnsOne( + p => p.GenerationSpec, + spec => + { + spec.ToTable("problem_generation_parameters"); + + spec.Property(s => s.Parameters) + .HasColumnName("parameters") + .HasColumnType("jsonb") + .HasConversion( + parameters => JsonSerializer.Serialize(parameters, (JsonSerializerOptions?)null), + json => + JsonSerializer.Deserialize>( + json, + (JsonSerializerOptions?)null + ) ?? new List() + ) + .IsRequired(); + + spec.Property(s => s.OutputValueType).HasColumnName("output_value_type").HasMaxLength(100).IsRequired(); + + spec.Property(s => s.TargetCaseCount).HasColumnName("target_case_count").IsRequired(); + + spec.Property(s => s.Seed).HasColumnName("seed").IsRequired(); + } + ); + + builder.Property(p => p.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder + .HasMany(p => p.History) + .WithOne() + .HasForeignKey("problem_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(p => p.History).HasField("_history").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(p => p.Setups) + .WithOne() + .HasForeignKey("problem_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(p => p.Setups).HasField("_setups").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(p => p.Tags).WithMany(t => t.Problems).UsingEntity(j => j.ToTable("problem_tags")); + + builder.Navigation(p => p.Tags).HasField("_tags").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.Property(p => p.CreatedById).HasColumnName("created_by_id").IsRequired(false); + + builder + .HasOne(p => p.CreatedBy) + .WithMany() + .HasForeignKey(p => p.CreatedById) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemHistoryConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemHistoryConfiguration.cs new file mode 100644 index 0000000..40b3c36 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemHistoryConfiguration.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class ProblemHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_history"); + + builder.HasKey(h => h.Id); + + builder.Property(h => h.Id).HasColumnName("id"); + + builder.Property("problem_id").HasColumnName("problem_id").IsRequired(); + + builder + .Property(h => h.Title) + .HasColumnName("title") + .HasMaxLength(Title.MaxLength) + .IsRequired() + .HasConversion(t => t.Value, v => new Title(v)); + + builder + .Property(h => h.Question) + .HasColumnName("question") + .HasMaxLength(Question.MaxLength) + .IsRequired() + .HasConversion(q => q.Value, v => new Question(v)); + + builder + .Property(h => h.Difficulty) + .HasColumnName("difficulty") + .IsRequired() + .HasConversion(d => d.Value, v => new Difficulty(v)); + + builder + .Property(h => h.TimeLimit) + .HasColumnName("time_limit") + .IsRequired() + .HasConversion(t => t.Milliseconds, v => new TimeLimit(v)); + + builder + .Property(h => h.MemoryLimit) + .HasColumnName("memory_limit") + .IsRequired() + .HasConversion(m => m.Megabytes, v => new MemoryLimit(v)); + + builder.Property(h => h.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemPoolConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemPoolConfiguration.cs new file mode 100644 index 0000000..e09d231 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemPoolConfiguration.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class ProblemPoolConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_pools"); + + builder.HasKey(pool => pool.Id); + + builder.Property(pool => pool.Id).HasColumnName("id"); + + builder.Property(pool => pool.Key).HasColumnName("key").HasMaxLength(ProblemPool.MaxKeyLength).IsRequired(); + + builder.HasIndex(pool => pool.Key).IsUnique(); + + builder.Property(pool => pool.Name).HasColumnName("name").HasMaxLength(ProblemPool.MaxNameLength).IsRequired(); + + builder.Property(pool => pool.Description).HasColumnName("description").IsRequired(false); + + builder.Property(pool => pool.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder + .HasMany("_items") + .WithOne() + .HasForeignKey("pool_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Ignore(pool => pool.ProblemIds); + } +} + +internal sealed class ProblemPoolItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_pool_problems"); + + builder.HasKey(item => item.Id); + + builder.Property(item => item.Id).HasColumnName("id"); + + builder.Property("pool_id").HasColumnName("pool_id").IsRequired(); + + builder.Property(item => item.ProblemId).HasColumnName("problem_id").IsRequired(); + + builder.Property(item => item.Position).HasColumnName("position").IsRequired(); + + builder + .Property(item => item.AddedAt) + .HasColumnName("added_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(item => item.ProblemId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex("pool_id", nameof(ProblemPoolItem.ProblemId)).IsUnique(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemReactionConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemReactionConfiguration.cs new file mode 100644 index 0000000..addd97c --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemReactionConfiguration.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Users.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class ProblemReactionTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_reaction_types"); + + builder.HasKey(type => type.Id); + + builder.Property(type => type.Id).HasColumnName("id"); + + builder.Property(type => type.Key).HasColumnName("key").HasMaxLength(50).IsRequired(); + + builder.HasIndex(type => type.Key).IsUnique(); + + builder.Property(type => type.Name).HasColumnName("name").HasMaxLength(100).IsRequired(); + + builder.Property(type => type.Emoji).HasColumnName("emoji").HasMaxLength(16).IsRequired(false); + + builder.Property(type => type.SortOrder).HasColumnName("sort_order").IsRequired(); + + builder.Property(type => type.IsEnabled).HasColumnName("is_enabled").IsRequired(); + + builder.Property(type => type.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} + +internal sealed class ProblemReactionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_reactions"); + + builder.HasKey(reaction => reaction.Id); + + builder.Property(reaction => reaction.Id).HasColumnName("id"); + + builder.Property(reaction => reaction.ProblemId).HasColumnName("problem_id").IsRequired(); + + builder.Property(reaction => reaction.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(reaction => reaction.ReactionTypeId).HasColumnName("reaction_type_id").IsRequired(); + + builder.Property(reaction => reaction.IsActive).HasColumnName("is_active").IsRequired(); + + builder.Property(reaction => reaction.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(reaction => reaction.UpdatedAt).HasColumnName("updated_at").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(reaction => reaction.ProblemId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne() + .WithMany() + .HasForeignKey(reaction => reaction.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(reaction => reaction.ReactionTypeId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + // Permanent row per (problem, user, reaction type) — reacting with the same kind twice + // never inserts a second row, it's found and its IsActive flag flipped instead. + builder + .HasIndex(reaction => new + { + reaction.ProblemId, + reaction.UserId, + reaction.ReactionTypeId, + }) + .IsUnique(); + + // DB-enforced: a user can have at most one active reaction on a given problem at a time, + // regardless of kind. The handler deactivates any other active row before activating a + // new one so this is never violated mid-request. + builder + .HasIndex(reaction => new { reaction.ProblemId, reaction.UserId }) + .IsUnique() + .HasFilter("is_active = true") + .HasDatabaseName("ix_problem_reactions_active_per_user"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemSetupConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemSetupConfiguration.cs new file mode 100644 index 0000000..5bd0764 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemSetupConfiguration.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ExecutionAssets.Entities; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestSuites.Entities; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class ProblemSetupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("problem_setups"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property("problem_id").HasColumnName("problem_id").IsRequired(); + + builder.Property(s => s.LanguageVersionId).HasColumnName("language_version_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(s => s.LanguageVersionId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(s => s.PipelineId).HasColumnName("pipeline_id").IsRequired(true); + + builder + .HasOne() + .WithMany() + .HasForeignKey(s => s.PipelineId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(s => s.InitialCode).HasColumnName("initial_code").IsRequired(); + + builder.Property(s => s.FunctionName).HasColumnName("function_name").HasMaxLength(200).IsRequired(false); + + builder.Property(s => s.ReferenceSolutionCode).HasColumnName("reference_solution_code").IsRequired(false); + + builder.Property(s => s.GenerationSpecId).HasColumnName("generation_spec_id").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(s => s.GenerationSpecId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(s => s.AdditionalFileBundleId).HasColumnName("additional_file_bundle_id").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(s => s.AdditionalFileBundleId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder + .Property(s => s.AdditionalFiles) + .HasColumnName("additional_files") + .HasConversion( + files => JsonSerializer.Serialize(files, (JsonSerializerOptions?)null), + json => + JsonSerializer.Deserialize>(json, (JsonSerializerOptions?)null) + ?? new List() + ) + .IsRequired(); + + builder + .HasMany(s => s.TestSuites) + .WithMany() + .UsingEntity>( + "problem_setup_test_suites", + r => r.HasOne().WithMany().HasForeignKey("test_suite_id").OnDelete(DeleteBehavior.Cascade), + l => + l.HasOne() + .WithMany() + .HasForeignKey("problem_setup_id") + .OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("problem_setup_id", "test_suite_id"); + j.ToTable("problem_setup_test_suites"); + } + ); + + builder.Navigation(s => s.TestSuites).HasField("_testSuites").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemTagConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemTagConfiguration.cs new file mode 100644 index 0000000..94bc6d0 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/ProblemTagConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class ProblemTagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tags"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id).HasColumnName("id"); + + builder + .Property(t => t.Name) + .HasColumnName("name") + .HasMaxLength(Tag.MaxLength) + .IsRequired() + .HasConversion(t => t.Value, v => new Tag(v)); + + builder.HasIndex(t => t.Name).IsUnique(); + + builder.Navigation(t => t.Problems).HasField("_problems").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Problems/RequiredProblemLanguageConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Problems/RequiredProblemLanguageConfiguration.cs new file mode 100644 index 0000000..d291bb7 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Problems/RequiredProblemLanguageConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Problems; + +internal sealed class RequiredProblemLanguageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("required_problem_languages"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).HasColumnName("id"); + + builder.Property(x => x.LanguageVersionId).HasColumnName("language_version_id").IsRequired(); + + builder.HasIndex(x => x.LanguageVersionId).IsUnique(); + + builder.Property(x => x.SortOrder).HasColumnName("sort_order").IsRequired(); + + builder.Property(x => x.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/SubmissionJobs/SubmissionJobConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/SubmissionJobs/SubmissionJobConfiguration.cs new file mode 100644 index 0000000..7304be3 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/SubmissionJobs/SubmissionJobConfiguration.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.ExecutionPipelines.Entities; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.SubmissionJobs.Entities; +using StackDuel.Domain.Submissions.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.SubmissionJobs; + +internal sealed class SubmissionJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("submission_jobs"); + + builder.HasKey(j => j.Id); + builder.Property(j => j.Id).HasColumnName("id"); + + builder.Property(j => j.SubmissionId).HasColumnName("submission_id").IsRequired(); + builder.HasIndex(j => j.SubmissionId).IsUnique(); + + builder + .HasOne() + .WithOne() + .HasForeignKey(j => j.SubmissionId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(j => j.PipelineId).HasColumnName("pipeline_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(j => j.PipelineId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(j => j.CurrentStepId).HasColumnName("current_step_id").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(j => j.CurrentStepId) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); + + builder.Property(j => j.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(j => j.FailureReason).HasColumnName("failure_reason").IsRequired(false); + + builder.Property(j => j.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(j => j.CompletedAt).HasColumnName("completed_at").IsRequired(false); + + builder.Property(j => j.LockedUntil).HasColumnName("locked_until").IsRequired(false); + + builder.HasIndex(j => new + { + j.Status, + j.LockedUntil, + j.CreatedAt, + }); + + builder + .HasMany(j => j.Attempts) + .WithOne() + .HasForeignKey("job_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(j => j.Attempts).HasField("_attempts").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +internal sealed class SubmissionJobAttemptConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("submission_job_attempts"); + + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).HasColumnName("id"); + + builder.Property("job_id").HasColumnName("job_id").IsRequired(); + + builder.Property(a => a.PipelineStepId).HasColumnName("pipeline_step_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(a => a.PipelineStepId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(a => a.AttemptNumber).HasColumnName("attempt_number").IsRequired(); + + builder.Property(a => a.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(a => a.RequestPayload).HasColumnName("request_payload").IsRequired(false); + builder.Property(a => a.ResponsePayload).HasColumnName("response_payload").IsRequired(false); + builder.Property(a => a.Error).HasColumnName("error").IsRequired(false); + builder.Property(a => a.StartedAt).HasColumnName("started_at").IsRequired(); + builder.Property(a => a.CompletedAt).HasColumnName("completed_at").IsRequired(false); + builder.Property(a => a.DurationMs).HasColumnName("duration_ms").IsRequired(false); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Submissions/SubmissionConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Submissions/SubmissionConfiguration.cs new file mode 100644 index 0000000..253bb00 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Submissions/SubmissionConfiguration.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.ValueObjects; +using StackDuel.Domain.Users.Entities; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Submissions; + +internal sealed class SubmissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("submissions"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property(s => s.UserId).HasColumnName("user_id").IsRequired(); + + builder.Property(s => s.ProblemSetupId).HasColumnName("problem_setup_id").IsRequired(); + + builder.Property(s => s.GameId).HasColumnName("game_id").IsRequired(false); + + builder.Property(s => s.Type).HasColumnName("type").HasConversion().IsRequired(); + + builder.Property(s => s.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder + .Property(s => s.SourceCode) + .HasConversion(v => v.Value, v => new SourceCode(v)) + .HasColumnName("source_code") + .HasMaxLength(SourceCode.MaxLength) + .IsRequired(); + + builder + .Property(s => s.AdditionalFiles) + .HasColumnName("additional_files") + .HasConversion( + files => JsonSerializer.Serialize(files, (JsonSerializerOptions?)null), + json => + JsonSerializer.Deserialize>(json, (JsonSerializerOptions?)null) + ?? new List() + ) + .IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(s => s.UserId).IsRequired().OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(s => s.ProblemSetupId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne() + .WithMany() + .HasForeignKey(s => s.GameId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasMany(s => s.Results) + .WithOne() + .HasForeignKey("submission_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(s => s.Results).HasField("_results").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Submissions/SubmissionResultConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Submissions/SubmissionResultConfiguration.cs new file mode 100644 index 0000000..929e965 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Submissions/SubmissionResultConfiguration.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.TestSuites.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Submissions; + +internal sealed class SubmissionResultConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("submission_results"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id).HasColumnName("id"); + + builder.Property("submission_id").HasColumnName("submission_id").IsRequired(); + + builder.Property(r => r.TestCaseId).HasColumnName("test_case_id").IsRequired(); + + builder + .HasOne() + .WithMany() + .HasForeignKey(r => r.TestCaseId) + .IsRequired() + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(r => r.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(r => r.Runtime).HasColumnName("runtime").IsRequired(false); + + builder.Property(r => r.MemoryUsed).HasColumnName("memory_used").IsRequired(false); + + builder.Property(r => r.ActualOutput).HasColumnName("actual_output").IsRequired(false); + + builder.Property(r => r.StandardOutput).HasColumnName("standard_output").IsRequired(false); + + builder.Property(r => r.StandardError).HasColumnName("standard_error").IsRequired(false); + + builder.Property(r => r.CompileOutput).HasColumnName("compile_output").IsRequired(false); + + builder.Property("execution_id").HasColumnName("execution_id").HasMaxLength(100); + + builder.Property("evaluation_id").HasColumnName("evaluation_id").HasMaxLength(100); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/TestCaseGeneration/TestCaseGenerationJobConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/TestCaseGeneration/TestCaseGenerationJobConfiguration.cs new file mode 100644 index 0000000..b7ebd9f --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/TestCaseGeneration/TestCaseGenerationJobConfiguration.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Persistence.Configuration.TestCaseGeneration; + +internal sealed class TestCaseGenerationJobConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_case_generation_jobs"); + + builder.HasKey(j => j.Id); + + builder.Property(j => j.Id).HasColumnName("id"); + + builder.Property(j => j.ProblemSetupId).HasColumnName("problem_setup_id").IsRequired(); + + builder.Property(j => j.ReferenceSolutionCode).HasColumnName("reference_solution_code").IsRequired(); + + builder + .Property(j => j.Parameters) + .HasColumnName("parameters") + .HasColumnType("jsonb") + .HasConversion( + parameters => JsonSerializer.Serialize(parameters, (JsonSerializerOptions?)null), + json => + JsonSerializer.Deserialize>( + json, + (JsonSerializerOptions?)null + ) ?? new List() + ) + .IsRequired(); + + builder.Property(j => j.OutputValueType).HasColumnName("output_value_type").HasMaxLength(100).IsRequired(); + + builder.Property(j => j.TargetCaseCount).HasColumnName("target_case_count").IsRequired(); + + builder.Property(j => j.Seed).HasColumnName("seed").IsRequired(); + + builder.Property(j => j.Status).HasColumnName("status").HasConversion().IsRequired(); + + builder.Property(j => j.ResultSummary).HasColumnName("result_summary").IsRequired(false); + + builder.Property(j => j.FailureReason).HasColumnName("failure_reason").IsRequired(false); + + builder.Property(j => j.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.Property(j => j.CompletedAt).HasColumnName("completed_at").IsRequired(false); + + builder.Property(j => j.LockedUntil).HasColumnName("locked_until").IsRequired(false); + + builder.HasIndex(j => j.Status); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/TestCaseGeneration/TestCaseGenerationSpecConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/TestCaseGeneration/TestCaseGenerationSpecConfiguration.cs new file mode 100644 index 0000000..25c5296 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/TestCaseGeneration/TestCaseGenerationSpecConfiguration.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using System.Text.Json; + +namespace StackDuel.Infrastructure.Persistence.Configuration.TestCaseGeneration; + +internal sealed class TestCaseGenerationSpecConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_case_generation_specs"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property(s => s.ProblemSetupId).HasColumnName("problem_setup_id").IsRequired(); + + builder + .Property(s => s.Parameters) + .HasColumnName("parameters") + .HasColumnType("jsonb") + .HasConversion( + parameters => JsonSerializer.Serialize(parameters, (JsonSerializerOptions?)null), + json => + JsonSerializer.Deserialize>( + json, + (JsonSerializerOptions?)null + ) ?? new List() + ) + .IsRequired(); + + builder.Property(s => s.OutputValueType).HasColumnName("output_value_type").HasMaxLength(100).IsRequired(); + + builder.Property(s => s.TargetCaseCount).HasColumnName("target_case_count").IsRequired(); + + builder.Property(s => s.Seed).HasColumnName("seed").IsRequired(); + + builder.Property(s => s.Version).HasColumnName("version").IsRequired(); + + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseConfiguration.cs new file mode 100644 index 0000000..5d08200 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseConfiguration.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestSuites.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.TestSuites; + +internal sealed class TestCaseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_cases"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id).HasColumnName("id"); + + builder.Property("test_suite_id").HasColumnName("test_suite_id").IsRequired(); + + builder.Property(t => t.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(t => t.Description).HasColumnName("description").IsRequired(false); + + builder + .Property(t => t.Source) + .HasColumnName("source") + .HasConversion() + .HasDefaultValue(StackDuel.Domain.TestSuites.Enums.TestCaseSource.Authored) + .IsRequired(); + + builder.Property(t => t.GenerationSpecId).HasColumnName("generation_spec_id").IsRequired(false); + + builder.Property(t => t.GenerationCaseIndex).HasColumnName("generation_case_index").IsRequired(false); + + builder.Property(t => t.RetiredAt).HasColumnName("retired_at").IsRequired(false); + + builder + .HasOne() + .WithMany() + .HasForeignKey(t => t.GenerationSpecId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasMany(t => t.Inputs) + .WithOne() + .HasForeignKey("test_case_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(t => t.Inputs).HasField("_inputs").UsePropertyAccessMode(PropertyAccessMode.Field); + + builder + .HasMany(t => t.ExpectedOutputs) + .WithOne() + .HasForeignKey("test_case_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder + .Navigation(t => t.ExpectedOutputs) + .HasField("_expectedOutputs") + .UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseExpectedOutputConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseExpectedOutputConfiguration.cs new file mode 100644 index 0000000..c2109b5 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseExpectedOutputConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.TestSuites.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.TestSuites; + +internal sealed class TestCaseExpectedOutputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_case_expected_outputs"); + + builder.HasKey(o => o.Id); + + builder.Property(o => o.Id).HasColumnName("id"); + + builder.Property("test_case_id").HasColumnName("test_case_id").IsRequired(); + + builder.Property(o => o.Value).HasColumnName("value").IsRequired(); + + builder.Property(o => o.ValueType).HasColumnName("value_type").HasMaxLength(100).IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseInputConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseInputConfiguration.cs new file mode 100644 index 0000000..beb4d66 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestCaseInputConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.TestSuites.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.TestSuites; + +internal sealed class TestCaseInputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_case_inputs"); + + builder.HasKey(i => i.Id); + + builder.Property(i => i.Id).HasColumnName("id"); + + builder.Property("test_case_id").HasColumnName("test_case_id").IsRequired(); + + builder.Property(i => i.Value).HasColumnName("value").IsRequired(); + + builder.Property(i => i.ValueType).HasColumnName("value_type").HasMaxLength(100).IsRequired(); + + builder.Property(i => i.Position).HasColumnName("position").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestSuiteConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestSuiteConfiguration.cs new file mode 100644 index 0000000..0f2412d --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/TestSuites/TestSuiteConfiguration.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.TestSuites.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.TestSuites; + +internal sealed class TestSuiteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("test_suites"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id).HasColumnName("id"); + + builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(s => s.Type).HasColumnName("type").HasConversion().IsRequired(); + + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder + .HasMany(s => s.TestCases) + .WithOne() + .HasForeignKey("test_suite_id") + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Navigation(s => s.TestCases).HasField("_testCases").UsePropertyAccessMode(PropertyAccessMode.Field); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Tracks/TrackConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Tracks/TrackConfiguration.cs new file mode 100644 index 0000000..929c163 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Tracks/TrackConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Tracks; + +internal sealed class TrackConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tracks"); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id).HasColumnName("id"); + + builder.Property(t => t.Key).HasColumnName("key").HasMaxLength(100).IsRequired(); + + builder.HasIndex(t => t.Key).IsUnique(); + + builder.Property(t => t.Name).HasColumnName("name").HasMaxLength(200).IsRequired(); + + builder.Property(t => t.IsActive).HasColumnName("is_active").IsRequired(); + + builder.Property(t => t.AllowsLanguageSelection).HasColumnName("allows_language_selection").IsRequired(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Users/UserAvatarConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Users/UserAvatarConfiguration.cs new file mode 100644 index 0000000..0c922af --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Users/UserAvatarConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Users; + +internal sealed class UserAvatarConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_avatars"); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.Id).HasColumnName("id"); + + builder.Property(a => a.UserId).HasColumnName("user_id").IsRequired(); + + builder + .Property(a => a.ImageUrl) + .HasColumnName("image_url") + .HasMaxLength(ImageUrl.MaxLength) + .IsRequired() + .HasConversion(i => i.Value, v => new ImageUrl(v)); + + builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.HasIndex(a => new { a.UserId, a.CreatedAt }).HasDatabaseName("IX_user_avatars_user_id_created_at"); + + builder + .HasOne() + .WithMany() + .HasForeignKey(a => a.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Configuration/Users/UserConfiguration.cs b/StackDuel.Infrastructure/Persistence/Configuration/Users/UserConfiguration.cs new file mode 100644 index 0000000..bb90732 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Configuration/Users/UserConfiguration.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Domain.Authorization.Rbac.Entities; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Configuration.Users; + +internal sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + builder.HasKey(u => u.Id); + + builder.Property(u => u.Id).HasColumnName("id"); + + builder.Property(u => u.Sub).HasColumnName("sub").HasMaxLength(255).IsRequired(); + + builder.HasIndex(u => u.Sub).IsUnique(); + + builder.Property(u => u.Tenant).HasColumnName("tenant").HasMaxLength(64); + + builder + .Property(u => u.Username) + .HasColumnName("username") + .HasMaxLength(Username.MaxLength) + .IsRequired() + .HasConversion(u => u.Value, v => new Username(v)); + + builder.HasIndex(u => u.Username).IsUnique(); + + builder + .Property(u => u.Bio) + .HasColumnName("bio") + .HasMaxLength(Bio.MaxLength) + .HasConversion(b => b != null ? b.Value : null, v => v != null ? new Bio(v) : null); + + builder + .Property(u => u.ImageUrl) + .HasColumnName("image_url") + .HasMaxLength(ImageUrl.MaxLength) + .HasConversion(i => i != null ? i.Value : null, v => v != null ? new ImageUrl(v) : null); + + builder.Property(u => u.UsernameLastChangedAt).HasColumnName("username_last_changed_at"); + + builder.Property(u => u.CreatedAt).HasColumnName("created_at"); + + builder.Property(u => u.SetupCompletedAt).HasColumnName("setup_completed_at"); + + builder.Property(u => u.IsPrivate).HasColumnName("is_private"); + + builder + .HasMany() + .WithMany() + .UsingEntity>( + "user_groups", + right => right.HasOne().WithMany().HasForeignKey("group_id").OnDelete(DeleteBehavior.Cascade), + left => left.HasOne().WithMany().HasForeignKey("user_id").OnDelete(DeleteBehavior.Cascade), + join => + { + join.ToTable("user_groups"); + + join.Property("user_id").HasColumnName("user_id"); + + join.Property("group_id") + .HasColumnName("group_id") + .HasConversion(g => g.Value, v => new GroupId(v)); + + join.HasKey("user_id", "group_id"); + join.HasIndex("group_id").HasDatabaseName("IX_user_groups_group_id"); + } + ); + + builder + .HasMany() + .WithMany() + .UsingEntity>( + "user_language_preferences", + right => + right.HasOne().WithMany().HasForeignKey("language_id").OnDelete(DeleteBehavior.Cascade), + left => left.HasOne().WithMany().HasForeignKey("user_id").OnDelete(DeleteBehavior.Cascade), + join => + { + join.ToTable("user_language_preferences"); + + join.Property("user_id").HasColumnName("user_id"); + + join.Property("language_id").HasColumnName("language_id"); + + join.Property("position").HasColumnName("position"); + + join.HasKey("user_id", "language_id"); + join.HasIndex("language_id").HasDatabaseName("IX_user_language_preferences_language_id"); + join.HasIndex("user_id", "position") + .IsUnique() + .HasDatabaseName("IX_user_language_preferences_user_id_position"); + } + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/NewChildEntityTracker.cs b/StackDuel.Infrastructure/Persistence/NewChildEntityTracker.cs new file mode 100644 index 0000000..350d417 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/NewChildEntityTracker.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.SeedWork; + +namespace StackDuel.Infrastructure.Persistence; + +internal static class NewChildEntityTracker +{ + public static async Task MarkNewAsAddedAsync( + StackDuelDbContext context, + IEnumerable children, + CancellationToken cancellationToken + ) + where TChild : Entity + { + List childList = [.. children]; + + if (childList.Count == 0) + return; + + HashSet childIds = [.. childList.Select(c => c.Id)]; + + HashSet existingIds = + [ + .. await context + .Set() + .AsNoTracking() + .Where(c => childIds.Contains(c.Id)) + .Select(c => c.Id) + .ToListAsync(cancellationToken), + ]; + + foreach (TChild child in childList.Where(c => !existingIds.Contains(c.Id))) + context.Entry(child).State = EntityState.Added; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/GroupRoleRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/GroupRoleRowConfiguration.cs new file mode 100644 index 0000000..35c9385 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/GroupRoleRowConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class GroupRoleRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("group_roles"); + + builder.HasKey(x => new { x.GroupId, x.RoleId }); + + builder.Property(x => x.GroupId).HasColumnName("group_id"); + builder.Property(x => x.RoleId).HasColumnName("role_id"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/GroupRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/GroupRowConfiguration.cs new file mode 100644 index 0000000..b766080 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/GroupRowConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class GroupRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("groups"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.Name).HasColumnName("name"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/PermissionRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/PermissionRowConfiguration.cs new file mode 100644 index 0000000..ed56a6d --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/PermissionRowConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class PermissionRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("permissions"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.Code).HasColumnName("code"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/RolePermissionRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/RolePermissionRowConfiguration.cs new file mode 100644 index 0000000..feb552f --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/RolePermissionRowConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class RolePermissionRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("role_permissions"); + + builder.HasKey(x => new { x.RoleId, x.PermissionId }); + + builder.Property(x => x.RoleId).HasColumnName("role_id"); + builder.Property(x => x.PermissionId).HasColumnName("permission_id"); + builder.Property(x => x.Effect).HasColumnName("effect").HasConversion(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/RoleRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/RoleRowConfiguration.cs new file mode 100644 index 0000000..b307013 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/RoleRowConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class RoleRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("roles"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.Name).HasColumnName("name"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/SecurityRestrictionRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/SecurityRestrictionRowConfiguration.cs new file mode 100644 index 0000000..bbcb769 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/SecurityRestrictionRowConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class SecurityRestrictionRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("security_restrictions"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).HasColumnName("Id"); + builder.Property(x => x.UserId).HasColumnName("UserId"); + builder.Property(x => x.PermissionCode).HasColumnName("PermissionCode"); + builder.Property(x => x.Effect).HasColumnName("Effect").HasConversion(); + builder.Property(x => x.ExpiresAt).HasColumnName("ExpiresAt"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/UserGroupRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/UserGroupRowConfiguration.cs new file mode 100644 index 0000000..97b1df0 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/UserGroupRowConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class UserGroupRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_groups"); + + builder.HasKey(x => new { x.UserId, x.GroupId }); + + builder.Property(x => x.UserId).HasColumnName("user_id"); + builder.Property(x => x.GroupId).HasColumnName("group_id"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/UserLanguagePreferenceRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/UserLanguagePreferenceRowConfiguration.cs new file mode 100644 index 0000000..951e268 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/UserLanguagePreferenceRowConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class UserLanguagePreferenceRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_language_preferences"); + + builder.HasKey(x => new { x.UserId, x.LanguageId }); + + builder.Property(x => x.UserId).HasColumnName("user_id"); + builder.Property(x => x.LanguageId).HasColumnName("language_id"); + builder.Property(x => x.Position).HasColumnName("position"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Configuration/UserRowConfiguration.cs b/StackDuel.Infrastructure/Persistence/Read/Configuration/UserRowConfiguration.cs new file mode 100644 index 0000000..d779506 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Configuration/UserRowConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read.Configuration; + +internal sealed class UserRowConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.Sub).HasColumnName("sub"); + builder.Property(x => x.Username).HasColumnName("username"); + builder.Property(x => x.Bio).HasColumnName("bio"); + builder.Property(x => x.ImageUrl).HasColumnName("image_url"); + builder.Property(x => x.UsernameLastChangedAt).HasColumnName("username_last_changed_at"); + builder.Property(x => x.CreatedAt).HasColumnName("created_at"); + builder.Property(x => x.SetupCompletedAt).HasColumnName("setup_completed_at"); + builder.Property(x => x.IsPrivate).HasColumnName("is_private"); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/GroupRoleRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/GroupRoleRow.cs new file mode 100644 index 0000000..955f20e --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/GroupRoleRow.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class GroupRoleRow +{ + public Guid GroupId { get; set; } + + public Guid RoleId { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/GroupRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/GroupRow.cs new file mode 100644 index 0000000..3f988ee --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/GroupRow.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class GroupRow +{ + public Guid Id { get; set; } + + public required string Name { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/PermissionRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/PermissionRow.cs new file mode 100644 index 0000000..a01ad02 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/PermissionRow.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class PermissionRow +{ + public Guid Id { get; set; } + + public required string Code { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/RolePermissionRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/RolePermissionRow.cs new file mode 100644 index 0000000..d0e2a2c --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/RolePermissionRow.cs @@ -0,0 +1,12 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class RolePermissionRow +{ + public Guid RoleId { get; set; } + + public Guid PermissionId { get; set; } + + public DecisionEffect Effect { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/RoleRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/RoleRow.cs new file mode 100644 index 0000000..d2ad95f --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/RoleRow.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class RoleRow +{ + public Guid Id { get; set; } + + public required string Name { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/SecurityRestrictionRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/SecurityRestrictionRow.cs new file mode 100644 index 0000000..e795a4a --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/SecurityRestrictionRow.cs @@ -0,0 +1,16 @@ +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class SecurityRestrictionRow +{ + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public required string PermissionCode { get; set; } + + public DecisionEffect Effect { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/UserGroupRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/UserGroupRow.cs new file mode 100644 index 0000000..5ab77b6 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/UserGroupRow.cs @@ -0,0 +1,8 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class UserGroupRow +{ + public Guid UserId { get; set; } + + public Guid GroupId { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/UserLanguagePreferenceRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/UserLanguagePreferenceRow.cs new file mode 100644 index 0000000..3ff1025 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/UserLanguagePreferenceRow.cs @@ -0,0 +1,10 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class UserLanguagePreferenceRow +{ + public Guid UserId { get; set; } + + public Guid LanguageId { get; set; } + + public int Position { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/Entities/UserRow.cs b/StackDuel.Infrastructure/Persistence/Read/Entities/UserRow.cs new file mode 100644 index 0000000..56afe47 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/Entities/UserRow.cs @@ -0,0 +1,22 @@ +namespace StackDuel.Infrastructure.Persistence.Read.Entities; + +internal sealed class UserRow +{ + public Guid Id { get; set; } + + public required string Sub { get; set; } + + public required string Username { get; set; } + + public string? Bio { get; set; } + + public string? ImageUrl { get; set; } + + public DateTime? UsernameLastChangedAt { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? SetupCompletedAt { get; set; } + + public bool IsPrivate { get; set; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Read/StackDuelReadDbContext.cs b/StackDuel.Infrastructure/Persistence/Read/StackDuelReadDbContext.cs new file mode 100644 index 0000000..3d8dc33 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Read/StackDuelReadDbContext.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Infrastructure.Persistence.Read.Configuration; +using StackDuel.Infrastructure.Persistence.Read.Entities; + +namespace StackDuel.Infrastructure.Persistence.Read; + +internal sealed class StackDuelReadDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users { get; init; } + + public DbSet Groups { get; init; } + + public DbSet Roles { get; init; } + + public DbSet Permissions { get; init; } + + public DbSet RolePermissions { get; init; } + + public DbSet SecurityRestrictions { get; init; } + + public DbSet UserGroups { get; init; } + + public DbSet GroupRoles { get; init; } + + public DbSet UserLanguagePreferences { get; init; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new UserRowConfiguration()); + modelBuilder.ApplyConfiguration(new GroupRowConfiguration()); + modelBuilder.ApplyConfiguration(new RoleRowConfiguration()); + modelBuilder.ApplyConfiguration(new PermissionRowConfiguration()); + modelBuilder.ApplyConfiguration(new RolePermissionRowConfiguration()); + modelBuilder.ApplyConfiguration(new SecurityRestrictionRowConfiguration()); + modelBuilder.ApplyConfiguration(new UserGroupRowConfiguration()); + modelBuilder.ApplyConfiguration(new GroupRoleRowConfiguration()); + modelBuilder.ApplyConfiguration(new UserLanguagePreferenceRowConfiguration()); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/AdditionalFileBundleSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/AdditionalFileBundleSeeder.cs new file mode 100644 index 0000000..f7f7e5f --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/AdditionalFileBundleSeeder.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.ExecutionAssets.Entities; +using StackDuel.Domain.ExecutionAssets.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class AdditionalFileBundleSeeder(StackDuelDbContext context) +{ + public async Task GetOrCreateAsync(string name, byte[] content, CancellationToken cancellationToken = default) + { + Guid? existingId = await context + .AdditionalFileBundles.AsNoTracking() + .Where(b => b.Name == new AdditionalFileBundleName(name)) + .Select(b => (Guid?)b.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is not null) + return existingId.Value; + + var bundle = new AdditionalFileBundle(new AdditionalFileBundleName(name), content); + context.AdditionalFileBundles.Add(bundle); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + + return bundle.Id; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Assets/chinook-sqlite.zip b/StackDuel.Infrastructure/Persistence/Seeders/Assets/chinook-sqlite.zip new file mode 100644 index 0000000..90776e9 Binary files /dev/null and b/StackDuel.Infrastructure/Persistence/Seeders/Assets/chinook-sqlite.zip differ diff --git a/StackDuel.Infrastructure/Persistence/Seeders/AuthorizationSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/AuthorizationSeeder.cs new file mode 100644 index 0000000..e29dcb3 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/AuthorizationSeeder.cs @@ -0,0 +1,289 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Authorization.Rbac.Entities; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class AuthorizationSeeder(StackDuelDbContext context) : IStaticSeeder +{ + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + PermissionCode createSubmissionCode = new(WellKnownAuthorization.CreateSubmissionPermission); + + Permission createSubmissionPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == createSubmissionCode, cancellationToken) + ?? Permission.Create(createSubmissionCode, "Allows creating code submissions"); + + if (context.Entry(createSubmissionPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(createSubmissionPermission, cancellationToken); + + PermissionCode readAdminProblemsCode = new(WellKnownAuthorization.ReadAdminProblemsPermission); + + Permission readAdminProblemsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminProblemsCode, cancellationToken) + ?? Permission.Create(readAdminProblemsCode, "Allows viewing admin-only problem data"); + + if (context.Entry(readAdminProblemsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminProblemsPermission, cancellationToken); + + PermissionCode updateAdminProblemsCode = new(WellKnownAuthorization.UpdateAdminProblemsPermission); + + Permission updateAdminProblemsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == updateAdminProblemsCode, cancellationToken) + ?? Permission.Create(updateAdminProblemsCode, "Allows updating problems in the admin panel"); + + if (context.Entry(updateAdminProblemsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(updateAdminProblemsPermission, cancellationToken); + + PermissionCode readAdminUsersCode = new(WellKnownAuthorization.ReadAdminUsersPermission); + + Permission readAdminUsersPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminUsersCode, cancellationToken) + ?? Permission.Create(readAdminUsersCode, "Allows viewing users in the admin panel"); + + if (context.Entry(readAdminUsersPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminUsersPermission, cancellationToken); + + PermissionCode updateAdminUserGroupsCode = new(WellKnownAuthorization.UpdateAdminUserGroupsPermission); + + Permission updateAdminUserGroupsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == updateAdminUserGroupsCode, cancellationToken) + ?? Permission.Create( + updateAdminUserGroupsCode, + "Allows updating a user's group memberships in the admin panel" + ); + + if (context.Entry(updateAdminUserGroupsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(updateAdminUserGroupsPermission, cancellationToken); + + PermissionCode readAdminSubmissionsCode = new(WellKnownAuthorization.ReadAdminSubmissionsPermission); + + Permission readAdminSubmissionsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminSubmissionsCode, cancellationToken) + ?? Permission.Create(readAdminSubmissionsCode, "Allows viewing submissions in the admin panel"); + + if (context.Entry(readAdminSubmissionsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminSubmissionsPermission, cancellationToken); + + PermissionCode viewSubmissionCode = new(WellKnownAuthorization.ViewSubmissionPermission); + + Permission viewSubmissionPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == viewSubmissionCode, cancellationToken) + ?? Permission.Create(viewSubmissionCode, "Allows viewing code submissions"); + + if (context.Entry(viewSubmissionPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(viewSubmissionPermission, cancellationToken); + + PermissionCode playSoloRushCode = new(WellKnownAuthorization.PlaySoloRushPermission); + + Permission playSoloRushPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == playSoloRushCode, cancellationToken) + ?? Permission.Create(playSoloRushCode, "Allows playing Solo Rush mode"); + + if (context.Entry(playSoloRushPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(playSoloRushPermission, cancellationToken); + + PermissionCode createFeedbackCode = new(WellKnownAuthorization.CreateFeedbackPermission); + + Permission createFeedbackPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == createFeedbackCode, cancellationToken) + ?? Permission.Create(createFeedbackCode, "Allows submitting product feedback"); + + if (context.Entry(createFeedbackPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(createFeedbackPermission, cancellationToken); + + PermissionCode readAdminFeedbackCode = new(WellKnownAuthorization.ReadAdminFeedbackPermission); + + Permission readAdminFeedbackPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminFeedbackCode, cancellationToken) + ?? Permission.Create(readAdminFeedbackCode, "Allows viewing feedback in the admin panel"); + + if (context.Entry(readAdminFeedbackPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminFeedbackPermission, cancellationToken); + + PermissionCode updateAdminFeedbackCode = new(WellKnownAuthorization.UpdateAdminFeedbackPermission); + + Permission updateAdminFeedbackPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == updateAdminFeedbackCode, cancellationToken) + ?? Permission.Create(updateAdminFeedbackCode, "Allows updating feedback status in the admin panel"); + + if (context.Entry(updateAdminFeedbackPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(updateAdminFeedbackPermission, cancellationToken); + + PermissionCode readAdminDashboardCode = new(WellKnownAuthorization.ReadAdminDashboardPermission); + + Permission readAdminDashboardPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminDashboardCode, cancellationToken) + ?? Permission.Create(readAdminDashboardCode, "Allows viewing the admin dashboard overview"); + + if (context.Entry(readAdminDashboardPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminDashboardPermission, cancellationToken); + + PermissionCode readAdminGamesCode = new(WellKnownAuthorization.ReadAdminGamesPermission); + + Permission readAdminGamesPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminGamesCode, cancellationToken) + ?? Permission.Create(readAdminGamesCode, "Allows viewing games in the admin panel"); + + if (context.Entry(readAdminGamesPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminGamesPermission, cancellationToken); + + PermissionCode playDuelCode = new(WellKnownAuthorization.PlayDuelPermission); + + Permission playDuelPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == playDuelCode, cancellationToken) + ?? Permission.Create(playDuelCode, "Allows playing Duel mode"); + + if (context.Entry(playDuelPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(playDuelPermission, cancellationToken); + + PermissionCode playFfaCode = new(WellKnownAuthorization.PlayFfaPermission); + + Permission playFfaPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == playFfaCode, cancellationToken) + ?? Permission.Create(playFfaCode, "Allows playing FFA mode"); + + if (context.Entry(playFfaPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(playFfaPermission, cancellationToken); + + PermissionCode manageFeatureFlagsCode = new(WellKnownAuthorization.ManageFeatureFlagsPermission); + + Permission manageFeatureFlagsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == manageFeatureFlagsCode, cancellationToken) + ?? Permission.Create(manageFeatureFlagsCode, "Allows managing feature flags in the admin panel"); + + if (context.Entry(manageFeatureFlagsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(manageFeatureFlagsPermission, cancellationToken); + + PermissionCode readAdminCampaignsCode = new(WellKnownAuthorization.ReadAdminCampaignsPermission); + + Permission readAdminCampaignsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminCampaignsCode, cancellationToken) + ?? Permission.Create(readAdminCampaignsCode, "Allows viewing campaigns in the admin panel"); + + if (context.Entry(readAdminCampaignsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminCampaignsPermission, cancellationToken); + + PermissionCode manageAdminCampaignsCode = new(WellKnownAuthorization.ManageAdminCampaignsPermission); + + Permission manageAdminCampaignsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == manageAdminCampaignsCode, cancellationToken) + ?? Permission.Create(manageAdminCampaignsCode, "Allows creating and editing campaigns in the admin panel"); + + if (context.Entry(manageAdminCampaignsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(manageAdminCampaignsPermission, cancellationToken); + + PermissionCode readAdminAuditLogCode = new(WellKnownAuthorization.ReadAdminAuditLogPermission); + + Permission readAdminAuditLogPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == readAdminAuditLogCode, cancellationToken) + ?? Permission.Create(readAdminAuditLogCode, "Allows viewing the admin audit log"); + + if (context.Entry(readAdminAuditLogPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(readAdminAuditLogPermission, cancellationToken); + + PermissionCode createAdminProblemsCode = new(WellKnownAuthorization.CreateAdminProblemsPermission); + + Permission createAdminProblemsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == createAdminProblemsCode, cancellationToken) + ?? Permission.Create(createAdminProblemsCode, "Allows creating and authoring problems in the admin panel"); + + if (context.Entry(createAdminProblemsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(createAdminProblemsPermission, cancellationToken); + + PermissionCode submitAdminProblemsCode = new(WellKnownAuthorization.SubmitAdminProblemsPermission); + + Permission submitAdminProblemsPermission = + await context.Permissions.FirstOrDefaultAsync(p => p.Code == submitAdminProblemsCode, cancellationToken) + ?? Permission.Create(submitAdminProblemsCode, "Allows submitting an authored problem for validation"); + + if (context.Entry(submitAdminProblemsPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(submitAdminProblemsPermission, cancellationToken); + + PermissionCode manageRequiredProblemLanguagesCode = new( + WellKnownAuthorization.ManageRequiredProblemLanguagesPermission + ); + + Permission manageRequiredProblemLanguagesPermission = + await context.Permissions.FirstOrDefaultAsync( + p => p.Code == manageRequiredProblemLanguagesCode, + cancellationToken + ) + ?? Permission.Create( + manageRequiredProblemLanguagesCode, + "Allows managing the languages required for new problems" + ); + + if (context.Entry(manageRequiredProblemLanguagesPermission).State == EntityState.Detached) + await context.Permissions.AddAsync(manageRequiredProblemLanguagesPermission, cancellationToken); + + Role role = + await context + .Roles.Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Name == new Name(WellKnownAuthorization.DefaultUserRole), cancellationToken) + ?? Role.Create(new Name(WellKnownAuthorization.DefaultUserRole)); + + if (context.Entry(role).State == EntityState.Detached) + await context.Roles.AddAsync(role, cancellationToken); + + Role adminRole = + await context + .Roles.Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Name == new Name(WellKnownAuthorization.AdminRole), cancellationToken) + ?? Role.Create(new Name(WellKnownAuthorization.AdminRole)); + + if (context.Entry(adminRole).State == EntityState.Detached) + await context.Roles.AddAsync(adminRole, cancellationToken); + + Group group = + await context + .Groups.Include(g => g.RoleGrants) + .FirstOrDefaultAsync( + g => g.Name == new Name(WellKnownAuthorization.DefaultUserGroup), + cancellationToken + ) + ?? Group.Create(new Name(WellKnownAuthorization.DefaultUserGroup)); + + if (context.Entry(group).State == EntityState.Detached) + await context.Groups.AddAsync(group, cancellationToken); + + Group adminGroup = + await context + .Groups.Include(g => g.RoleGrants) + .FirstOrDefaultAsync(g => g.Name == new Name(WellKnownAuthorization.AdminGroup), cancellationToken) + ?? Group.Create(new Name(WellKnownAuthorization.AdminGroup)); + + if (context.Entry(adminGroup).State == EntityState.Detached) + await context.Groups.AddAsync(adminGroup, cancellationToken); + + role.GrantPermission(createSubmissionPermission.Id); + role.GrantPermission(viewSubmissionPermission.Id); + role.GrantPermission(playSoloRushPermission.Id); + role.GrantPermission(playDuelPermission.Id); + role.GrantPermission(playFfaPermission.Id); + role.GrantPermission(createFeedbackPermission.Id); + + adminRole.GrantPermission(readAdminProblemsPermission.Id); + adminRole.GrantPermission(updateAdminProblemsPermission.Id); + adminRole.GrantPermission(readAdminUsersPermission.Id); + adminRole.GrantPermission(updateAdminUserGroupsPermission.Id); + adminRole.GrantPermission(readAdminSubmissionsPermission.Id); + adminRole.GrantPermission(readAdminFeedbackPermission.Id); + adminRole.GrantPermission(updateAdminFeedbackPermission.Id); + adminRole.GrantPermission(readAdminDashboardPermission.Id); + adminRole.GrantPermission(readAdminGamesPermission.Id); + adminRole.GrantPermission(manageFeatureFlagsPermission.Id); + adminRole.GrantPermission(readAdminCampaignsPermission.Id); + adminRole.GrantPermission(manageAdminCampaignsPermission.Id); + adminRole.GrantPermission(readAdminAuditLogPermission.Id); + adminRole.GrantPermission(createAdminProblemsPermission.Id); + adminRole.GrantPermission(submitAdminProblemsPermission.Id); + adminRole.GrantPermission(manageRequiredProblemLanguagesPermission.Id); + + group.GrantRole(role); + adminGroup.GrantRole(adminRole); + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Campaigns/CodeFoundationsCampaignSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Campaigns/CodeFoundationsCampaignSeeder.cs new file mode 100644 index 0000000..01610b9 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Campaigns/CodeFoundationsCampaignSeeder.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Campaigns; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Campaigns; + +internal sealed class CodeFoundationsCampaignSeeder( + StackDuelDbContext context, + ICampaignReadRepository campaignReadRepository, + ICampaignWriteRepository campaignWriteRepository +) : IStaticSeeder +{ + private const string CampaignSlug = "code-foundations"; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + if (await campaignReadRepository.SlugExistsAsync(CampaignSlug, cancellationToken)) + return; + + Guid? twoSumProblemId = await FindProblemIdBySlugAsync("two-sum", cancellationToken); + Guid? helloOrGoodbyeProblemId = await FindProblemIdBySlugAsync("hello-or-goodbye", cancellationToken); + + Campaign campaign = new( + CampaignSlug, + "Code Foundations", + "Start here if you're brand new to programming. Learn the core building blocks you'll use in every language, then put them into practice.", + CampaignDifficulty.Beginner + ); + + CampaignModule gettingStarted = campaign.AddModule( + "Getting Started", + "What programming is, and how to write and run your first lines of code." + ); + + gettingStarted.AddUnit( + "What Is Programming?", + "A program is a sequence of instructions a computer follows, one step at a time. " + + "Every app, website, and game you've ever used is built from the same small set of " + + "building blocks: storing information, making decisions, and repeating work. " + + "This course walks through those building blocks from scratch.", + UnitType.Lesson, + 5 + ); + + gettingStarted.AddUnit( + "Variables and Data Types", + "A variable is a named container for a value. Values come in a few basic types — " + + "numbers, text (strings), and true/false (booleans) — and the type determines what " + + "you can do with a value, like adding two numbers or joining two strings together.", + UnitType.Lesson, + 10 + ); + + gettingStarted.AddUnit( + "Your First Program", + "Most languages start with a program that prints a message to the screen. Writing, " + + "running, and seeing the output of your first program is the fastest way to confirm " + + "your environment works before building anything more complex.", + UnitType.Lesson, + 10 + ); + + CampaignModule controlFlow = campaign.AddModule("Control Flow", "Make decisions and repeat work in your code."); + + controlFlow.AddUnit( + "Conditionals", + "An if statement runs a block of code only when a condition is true, and an else " + + "branch runs otherwise. Chaining conditions lets a program react differently " + + "depending on the data it's given.", + UnitType.Lesson, + 10 + ); + + controlFlow.AddUnit( + "Loops", + "A loop repeats a block of code until a condition is no longer true, so you can " + + "process a list of items or repeat an action a fixed number of times without " + + "writing it out by hand.", + UnitType.Lesson, + 10 + ); + + CampaignUnit controlFlowPractice = controlFlow.AddUnit( + "Practice: Hello or Goodbye", + "Put conditionals to work: given a name, decide whether to greet or say farewell.", + UnitType.Challenge, + 15 + ); + + if (helloOrGoodbyeProblemId is { } helloId) + controlFlowPractice.SetProblems([helloId]); + + CampaignModule functions = campaign.AddModule("Functions", "Package up logic so you can reuse it."); + + functions.AddUnit( + "Writing Functions", + "A function packages up a sequence of steps under a name, so you can run that logic " + + "again elsewhere without copying it. Functions take inputs (parameters) and can hand " + + "back a result (a return value).", + UnitType.Lesson, + 10 + ); + + CampaignUnit functionsPractice = functions.AddUnit( + "Practice: Two Sum", + "Write a function that finds two numbers in a list that add up to a target value.", + UnitType.Challenge, + 20 + ); + + if (twoSumProblemId is { } twoSumId) + functionsPractice.SetProblems([twoSumId]); + + functions.AddUnit( + "Foundations Check", + "A short check-in on variables, conditionals, loops, and functions before you move " + + "on to the next path.", + UnitType.Quiz, + 10 + ); + + campaign.Publish(); + + await campaignWriteRepository.AddAsync(campaign, cancellationToken); + } + + private async Task FindProblemIdBySlugAsync(string slug, CancellationToken cancellationToken) + { + return await context + .Set() + .AsNoTracking() + .Where(p => p.Slug.Value == slug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/DemoDataSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/DemoDataSeeder.cs new file mode 100644 index 0000000..746b6dc --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/DemoDataSeeder.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class DemoDataSeeder(StackDuelDbContext context, Judge0PipelineSeeder pipelineSeeder) : IDemoSeeder +{ + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + if (await context.Problems.AnyAsync(cancellationToken)) + return; + + LanguageVersionEntry jsVersion = await context + .Languages.Where(l => l.Slug == new LanguageSlug("javascript")) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + LanguageVersionEntry tsVersion = await context + .Languages.Where(l => l.Slug == new LanguageSlug("typescript")) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + LanguageVersionEntry pyVersion = await context + .Languages.Where(l => l.Slug == new LanguageSlug("python")) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + Problem twoSum = new( + new Slug("two-sum"), + new Title("Two Sum"), + new Question( + "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target. You may assume that each input would have exactly one solution, and you may not use the same element twice." + ), + new Difficulty(500), + new TimeLimit(1000), + new MemoryLimit(64) + ); + + twoSum.Publish(); + + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + ProblemSetup jsSetup = twoSum.AddSetup( + jsVersion.Id, + "function twoSum(nums, target) {\n \n}", + "twoSum", + pipelineId + ); + + ProblemSetup tsSetup = twoSum.AddSetup( + tsVersion.Id, + "function twoSum(nums: number[], target: number): number[] {\n \n}", + "twoSum", + pipelineId + ); + + ProblemSetup pySetup = twoSum.AddSetup( + pyVersion.Id, + "def two_sum(nums: list[int], target: int) -> list[int]:\n pass", + "two_sum", + pipelineId + ); + + context.Problems.Add(twoSum); + await context.SaveChangesAsync(cancellationToken); + + TestSuite sampleSuite = BuildTwoSumSampleSuite(); + TestSuite hiddenSuite = BuildTwoSumHiddenSuite(); + + context.TestSuites.AddRange(sampleSuite, hiddenSuite); + await context.SaveChangesAsync(cancellationToken); + + Guid[] setupIds = [jsSetup.Id, tsSetup.Id, pySetup.Id]; + foreach (Guid id in setupIds) + { + ProblemSetup setup = await context + .Set() + .Include(s => s.TestSuites) + .FirstAsync(s => s.Id == id, cancellationToken); + + ((List)setup.TestSuites).Add(sampleSuite); + ((List)setup.TestSuites).Add(hiddenSuite); + } + + await context.SaveChangesAsync(cancellationToken); + } + + private static TestSuite BuildTwoSumSampleSuite() + { + TestSuite suite = new("Two Sum - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Example 1"); + case1.AddInput("[2,7,11,15]", "integer_array"); + case1.AddInput("9", "integer"); + case1.AddExpectedOutput("[0,1]", "integer_array"); + + TestCase case2 = suite.AddTestCase("Example 2"); + case2.AddInput("[3,2,4]", "integer_array"); + case2.AddInput("6", "integer"); + case2.AddExpectedOutput("[1,2]", "integer_array"); + + return suite; + } + + private static TestSuite BuildTwoSumHiddenSuite() + { + TestSuite suite = new("Two Sum - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Hidden 1"); + case1.AddInput("[1,2,3,4,5]", "integer_array"); + case1.AddInput("9", "integer"); + case1.AddExpectedOutput("[3,4]", "integer_array"); + + TestCase case2 = suite.AddTestCase("Hidden 2"); + case2.AddInput("[0,4,3,0]", "integer_array"); + case2.AddInput("0", "integer"); + case2.AddExpectedOutput("[0,3]", "integer_array"); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/FeatureFlagSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/FeatureFlagSeeder.cs new file mode 100644 index 0000000..fb23a09 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/FeatureFlagSeeder.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.FeatureFlags.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class FeatureFlagSeeder(StackDuelDbContext context) : IStaticSeeder +{ + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + FeatureFlagKey leaderboardsKey = new(WellKnownFeatureFlags.Leaderboards); + + FeatureFlag leaderboardsFlag = + await context.FeatureFlags.FirstOrDefaultAsync(f => f.Key == leaderboardsKey, cancellationToken) + ?? FeatureFlag.Create( + leaderboardsKey, + "Leaderboards", + "Global kill-switch for the leaderboards page and endpoint.", + defaultEnabled: true + ); + + if (context.Entry(leaderboardsFlag).State == EntityState.Detached) + await context.FeatureFlags.AddAsync(leaderboardsFlag, cancellationToken); + + FeatureFlagKey campaignsKey = new(WellKnownFeatureFlags.Campaigns); + + FeatureFlag campaignsFlag = + await context.FeatureFlags.FirstOrDefaultAsync(f => f.Key == campaignsKey, cancellationToken) + ?? FeatureFlag.Create( + campaignsKey, + "Campaigns", + "Global kill-switch for the campaigns/learning-path feature.", + defaultEnabled: false + ); + + if (context.Entry(campaignsFlag).State == EntityState.Detached) + await context.FeatureFlags.AddAsync(campaignsFlag, cancellationToken); + + FeatureFlagKey ratingsKey = new(WellKnownFeatureFlags.Ratings); + + FeatureFlag ratingsFlag = + await context.FeatureFlags.FirstOrDefaultAsync(f => f.Key == ratingsKey, cancellationToken) + ?? FeatureFlag.Create( + ratingsKey, + "Ratings", + "Kill-switch for the player ranking UI in profiles; the duel/ffa/solo-rush mode-rating backend is currently removed.", + defaultEnabled: false + ); + + if (context.Entry(ratingsFlag).State == EntityState.Detached) + await context.FeatureFlags.AddAsync(ratingsFlag, cancellationToken); + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/GameModeSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/GameModeSeeder.cs new file mode 100644 index 0000000..0f47328 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/GameModeSeeder.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Games.Entities; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class GameModeSeeder(StackDuelDbContext context) : IStaticSeeder +{ + private const string SoloRushKey = "solo_rush"; + private const string DuelKey = "duel"; + private const string FfaKey = "ffa"; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid defaultPoolId = await context + .ProblemPools.Where(p => p.Key == ProblemPoolSeeder.AllProblemsKey) + .Select(p => p.Id) + .SingleAsync(cancellationToken); + + await EnsureSoloRushAsync(defaultPoolId, cancellationToken); + await EnsureDuelAsync(defaultPoolId, cancellationToken); + await EnsureFfaAsync(defaultPoolId, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + private async Task EnsureSoloRushAsync(Guid defaultPoolId, CancellationToken cancellationToken) + { + bool exists = await context.GameModes.AnyAsync(m => m.Key == SoloRushKey, cancellationToken); + + if (exists) + return; + + GameMode soloRush = new( + SoloRushKey, + "Solo Rush", + "Race against the clock solving algorithmic problems solo. " + + "Problems start easy and get progressively harder as you go.", + isBuiltIn: true, + minPlayers: 1, + maxPlayers: 1, + defaultPoolId + ); + + soloRush.AddTimeOption(300, isDefault: false); // 5 min + soloRush.AddTimeOption(600, isDefault: true); // 10 min (default) + soloRush.AddTimeOption(900, isDefault: false); // 15 min + soloRush.AddTimeOption(1800, isDefault: false); // 30 min + + await context.GameModes.AddAsync(soloRush, cancellationToken); + } + + private async Task EnsureDuelAsync(Guid defaultPoolId, CancellationToken cancellationToken) + { + bool exists = await context.GameModes.AnyAsync(m => m.Key == DuelKey, cancellationToken); + + if (exists) + return; + + GameMode duel = new( + DuelKey, + "Duel", + "Challenge your opponent in a one-on-one, head-to-head battle.", + isBuiltIn: true, + minPlayers: 2, + maxPlayers: 2, + defaultPoolId + ); + + duel.AddTimeOption(300, isDefault: false); // 5 min + duel.AddTimeOption(600, isDefault: true); // 10 min (default) + duel.AddTimeOption(900, isDefault: false); // 15 min + + await context.GameModes.AddAsync(duel, cancellationToken); + } + + private async Task EnsureFfaAsync(Guid defaultPoolId, CancellationToken cancellationToken) + { + bool exists = await context.GameModes.AnyAsync(m => m.Key == FfaKey, cancellationToken); + + if (exists) + return; + + GameMode ffa = new( + FfaKey, + "FFA", + "Free-for-all: solve as many problems as possible before the time runs out.", + isBuiltIn: true, + minPlayers: 2, + maxPlayers: 10, + defaultPoolId + ); + + ffa.AddTimeOption(300, isDefault: false); // 5 min + ffa.AddTimeOption(600, isDefault: true); // 10 min (default) + ffa.AddTimeOption(900, isDefault: false); // 15 min + ffa.AddTimeOption(1800, isDefault: false); // 30 min + + await context.GameModes.AddAsync(ffa, cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/ISeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/ISeeder.cs new file mode 100644 index 0000000..3a0d21e --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/ISeeder.cs @@ -0,0 +1,12 @@ +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal interface ISeeder +{ + Task SeedAsync(CancellationToken cancellationToken = default); +} + +/// Reference data — languages, problems, tracks. Idempotent, runs on every deploy. +internal interface IStaticSeeder : ISeeder; + +/// Demo/sample data. Development and staging only. +internal interface IDemoSeeder : ISeeder; \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Judge0PipelineSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Judge0PipelineSeeder.cs new file mode 100644 index 0000000..69b4f22 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Judge0PipelineSeeder.cs @@ -0,0 +1,150 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.ExecutionPipelines.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +/// +/// Ensures the default Judge0 execution pipeline exists in the database. +/// Problem seeders call to resolve the pipeline id +/// before seeding problem setups. +/// +internal sealed class Judge0PipelineSeeder(StackDuelDbContext context) : IStaticSeeder +{ + public const string PipelineName = WellKnownExecutionPipelines.DefaultName; + + public async Task SeedAsync(CancellationToken cancellationToken = default) => + await GetOrCreateAsync(cancellationToken); + + public async Task GetOrCreateAsync(CancellationToken cancellationToken = default) + { + var existingPipeline = await context + .ExecutionPipelines.AsNoTracking() + .Where(pipeline => pipeline.Name == PipelineName) + .Select(pipeline => new + { + pipeline.Id, + Steps = pipeline + .Steps.Select(step => new ExistingStepInfo( + step.Id, + step.StepType, + step.StepOrder, + step.MaxAttempts, + step.TimeoutSeconds, + step.IsPolling + )) + .ToList(), + }) + .FirstOrDefaultAsync(cancellationToken); + + if (existingPipeline is not null) + { + // Insert any steps that don't exist yet. + List missingSteps = GetMissingSteps(existingPipeline.Steps); + foreach (RequiredStep missingStep in missingSteps) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO execution_pipeline_steps + (id, name, step_type, step_order, max_attempts, timeout_seconds, is_polling, pipeline_id) + VALUES + ({Guid.NewGuid()}, {missingStep.Name}, {(int) + missingStep.StepType}, {missingStep.StepOrder}, {missingStep.MaxAttempts}, {missingStep.TimeoutSeconds}, {missingStep.IsPolling}, {existingPipeline.Id}) + """, + cancellationToken + ); + } + + // Sync configuration for existing steps so changes to RequiredSteps + // (e.g. timeout_seconds, max_attempts) take effect on next startup + // rather than being silently ignored. + foreach (RequiredStep required in RequiredSteps) + { + var existing = existingPipeline.Steps.FirstOrDefault(s => s.StepType == required.StepType); + if (existing is null) + continue; + + bool stale = + existing.MaxAttempts != required.MaxAttempts + || existing.TimeoutSeconds != required.TimeoutSeconds + || existing.IsPolling != required.IsPolling; + + if (stale) + { + await context.Database.ExecuteSqlAsync( + $""" + UPDATE execution_pipeline_steps + SET max_attempts = {required.MaxAttempts}, + timeout_seconds = {required.TimeoutSeconds}, + is_polling = {required.IsPolling} + WHERE id = {existing.Id} + """, + cancellationToken + ); + } + } + + context.ChangeTracker.Clear(); + return existingPipeline.Id; + } + + var pipeline = new ExecutionPipeline( + PipelineName, + "Executes submissions via Judge0: submit → poll → evaluate." + ); + + foreach (RequiredStep step in RequiredSteps) + pipeline.AddStep( + step.StepType, + step.StepOrder, + step.MaxAttempts, + step.TimeoutSeconds, + step.IsPolling, + step.Name + ); + + context.ExecutionPipelines.Add(pipeline); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + + return pipeline.Id; + } + + private static List GetMissingSteps(IReadOnlyCollection existingSteps) + { + HashSet existingTypes = [.. existingSteps.Select(step => step.StepType)]; + HashSet existingOrders = [.. existingSteps.Select(step => step.StepOrder)]; + + return + [ + .. RequiredSteps.Where(step => + !existingTypes.Contains(step.StepType) && !existingOrders.Contains(step.StepOrder) + ), + ]; + } + + private sealed record ExistingStepInfo( + Guid Id, + ExecutionPipelineStepType StepType, + int StepOrder, + int MaxAttempts, + int TimeoutSeconds, + bool IsPolling + ); + + private sealed record RequiredStep( + ExecutionPipelineStepType StepType, + int StepOrder, + int MaxAttempts, + int TimeoutSeconds, + bool IsPolling, + string Name + ); + + private static readonly RequiredStep[] RequiredSteps = + [ + new(ExecutionPipelineStepType.Judge0Execute, 1, 3, 30, false, "Submit to Judge0"), + new(ExecutionPipelineStepType.Judge0Poll, 2, 30, 1, true, "Poll Judge0 Results"), + new(ExecutionPipelineStepType.Evaluate, 3, 1, 10, false, "Evaluate Results"), + ]; +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/LanguageSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/LanguageSeeder.cs new file mode 100644 index 0000000..e61b09c --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/LanguageSeeder.cs @@ -0,0 +1,151 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class LanguageSeeder(StackDuelDbContext context) : IStaticSeeder +{ + private static readonly DesiredLanguage[] DesiredLanguages = + [ + new( + "JavaScript", + "javascript", + TrackSeeder.GeneralPurposeKey, + [new(new LanguageVersion("Node.js 22.08.0"), new Judge0Id(102))], + IsActive: true + ), + new( + "TypeScript", + "typescript", + TrackSeeder.GeneralPurposeKey, + [new(new LanguageVersion("5.6.2"), new Judge0Id(101))], + IsActive: false + ), + new( + "Python", + "python", + TrackSeeder.GeneralPurposeKey, + [new(new LanguageVersion("3.13.2"), new Judge0Id(109))], + IsActive: true + ), + new( + "SQLite", + "sqlite", + TrackSeeder.SqlKey, + [new(new LanguageVersion("3.27.2"), new Judge0Id(82))], + IsActive: true + ), + new( + "Java", + "java", + TrackSeeder.GeneralPurposeKey, + [new(new LanguageVersion("JDK 17.0.6"), new Judge0Id(89))], + IsActive: true + ), + new( + "C++", + "cpp", + TrackSeeder.GeneralPurposeKey, + [new(new LanguageVersion("(GCC 9.2.0)"), new Judge0Id(54))], + IsActive: true + ), + new( + "React", + "react", + TrackSeeder.FrontendKey, + [new(new LanguageVersion("19.0.0"), new Judge0Id(89))], + IsActive: false + ), + new( + "Angular", + "angular", + TrackSeeder.FrontendKey, + [new(new LanguageVersion("18.2.0"), new Judge0Id(89))], + IsActive: false + ), + new( + "Vanilla JS", + "vanilla-js", + TrackSeeder.FrontendKey, + [new(new LanguageVersion("ES2023"), new Judge0Id(89))], + IsActive: false + ), + ]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + List existingLanguages = await context + .Languages.IgnoreQueryFilters() + .Include(l => l.Versions) + .ToListAsync(cancellationToken); + + Dictionary trackIdsByKey = await context.Tracks.ToDictionaryAsync( + t => t.Key, + t => t.Id, + cancellationToken + ); + + foreach (DesiredLanguage desired in DesiredLanguages) + { + if (!trackIdsByKey.TryGetValue(desired.TrackKey, out Guid trackId)) + throw new InvalidOperationException( + $"Track '{desired.TrackKey}' was not found — ensure TrackSeeder runs before LanguageSeeder." + ); + + Language? language = existingLanguages.FirstOrDefault(l => l.Slug == new LanguageSlug(desired.Slug)); + + if (language is null) + { + language = new Language(new LanguageName(desired.Name), new LanguageSlug(desired.Slug), trackId); + context.Languages.Add(language); + } + + if (desired.IsActive && !language.IsActive) + language.Activate(); + else if (!desired.IsActive && language.IsActive) + language.Deactivate(); + + HashSet desiredJudge0Ids = [.. desired.Versions.Select(v => v.Judge0Id.Value)]; + + foreach (LanguageVersionEntry existing in language.Versions) + { + if (!desiredJudge0Ids.Contains(existing.Judge0Id.Value) || !desired.IsActive) + { + if (existing.IsActive) + existing.Deprecate(); + } + else if (!existing.IsActive) + { + existing.Activate(); + } + } + + HashSet existingJudge0Ids = [.. language.Versions.Select(v => v.Judge0Id.Value)]; + + foreach (DesiredVersion version in desired.Versions) + { + if (!existingJudge0Ids.Contains(version.Judge0Id.Value)) + { + LanguageVersionEntry entry = language.AddVersion(version.Version, version.Judge0Id); + context.Entry(entry).State = EntityState.Added; + + if (!desired.IsActive) + entry.Deprecate(); + } + } + } + + await context.SaveChangesAsync(cancellationToken); + } + + private sealed record DesiredLanguage( + string Name, + string Slug, + string TrackKey, + DesiredVersion[] Versions, + bool IsActive + ); + + private sealed record DesiredVersion(LanguageVersion Version, Judge0Id Judge0Id); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/ProblemPoolSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/ProblemPoolSeeder.cs new file mode 100644 index 0000000..295cea3 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/ProblemPoolSeeder.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class ProblemPoolSeeder(StackDuelDbContext context) : IStaticSeeder +{ + public const string AllProblemsKey = "all-problems"; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + ProblemPool? pool = await context.ProblemPools.FirstOrDefaultAsync( + p => p.Key == AllProblemsKey, + cancellationToken + ); + + if (pool is null) + { + pool = new ProblemPool( + AllProblemsKey, + "All Problems", + "Every published problem. Used as the default pool for built-in game modes." + ); + await context.ProblemPools.AddAsync(pool, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + List problemIds = await context.Problems.Select(p => p.Id).ToListAsync(cancellationToken); + + List existingMemberIds = await context + .Set() + .Where(item => EF.Property(item, "pool_id") == pool.Id) + .Select(item => item.ProblemId) + .ToListAsync(cancellationToken); + + HashSet existingMemberIdSet = [.. existingMemberIds]; + List missingIds = [.. problemIds.Where(id => !existingMemberIdSet.Contains(id))]; + + if (missingIds.Count == 0) + return; + + ProblemPool trackedPool = await context + .ProblemPools.Include("_items") + .SingleAsync(p => p.Id == pool.Id, cancellationToken); + + foreach (Guid problemId in missingIds) + trackedPool.AddProblem(problemId); + + // ProblemPoolItem has a client-generated (non-default) Guid key, so EF's graph-fixup + // can't tell these newly-added items apart from pre-existing ones reached via the + // "_items" navigation and marks them Modified instead of Added. Force the ones we just + // created to Added so SaveChanges inserts them instead of issuing no-op updates. + foreach (EntityEntry entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.State = EntityState.Added; + } + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/ProblemReactionTypeSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/ProblemReactionTypeSeeder.cs new file mode 100644 index 0000000..1ec274e --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/ProblemReactionTypeSeeder.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Problems.Entities; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class ProblemReactionTypeSeeder(StackDuelDbContext context) : IStaticSeeder +{ + private const string LikeKey = "like"; + private const string DislikeKey = "dislike"; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await EnsureLikeAsync(cancellationToken); + await EnsureDislikeAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + private async Task EnsureLikeAsync(CancellationToken cancellationToken) + { + bool exists = await context.ProblemReactionTypes.AnyAsync(type => type.Key == LikeKey, cancellationToken); + + if (exists) + return; + + ProblemReactionType like = new(LikeKey, "Like", "\U0001F44D", sortOrder: 1); + + await context.ProblemReactionTypes.AddAsync(like, cancellationToken); + } + + private async Task EnsureDislikeAsync(CancellationToken cancellationToken) + { + bool exists = await context.ProblemReactionTypes.AnyAsync(type => type.Key == DislikeKey, cancellationToken); + + if (exists) + return; + + ProblemReactionType dislike = new(DislikeKey, "Dislike", "\U0001F44E", sortOrder: 2); + + await context.ProblemReactionTypes.AddAsync(dislike, cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/AdditionalProblemsSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/AdditionalProblemsSeeder.cs new file mode 100644 index 0000000..b03da28 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/AdditionalProblemsSeeder.cs @@ -0,0 +1,1757 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class AdditionalProblemsSeeder( + StackDuelDbContext context, + Judge0PipelineSeeder pipelineSeeder, + ITestCaseGenerationJobRepository testCaseGenerationJobRepository +) : IStaticSeeder +{ + private const string FactorialFunctionName = "factorial"; + + private static readonly ProblemSeedDefinition[] ProblemSeeds = + [ + new( + Slug: "sum-two-integers", + Title: "Sum Two Integers", + Question: "Given two integers a and b, return the sum a + b.\n\nExample 1:\nInput: a = 2, b = 3\nOutput: 5\n\nExample 2:\nInput: a = -4, b = 10\nOutput: 6\n\nConstraints:\n-1000 <= a, b <= 1000", + Difficulty: 50, + Tags: ["math", "beginner", "arithmetic"], + Setups: + [ + new("javascript", "function sumTwoIntegers(a, b) {\n \n}", "sumTwoIntegers"), + new("python", "def sum_two_integers(a: int, b: int) -> int:\n pass", "sum_two_integers"), + new( + "java", + "class Solution {\n public int sumTwoIntegers(int a, int b) {\n return 0;\n }\n}", + "sumTwoIntegers" + ), + new( + "cpp", + "class Solution {\npublic:\n int sumTwoIntegers(int a, int b) {\n return 0;\n }\n};", + "sumTwoIntegers" + ), + ], + SampleCases: + [ + new("Example 1", [new("2", "integer"), new("3", "integer")], [new("5", "integer")]), + new("Example 2", [new("-4", "integer"), new("10", "integer")], [new("6", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer"), new("0", "integer")], [new("0", "integer")]), + new("Hidden 2", [new("999", "integer"), new("1", "integer")], [new("1000", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def sum_two_integers(a: int, b: int) -> int: + return a + b + """, + Parameters: + [ + new("a", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + new("b", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 33333 + ) + ), + new( + Slug: "is-even", + Title: "Is Even", + Question: "Given an integer n, return true if n is even, otherwise return false.\n\nExample 1:\nInput: n = 8\nOutput: true\n\nExample 2:\nInput: n = 7\nOutput: false\n\nConstraints:\n-10^9 <= n <= 10^9", + Difficulty: 140, + Tags: ["math", "modulo", "beginner"], + Setups: + [ + new("javascript", "function isEven(n) {\n \n}", "isEven"), + new("python", "def is_even(n: int) -> bool:\n pass", "is_even"), + new( + "java", + "class Solution {\n public boolean isEven(int n) {\n return false;\n }\n}", + "isEven" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isEven(int n) {\n return false;\n }\n};", + "isEven" + ), + ], + SampleCases: + [ + new("Example 1", [new("8", "integer")], [new("true", "boolean")]), + new("Example 2", [new("7", "integer")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer")], [new("true", "boolean")]), + new("Hidden 2", [new("-13", "integer")], [new("false", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_even(n: int) -> bool: + return n % 2 == 0 + """, + Parameters: + [ + new( + "n", + "integer", + Min: -1_000_000_000, + Max: 1_000_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 22222 + ) + ), + new( + Slug: "reverse-word", + Title: "Reverse Word", + Question: "Given a non-empty string word, return a new string with the characters in reverse order.\n\nExample 1:\nInput: word = \"code\"\nOutput: \"edoc\"\n\nExample 2:\nInput: word = \"racecar\"\nOutput: \"racecar\"\n\nConstraints:\n1 <= word.length <= 100\nword contains only lowercase English letters.", + Difficulty: 260, + Tags: ["string", "two-pointers", "easy"], + Setups: + [ + new("javascript", "function reverseWord(word) {\n \n}", "reverseWord"), + new("python", "def reverse_word(word: str) -> str:\n pass", "reverse_word"), + new( + "java", + "class Solution {\n public String reverseWord(String word) {\n return null;\n }\n}", + "reverseWord" + ), + new( + "cpp", + "class Solution {\npublic:\n string reverseWord(string word) {\n return \"\";\n }\n};", + "reverseWord" + ), + ], + SampleCases: + [ + new("Example 1", [new("code", "string")], [new("edoc", "string")]), + new("Example 2", [new("racecar", "string")], [new("racecar", "string")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("algorithm", "string")], [new("mhtirogla", "string")]), + new("Hidden 2", [new("a", "string")], [new("a", "string")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def reverse_word(word: str) -> str: + return word[::-1] + """, + Parameters: + [ + new( + "word", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 20, + Charset: "abcdefghijklmnopqrstuvwxyz" + ), + ], + OutputValueType: "string", + TargetCaseCount: 20, + Seed: 44444 + ) + ), + new( + Slug: "max-of-three", + Title: "Max of Three", + Question: "Given three integers a, b, and c, return the largest value among them.\n\nExample 1:\nInput: a = 1, b = 7, c = 3\nOutput: 7\n\nExample 2:\nInput: a = -5, b = -2, c = -9\nOutput: -2\n\nConstraints:\n-10^6 <= a, b, c <= 10^6", + Difficulty: 420, + Tags: ["math", "comparison", "easy"], + Setups: + [ + new("javascript", "function maxOfThree(a, b, c) {\n \n}", "maxOfThree"), + new("python", "def max_of_three(a: int, b: int, c: int) -> int:\n pass", "max_of_three"), + new( + "java", + "class Solution {\n public int maxOfThree(int a, int b, int c) {\n return 0;\n }\n}", + "maxOfThree" + ), + new( + "cpp", + "class Solution {\npublic:\n int maxOfThree(int a, int b, int c) {\n return 0;\n }\n};", + "maxOfThree" + ), + ], + SampleCases: + [ + new( + "Example 1", + [new("1", "integer"), new("7", "integer"), new("3", "integer")], + [new("7", "integer")] + ), + new( + "Example 2", + [new("-5", "integer"), new("-2", "integer"), new("-9", "integer")], + [new("-2", "integer")] + ), + ], + HiddenCases: + [ + new( + "Hidden 1", + [new("10", "integer"), new("10", "integer"), new("9", "integer")], + [new("10", "integer")] + ), + new( + "Hidden 2", + [new("100", "integer"), new("250", "integer"), new("249", "integer")], + [new("250", "integer")] + ), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def max_of_three(a: int, b: int, c: int) -> int: + return max(a, b, c) + """, + Parameters: + [ + new( + "a", + "integer", + Min: -1_000_000, + Max: 1_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + new( + "b", + "integer", + Min: -1_000_000, + Max: 1_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + new( + "c", + "integer", + Min: -1_000_000, + Max: 1_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 55555 + ) + ), + new( + Slug: "count-vowels", + Title: "Count Vowels", + Question: "Given a string s consisting of lowercase English letters, return the number of vowels (a, e, i, o, u) in s.\n\nExample 1:\nInput: s = \"hello\"\nOutput: 2\n\nExample 2:\nInput: s = \"rhythm\"\nOutput: 0\n\nConstraints:\n1 <= s.length <= 100\ns consists of lowercase English letters only.", + Difficulty: 180, + Tags: ["string", "beginner"], + Setups: + [ + new("javascript", "function countVowels(s) {\n \n}", "countVowels"), + new("python", "def count_vowels(s: str) -> int:\n pass", "count_vowels"), + new( + "java", + "class Solution {\n public int countVowels(String s) {\n return 0;\n }\n}", + "countVowels" + ), + new( + "cpp", + "class Solution {\npublic:\n int countVowels(string s) {\n return 0;\n }\n};", + "countVowels" + ), + ], + SampleCases: + [ + new("Example 1", [new("hello", "string")], [new("2", "integer")]), + new("Example 2", [new("rhythm", "string")], [new("0", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("aeiou", "string")], [new("5", "integer")]), + new("Hidden 2", [new("bcdfg", "string")], [new("0", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def count_vowels(s: str) -> int: + return sum(1 for ch in s if ch in "aeiou") + """, + Parameters: + [ + new( + "s", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 50, + Charset: "aeioubcdfghjklmnpqrstvwxyz" + ), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 66666 + ) + ), + new( + Slug: "array-sum", + Title: "Array Sum", + Question: "Given an array of integers nums, return the sum of all elements.\n\nExample 1:\nInput: nums = [1,2,3]\nOutput: 6\n\nExample 2:\nInput: nums = [-4,5,10]\nOutput: 11\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i] <= 1000", + Difficulty: 150, + Tags: ["array", "beginner", "math"], + Setups: + [ + new("javascript", "function arraySum(nums) {\n \n}", "arraySum"), + new("python", "def array_sum(nums: list[int]) -> int:\n pass", "array_sum"), + new( + "java", + "class Solution {\n public int arraySum(int[] nums) {\n return 0;\n }\n}", + "arraySum" + ), + new( + "cpp", + "class Solution {\npublic:\n int arraySum(vector& nums) {\n return 0;\n }\n};", + "arraySum" + ), + ], + SampleCases: + [ + new("Example 1", [new("[1,2,3]", "integer_array")], [new("6", "integer")]), + new("Example 2", [new("[-4,5,10]", "integer_array")], [new("11", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[0,0,0]", "integer_array")], [new("0", "integer")]), + new("Hidden 2", [new("[100,-100,50]", "integer_array")], [new("50", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def array_sum(nums: list[int]) -> int: + return sum(nums) + """, + Parameters: + [ + new("nums", "integer_array", Min: -1000, Max: 1000, LengthMin: 1, LengthMax: 30, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 77777 + ) + ), + new( + Slug: "find-max-in-array", + Title: "Find Maximum In Array", + Question: "Given a non-empty array of integers nums, return the largest value in the array.\n\nExample 1:\nInput: nums = [3,1,4,1,5]\nOutput: 5\n\nExample 2:\nInput: nums = [-8,-2,-9]\nOutput: -2\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i] <= 1000", + Difficulty: 230, + Tags: ["array", "easy"], + Setups: + [ + new("javascript", "function findMax(nums) {\n \n}", "findMax"), + new("python", "def find_max(nums: list[int]) -> int:\n pass", "find_max"), + new( + "java", + "class Solution {\n public int findMax(int[] nums) {\n return 0;\n }\n}", + "findMax" + ), + new( + "cpp", + "class Solution {\npublic:\n int findMax(vector& nums) {\n return 0;\n }\n};", + "findMax" + ), + ], + SampleCases: + [ + new("Example 1", [new("[3,1,4,1,5]", "integer_array")], [new("5", "integer")]), + new("Example 2", [new("[-8,-2,-9]", "integer_array")], [new("-2", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[7]", "integer_array")], [new("7", "integer")]), + new("Hidden 2", [new("[0,0,0,1]", "integer_array")], [new("1", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def find_max(nums: list[int]) -> int: + return max(nums) + """, + Parameters: + [ + new("nums", "integer_array", Min: -1000, Max: 1000, LengthMin: 1, LengthMax: 30, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 88888 + ) + ), + new( + Slug: "palindrome-number", + Title: "Palindrome Number", + Question: "Given an integer n, return true if n is a palindrome (reads the same forwards and backwards), otherwise return false. Negative numbers are never palindromes.\n\nExample 1:\nInput: n = 121\nOutput: true\n\nExample 2:\nInput: n = -121\nOutput: false\n\nConstraints:\n-1000000 <= n <= 1000000", + Difficulty: 320, + Tags: ["math", "easy"], + Setups: + [ + new("javascript", "function isPalindromeNumber(n) {\n \n}", "isPalindromeNumber"), + new("python", "def is_palindrome_number(n: int) -> bool:\n pass", "is_palindrome_number"), + new( + "java", + "class Solution {\n public boolean isPalindromeNumber(int n) {\n return false;\n }\n}", + "isPalindromeNumber" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isPalindromeNumber(int n) {\n return false;\n }\n};", + "isPalindromeNumber" + ), + ], + SampleCases: + [ + new("Example 1", [new("121", "integer")], [new("true", "boolean")]), + new("Example 2", [new("-121", "integer")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer")], [new("true", "boolean")]), + new("Hidden 2", [new("123", "integer")], [new("false", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_palindrome_number(n: int) -> bool: + if n < 0: + return False + s = str(n) + return s == s[::-1] + """, + Parameters: + [ + new( + "n", + "integer", + Min: -1_000_000, + Max: 1_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 99999 + ) + ), + new( + Slug: "second-largest", + Title: "Second Largest", + Question: "Given an array of integers nums containing at least two distinct values, return the second largest distinct value.\n\nExample 1:\nInput: nums = [4,2,9,9,5]\nOutput: 5\n\nExample 2:\nInput: nums = [1,1,2]\nOutput: 1\n\nConstraints:\n2 <= nums.length <= 100\n-1000 <= nums[i] <= 1000\nnums contains at least two distinct values.", + Difficulty: 700, + Tags: ["array", "intermediate"], + Setups: + [ + new("javascript", "function secondLargest(nums) {\n \n}", "secondLargest"), + new("python", "def second_largest(nums: list[int]) -> int:\n pass", "second_largest"), + new( + "java", + "class Solution {\n public int secondLargest(int[] nums) {\n return 0;\n }\n}", + "secondLargest" + ), + new( + "cpp", + "class Solution {\npublic:\n int secondLargest(vector& nums) {\n return 0;\n }\n};", + "secondLargest" + ), + ], + SampleCases: + [ + new("Example 1", [new("[4,2,9,9,5]", "integer_array")], [new("5", "integer")]), + new("Example 2", [new("[1,1,2]", "integer_array")], [new("1", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[10,20]", "integer_array")], [new("10", "integer")]), + new("Hidden 2", [new("[-5,-1,-1,-9]", "integer_array")], [new("-5", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def second_largest(nums: list[int]) -> int: + unique = sorted(set(nums), reverse=True) + if len(unique) < 2: + raise ValueError("no second largest value exists") + return unique[1] + """, + Parameters: + [ + new("nums", "integer_array", Min: -100, Max: 100, LengthMin: 5, LengthMax: 15, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 10101 + ) + ), + new( + Slug: "multiply-two-integers", + Title: "Multiply Two Integers", + Question: "Given two integers a and b, return the product a * b.\n\nExample 1:\nInput: a = 3, b = 4\nOutput: 12\n\nExample 2:\nInput: a = -2, b = 5\nOutput: -10\n\nConstraints:\n-1000 <= a, b <= 1000", + Difficulty: 60, + Tags: ["math", "beginner", "arithmetic"], + Setups: + [ + new("javascript", "function multiplyTwoIntegers(a, b) {\n \n}", "multiplyTwoIntegers"), + new("python", "def multiply_two_integers(a: int, b: int) -> int:\n pass", "multiply_two_integers"), + new( + "java", + "class Solution {\n public int multiplyTwoIntegers(int a, int b) {\n return 0;\n }\n}", + "multiplyTwoIntegers" + ), + new( + "cpp", + "class Solution {\npublic:\n int multiplyTwoIntegers(int a, int b) {\n return 0;\n }\n};", + "multiplyTwoIntegers" + ), + ], + SampleCases: + [ + new("Example 1", [new("3", "integer"), new("4", "integer")], [new("12", "integer")]), + new("Example 2", [new("-2", "integer"), new("5", "integer")], [new("-10", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer"), new("100", "integer")], [new("0", "integer")]), + new("Hidden 2", [new("-7", "integer"), new("-8", "integer")], [new("56", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def multiply_two_integers(a: int, b: int) -> int: + return a * b + """, + Parameters: + [ + new("a", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + new("b", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 13131 + ) + ), + new( + Slug: "is-positive", + Title: "Is Positive", + Question: "Given an integer n, return true if n is strictly greater than 0, otherwise return false.\n\nExample 1:\nInput: n = 5\nOutput: true\n\nExample 2:\nInput: n = -3\nOutput: false\n\nConstraints:\n-10^9 <= n <= 10^9", + Difficulty: 40, + Tags: ["math", "comparison", "beginner"], + Setups: + [ + new("javascript", "function isPositive(n) {\n \n}", "isPositive"), + new("python", "def is_positive(n: int) -> bool:\n pass", "is_positive"), + new( + "java", + "class Solution {\n public boolean isPositive(int n) {\n return false;\n }\n}", + "isPositive" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isPositive(int n) {\n return false;\n }\n};", + "isPositive" + ), + ], + SampleCases: + [ + new("Example 1", [new("5", "integer")], [new("true", "boolean")]), + new("Example 2", [new("-3", "integer")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer")], [new("false", "boolean")]), + new("Hidden 2", [new("1", "integer")], [new("true", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_positive(n: int) -> bool: + return n > 0 + """, + Parameters: + [ + new( + "n", + "integer", + Min: -1_000_000_000, + Max: 1_000_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 14141 + ) + ), + new( + Slug: "count-consonants", + Title: "Count Consonants", + Question: "Given a string s consisting of lowercase English letters, return the number of consonants (every letter except a, e, i, o, u) in s.\n\nExample 1:\nInput: s = \"hello\"\nOutput: 3\n\nExample 2:\nInput: s = \"aeiou\"\nOutput: 0\n\nConstraints:\n1 <= s.length <= 100\ns consists of lowercase English letters only.", + Difficulty: 190, + Tags: ["string", "beginner"], + Setups: + [ + new("javascript", "function countConsonants(s) {\n \n}", "countConsonants"), + new("python", "def count_consonants(s: str) -> int:\n pass", "count_consonants"), + new( + "java", + "class Solution {\n public int countConsonants(String s) {\n return 0;\n }\n}", + "countConsonants" + ), + new( + "cpp", + "class Solution {\npublic:\n int countConsonants(string s) {\n return 0;\n }\n};", + "countConsonants" + ), + ], + SampleCases: + [ + new("Example 1", [new("hello", "string")], [new("3", "integer")]), + new("Example 2", [new("aeiou", "string")], [new("0", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("bcdfg", "string")], [new("5", "integer")]), + new("Hidden 2", [new("rhythm", "string")], [new("6", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def count_consonants(s: str) -> int: + return sum(1 for ch in s if ch not in "aeiou") + """, + Parameters: + [ + new( + "s", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 50, + Charset: "aeioubcdfghjklmnpqrstvwxyz" + ), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 15151 + ) + ), + new( + Slug: "average-of-array", + Title: "Average Of Array", + Question: "Given a non-empty array of integers nums, return the average of all elements as a floating-point number.\n\nExample 1:\nInput: nums = [1,2,3]\nOutput: 2.0\n\nExample 2:\nInput: nums = [4,8,15,16]\nOutput: 10.75\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i] <= 1000", + Difficulty: 260, + Tags: ["array", "math", "easy"], + Setups: + [ + new("javascript", "function averageOfArray(nums) {\n \n}", "averageOfArray"), + new("python", "def average_of_array(nums: list[int]) -> float:\n pass", "average_of_array"), + new( + "java", + "class Solution {\n public double averageOfArray(int[] nums) {\n return 0;\n }\n}", + "averageOfArray" + ), + new( + "cpp", + "class Solution {\npublic:\n double averageOfArray(vector& nums) {\n return 0;\n }\n};", + "averageOfArray" + ), + ], + SampleCases: + [ + new("Example 1", [new("[1,2,3]", "integer_array")], [new("2.0", "double")]), + new("Example 2", [new("[4,8,15,16]", "integer_array")], [new("10.75", "double")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[100]", "integer_array")], [new("100.0", "double")]), + new("Hidden 2", [new("[1,2]", "integer_array")], [new("1.5", "double")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def average_of_array(nums: list[int]) -> float: + return sum(nums) / len(nums) + """, + Parameters: + [ + new("nums", "integer_array", Min: -1000, Max: 1000, LengthMin: 1, LengthMax: 30, Charset: null), + ], + OutputValueType: "double", + TargetCaseCount: 20, + Seed: 16161 + ) + ), + new( + Slug: "find-min-in-array", + Title: "Find Minimum In Array", + Question: "Given a non-empty array of integers nums, return the smallest value in the array.\n\nExample 1:\nInput: nums = [3,1,4,1,5]\nOutput: 1\n\nExample 2:\nInput: nums = [-8,-2,-9]\nOutput: -9\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i] <= 1000", + Difficulty: 235, + Tags: ["array", "easy"], + Setups: + [ + new("javascript", "function findMin(nums) {\n \n}", "findMin"), + new("python", "def find_min(nums: list[int]) -> int:\n pass", "find_min"), + new( + "java", + "class Solution {\n public int findMin(int[] nums) {\n return 0;\n }\n}", + "findMin" + ), + new( + "cpp", + "class Solution {\npublic:\n int findMin(vector& nums) {\n return 0;\n }\n};", + "findMin" + ), + ], + SampleCases: + [ + new("Example 1", [new("[3,1,4,1,5]", "integer_array")], [new("1", "integer")]), + new("Example 2", [new("[-8,-2,-9]", "integer_array")], [new("-9", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[7]", "integer_array")], [new("7", "integer")]), + new("Hidden 2", [new("[0,0,0,-1]", "integer_array")], [new("-1", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def find_min(nums: list[int]) -> int: + return min(nums) + """, + Parameters: + [ + new("nums", "integer_array", Min: -1000, Max: 1000, LengthMin: 1, LengthMax: 30, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 17171 + ) + ), + new( + Slug: "is-prime", + Title: "Is Prime", + Question: "Given a positive integer n, return true if n is a prime number, otherwise return false.\n\nExample 1:\nInput: n = 7\nOutput: true\n\nExample 2:\nInput: n = 8\nOutput: false\n\nConstraints:\n1 <= n <= 1000000", + Difficulty: 560, + Tags: ["math", "number-theory", "intermediate"], + Setups: + [ + new("javascript", "function isPrime(n) {\n \n}", "isPrime"), + new("python", "def is_prime(n: int) -> bool:\n pass", "is_prime"), + new( + "java", + "class Solution {\n public boolean isPrime(int n) {\n return false;\n }\n}", + "isPrime" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isPrime(int n) {\n return false;\n }\n};", + "isPrime" + ), + ], + SampleCases: + [ + new("Example 1", [new("7", "integer")], [new("true", "boolean")]), + new("Example 2", [new("8", "integer")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("1", "integer")], [new("false", "boolean")]), + new("Hidden 2", [new("2", "integer")], [new("true", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_prime(n: int) -> bool: + if n < 2: + return False + i = 2 + while i * i <= n: + if n % i == 0: + return False + i += 1 + return True + """, + Parameters: + [ + new("n", "integer", Min: 1, Max: 1_000_000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 18181 + ) + ), + new( + Slug: "fibonacci-number", + Title: "Fibonacci Number", + Question: "Given an integer n, return the nth Fibonacci number, where fib(0) = 0, fib(1) = 1, and fib(n) = fib(n-1) + fib(n-2) for n > 1.\n\nExample 1:\nInput: n = 5\nOutput: 5\n\nExample 2:\nInput: n = 10\nOutput: 55\n\nConstraints:\n0 <= n <= 40", + Difficulty: 610, + Tags: ["math", "dynamic-programming", "intermediate"], + Setups: + [ + new("javascript", "function fibonacciNumber(n) {\n \n}", "fibonacciNumber"), + new("python", "def fibonacci_number(n: int) -> int:\n pass", "fibonacci_number"), + new( + "java", + "class Solution {\n public int fibonacciNumber(int n) {\n return 0;\n }\n}", + "fibonacciNumber" + ), + new( + "cpp", + "class Solution {\npublic:\n int fibonacciNumber(int n) {\n return 0;\n }\n};", + "fibonacciNumber" + ), + ], + SampleCases: + [ + new("Example 1", [new("5", "integer")], [new("5", "integer")]), + new("Example 2", [new("10", "integer")], [new("55", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer")], [new("0", "integer")]), + new("Hidden 2", [new("1", "integer")], [new("1", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def fibonacci_number(n: int) -> int: + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return a + """, + Parameters: [new("n", "integer", Min: 0, Max: 40, LengthMin: null, LengthMax: null, Charset: null)], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 19191 + ) + ), + new( + Slug: "string-length", + Title: "String Length", + Question: "Given a string s, return the number of characters in s.\n\nExample 1:\nInput: s = \"hello\"\nOutput: 5\n\nExample 2:\nInput: s = \"zoo\"\nOutput: 3\n\nConstraints:\n1 <= s.length <= 1000", + Difficulty: 20, + Tags: ["string", "beginner"], + Setups: + [ + new("javascript", "function stringLength(s) {\n \n}", "stringLength"), + new("python", "def string_length(s: str) -> int:\n pass", "string_length"), + new( + "java", + "class Solution {\n public int stringLength(String s) {\n return 0;\n }\n}", + "stringLength" + ), + new( + "cpp", + "class Solution {\npublic:\n int stringLength(string s) {\n return 0;\n }\n};", + "stringLength" + ), + ], + SampleCases: + [ + new("Example 1", [new("hello", "string")], [new("5", "integer")]), + new("Example 2", [new("zoo", "string")], [new("3", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("a", "string")], [new("1", "integer")]), + new("Hidden 2", [new("algorithm", "string")], [new("9", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def string_length(s: str) -> int: + return len(s) + """, + Parameters: + [ + new( + "s", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 50, + Charset: "abcdefghijklmnopqrstuvwxyz" + ), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 20202 + ) + ), + new( + Slug: "contains-duplicate", + Title: "Contains Duplicate", + Question: "Given an array of integers nums, return true if any value appears at least twice in the array, and return false if every element is distinct.\n\nExample 1:\nInput: nums = [1,2,3,1]\nOutput: true\n\nExample 2:\nInput: nums = [1,2,3,4]\nOutput: false\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i] <= 1000", + Difficulty: 350, + Tags: ["array", "hash-table", "easy"], + Setups: + [ + new("javascript", "function containsDuplicate(nums) {\n \n}", "containsDuplicate"), + new("python", "def contains_duplicate(nums: list[int]) -> bool:\n pass", "contains_duplicate"), + new( + "java", + "class Solution {\n public boolean containsDuplicate(int[] nums) {\n return false;\n }\n}", + "containsDuplicate" + ), + new( + "cpp", + "class Solution {\npublic:\n bool containsDuplicate(vector& nums) {\n return false;\n }\n};", + "containsDuplicate" + ), + ], + SampleCases: + [ + new("Example 1", [new("[1,2,3,1]", "integer_array")], [new("true", "boolean")]), + new("Example 2", [new("[1,2,3,4]", "integer_array")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[5]", "integer_array")], [new("false", "boolean")]), + new("Hidden 2", [new("[0,0]", "integer_array")], [new("true", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def contains_duplicate(nums: list[int]) -> bool: + return len(nums) != len(set(nums)) + """, + Parameters: + [ + new("nums", "integer_array", Min: -50, Max: 50, LengthMin: 1, LengthMax: 20, Charset: null), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 21212 + ) + ), + new( + Slug: "capitalize-first-letter", + Title: "Capitalize First Letter", + Question: "Given a non-empty string word consisting of lowercase English letters, return a new string with the first letter capitalized and the rest unchanged.\n\nExample 1:\nInput: word = \"apple\"\nOutput: \"Apple\"\n\nExample 2:\nInput: word = \"zoo\"\nOutput: \"Zoo\"\n\nConstraints:\n1 <= word.length <= 100\nword consists of lowercase English letters only.", + Difficulty: 210, + Tags: ["string", "easy"], + Setups: + [ + new("javascript", "function capitalizeFirstLetter(word) {\n \n}", "capitalizeFirstLetter"), + new("python", "def capitalize_first_letter(word: str) -> str:\n pass", "capitalize_first_letter"), + new( + "java", + "class Solution {\n public String capitalizeFirstLetter(String word) {\n return null;\n }\n}", + "capitalizeFirstLetter" + ), + new( + "cpp", + "class Solution {\npublic:\n string capitalizeFirstLetter(string word) {\n return \"\";\n }\n};", + "capitalizeFirstLetter" + ), + ], + SampleCases: + [ + new("Example 1", [new("apple", "string")], [new("Apple", "string")]), + new("Example 2", [new("zoo", "string")], [new("Zoo", "string")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("a", "string")], [new("A", "string")]), + new("Hidden 2", [new("algorithm", "string")], [new("Algorithm", "string")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def capitalize_first_letter(word: str) -> str: + return word[0].upper() + word[1:] + """, + Parameters: + [ + new( + "word", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 30, + Charset: "abcdefghijklmnopqrstuvwxyz" + ), + ], + OutputValueType: "string", + TargetCaseCount: 20, + Seed: 23232 + ) + ), + new( + Slug: "absolute-difference", + Title: "Absolute Difference", + Question: "Given two integers a and b, return the absolute value of their difference.\n\nExample 1:\nInput: a = 10, b = 3\nOutput: 7\n\nExample 2:\nInput: a = -5, b = 5\nOutput: 10\n\nConstraints:\n-1000 <= a, b <= 1000", + Difficulty: 65, + Tags: ["math", "beginner", "arithmetic"], + Setups: + [ + new("javascript", "function absoluteDifference(a, b) {\n \n}", "absoluteDifference"), + new("python", "def absolute_difference(a: int, b: int) -> int:\n pass", "absolute_difference"), + new( + "java", + "class Solution {\n public int absoluteDifference(int a, int b) {\n return 0;\n }\n}", + "absoluteDifference" + ), + new( + "cpp", + "class Solution {\npublic:\n int absoluteDifference(int a, int b) {\n return 0;\n }\n};", + "absoluteDifference" + ), + ], + SampleCases: + [ + new("Example 1", [new("10", "integer"), new("3", "integer")], [new("7", "integer")]), + new("Example 2", [new("-5", "integer"), new("5", "integer")], [new("10", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer"), new("0", "integer")], [new("0", "integer")]), + new("Hidden 2", [new("-1000", "integer"), new("1000", "integer")], [new("2000", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def absolute_difference(a: int, b: int) -> int: + return abs(a - b) + """, + Parameters: + [ + new("a", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + new("b", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 24242 + ) + ), + new( + Slug: "celsius-to-fahrenheit", + Title: "Celsius To Fahrenheit", + Question: "Given a temperature celsius in degrees Celsius, return the equivalent temperature in degrees Fahrenheit using the formula F = (C * 9/5) + 32.\n\nExample 1:\nInput: celsius = 0\nOutput: 32.0\n\nExample 2:\nInput: celsius = 100\nOutput: 212.0\n\nConstraints:\n-273 <= celsius <= 1000", + Difficulty: 85, + Tags: ["math", "conversion", "beginner"], + Setups: + [ + new("javascript", "function celsiusToFahrenheit(celsius) {\n \n}", "celsiusToFahrenheit"), + new("python", "def celsius_to_fahrenheit(celsius: float) -> float:\n pass", "celsius_to_fahrenheit"), + new( + "java", + "class Solution {\n public double celsiusToFahrenheit(double celsius) {\n return 0;\n }\n}", + "celsiusToFahrenheit" + ), + new( + "cpp", + "class Solution {\npublic:\n double celsiusToFahrenheit(double celsius) {\n return 0;\n }\n};", + "celsiusToFahrenheit" + ), + ], + SampleCases: + [ + new("Example 1", [new("0", "double")], [new("32.0", "double")]), + new("Example 2", [new("100", "double")], [new("212.0", "double")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("-40", "double")], [new("-40.0", "double")]), + new("Hidden 2", [new("37", "double")], [new("98.6", "double")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def celsius_to_fahrenheit(celsius: float) -> float: + return celsius * 9 / 5 + 32 + """, + Parameters: + [ + new("celsius", "double", Min: -273, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "double", + TargetCaseCount: 20, + Seed: 25252 + ) + ), + new( + Slug: FactorialFunctionName, + Title: "Factorial", + Question: "Given a non-negative integer n, return n! (n factorial), the product of all positive integers less than or equal to n. By definition, 0! = 1.\n\nExample 1:\nInput: n = 5\nOutput: 120\n\nExample 2:\nInput: n = 0\nOutput: 1\n\nConstraints:\n0 <= n <= 12", + Difficulty: 240, + Tags: ["math", "recursion", "easy"], + Setups: + [ + new("javascript", "function factorial(n) {\n \n}", FactorialFunctionName), + new("python", "def factorial(n: int) -> int:\n pass", FactorialFunctionName), + new( + "java", + "class Solution {\n public int factorial(int n) {\n return 0;\n }\n}", + FactorialFunctionName + ), + new( + "cpp", + "class Solution {\npublic:\n int factorial(int n) {\n return 0;\n }\n};", + FactorialFunctionName + ), + ], + SampleCases: + [ + new("Example 1", [new("5", "integer")], [new("120", "integer")]), + new("Example 2", [new("0", "integer")], [new("1", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("1", "integer")], [new("1", "integer")]), + new("Hidden 2", [new("10", "integer")], [new("3628800", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def factorial(n: int) -> int: + result = 1 + for i in range(2, n + 1): + result *= i + return result + """, + Parameters: [new("n", "integer", Min: 0, Max: 12, LengthMin: null, LengthMax: null, Charset: null)], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 26262 + ) + ), + new( + Slug: "is-leap-year", + Title: "Is Leap Year", + Question: "Given an integer year, return true if year is a leap year in the Gregorian calendar, otherwise return false. A year is a leap year if it is divisible by 4, except for years divisible by 100 unless also divisible by 400.\n\nExample 1:\nInput: year = 2024\nOutput: true\n\nExample 2:\nInput: year = 1900\nOutput: false\n\nConstraints:\n1 <= year <= 9999", + Difficulty: 150, + Tags: ["math", "conditionals", "beginner"], + Setups: + [ + new("javascript", "function isLeapYear(year) {\n \n}", "isLeapYear"), + new("python", "def is_leap_year(year: int) -> bool:\n pass", "is_leap_year"), + new( + "java", + "class Solution {\n public boolean isLeapYear(int year) {\n return false;\n }\n}", + "isLeapYear" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isLeapYear(int year) {\n return false;\n }\n};", + "isLeapYear" + ), + ], + SampleCases: + [ + new("Example 1", [new("2024", "integer")], [new("true", "boolean")]), + new("Example 2", [new("1900", "integer")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("2000", "integer")], [new("true", "boolean")]), + new("Hidden 2", [new("2023", "integer")], [new("false", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_leap_year(year: int) -> bool: + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + """, + Parameters: + [ + new("year", "integer", Min: 1, Max: 9999, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 27272 + ) + ), + new( + Slug: "digit-sum", + Title: "Digit Sum", + Question: "Given a non-negative integer n, return the sum of its digits.\n\nExample 1:\nInput: n = 1234\nOutput: 10\n\nExample 2:\nInput: n = 7\nOutput: 7\n\nConstraints:\n0 <= n <= 1000000000", + Difficulty: 175, + Tags: ["math", "string", "beginner"], + Setups: + [ + new("javascript", "function digitSum(n) {\n \n}", "digitSum"), + new("python", "def digit_sum(n: int) -> int:\n pass", "digit_sum"), + new( + "java", + "class Solution {\n public int digitSum(int n) {\n return 0;\n }\n}", + "digitSum" + ), + new( + "cpp", + "class Solution {\npublic:\n int digitSum(int n) {\n return 0;\n }\n};", + "digitSum" + ), + ], + SampleCases: + [ + new("Example 1", [new("1234", "integer")], [new("10", "integer")]), + new("Example 2", [new("7", "integer")], [new("7", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("0", "integer")], [new("0", "integer")]), + new("Hidden 2", [new("999999", "integer")], [new("54", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def digit_sum(n: int) -> int: + return sum(int(d) for d in str(n)) + """, + Parameters: + [ + new("n", "integer", Min: 0, Max: 1_000_000_000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 28282 + ) + ), + new( + Slug: "count-occurrences", + Title: "Count Occurrences", + Question: "Given an array of integers nums and an integer target, return the number of times target appears in nums.\n\nExample 1:\nInput: nums = [1,2,2,3,2], target = 2\nOutput: 3\n\nExample 2:\nInput: nums = [5,5,5], target = 1\nOutput: 0\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i], target <= 1000", + Difficulty: 165, + Tags: ["array", "easy"], + Setups: + [ + new("javascript", "function countOccurrences(nums, target) {\n \n}", "countOccurrences"), + new( + "python", + "def count_occurrences(nums: list[int], target: int) -> int:\n pass", + "count_occurrences" + ), + new( + "java", + "class Solution {\n public int countOccurrences(int[] nums, int target) {\n return 0;\n }\n}", + "countOccurrences" + ), + new( + "cpp", + "class Solution {\npublic:\n int countOccurrences(vector& nums, int target) {\n return 0;\n }\n};", + "countOccurrences" + ), + ], + SampleCases: + [ + new("Example 1", [new("[1,2,2,3,2]", "integer_array"), new("2", "integer")], [new("3", "integer")]), + new("Example 2", [new("[5,5,5]", "integer_array"), new("1", "integer")], [new("0", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[0]", "integer_array"), new("0", "integer")], [new("1", "integer")]), + new("Hidden 2", [new("[-1,-1,2,3]", "integer_array"), new("-1", "integer")], [new("2", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def count_occurrences(nums: list[int], target: int) -> int: + return nums.count(target) + """, + Parameters: + [ + new("nums", "integer_array", Min: -20, Max: 20, LengthMin: 1, LengthMax: 30, Charset: null), + new("target", "integer", Min: -20, Max: 20, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 29292 + ) + ), + new( + Slug: "sum-of-even-numbers", + Title: "Sum Of Even Numbers", + Question: "Given an array of integers nums, return the sum of all even numbers in the array.\n\nExample 1:\nInput: nums = [1,2,3,4,5,6]\nOutput: 12\n\nExample 2:\nInput: nums = [1,3,5]\nOutput: 0\n\nConstraints:\n1 <= nums.length <= 100\n-1000 <= nums[i] <= 1000", + Difficulty: 195, + Tags: ["array", "math", "easy"], + Setups: + [ + new("javascript", "function sumOfEvenNumbers(nums) {\n \n}", "sumOfEvenNumbers"), + new("python", "def sum_of_even_numbers(nums: list[int]) -> int:\n pass", "sum_of_even_numbers"), + new( + "java", + "class Solution {\n public int sumOfEvenNumbers(int[] nums) {\n return 0;\n }\n}", + "sumOfEvenNumbers" + ), + new( + "cpp", + "class Solution {\npublic:\n int sumOfEvenNumbers(vector& nums) {\n return 0;\n }\n};", + "sumOfEvenNumbers" + ), + ], + SampleCases: + [ + new("Example 1", [new("[1,2,3,4,5,6]", "integer_array")], [new("12", "integer")]), + new("Example 2", [new("[1,3,5]", "integer_array")], [new("0", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("[2,4,6]", "integer_array")], [new("12", "integer")]), + new("Hidden 2", [new("[-2,-3,5]", "integer_array")], [new("-2", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def sum_of_even_numbers(nums: list[int]) -> int: + return sum(n for n in nums if n % 2 == 0) + """, + Parameters: + [ + new("nums", "integer_array", Min: -1000, Max: 1000, LengthMin: 1, LengthMax: 30, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 30303 + ) + ), + new( + Slug: "power-of-two", + Title: "Power Of Two", + Question: "Given an integer n, return true if n is a power of two (that is, n = 2^k for some non-negative integer k), otherwise return false.\n\nExample 1:\nInput: n = 16\nOutput: true\n\nExample 2:\nInput: n = 18\nOutput: false\n\nConstraints:\n-1000000 <= n <= 1000000", + Difficulty: 250, + Tags: ["math", "bit-manipulation", "easy"], + Setups: + [ + new("javascript", "function isPowerOfTwo(n) {\n \n}", "isPowerOfTwo"), + new("python", "def is_power_of_two(n: int) -> bool:\n pass", "is_power_of_two"), + new( + "java", + "class Solution {\n public boolean isPowerOfTwo(int n) {\n return false;\n }\n}", + "isPowerOfTwo" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isPowerOfTwo(int n) {\n return false;\n }\n};", + "isPowerOfTwo" + ), + ], + SampleCases: + [ + new("Example 1", [new("16", "integer")], [new("true", "boolean")]), + new("Example 2", [new("18", "integer")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("1", "integer")], [new("true", "boolean")]), + new("Hidden 2", [new("0", "integer")], [new("false", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_power_of_two(n: int) -> bool: + return n > 0 and (n & (n - 1)) == 0 + """, + Parameters: + [ + new( + "n", + "integer", + Min: -1_000_000, + Max: 1_000_000, + LengthMin: null, + LengthMax: null, + Charset: null + ), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 31313 + ) + ), + new( + Slug: "gcd-of-two-numbers", + Title: "GCD Of Two Numbers", + Question: "Given two positive integers a and b, return their greatest common divisor (GCD).\n\nExample 1:\nInput: a = 12, b = 18\nOutput: 6\n\nExample 2:\nInput: a = 17, b = 5\nOutput: 1\n\nConstraints:\n1 <= a, b <= 1000000", + Difficulty: 330, + Tags: ["math", "number-theory", "easy"], + Setups: + [ + new("javascript", "function gcdOfTwoNumbers(a, b) {\n \n}", "gcdOfTwoNumbers"), + new("python", "def gcd_of_two_numbers(a: int, b: int) -> int:\n pass", "gcd_of_two_numbers"), + new( + "java", + "class Solution {\n public int gcdOfTwoNumbers(int a, int b) {\n return 0;\n }\n}", + "gcdOfTwoNumbers" + ), + new( + "cpp", + "class Solution {\npublic:\n int gcdOfTwoNumbers(int a, int b) {\n return 0;\n }\n};", + "gcdOfTwoNumbers" + ), + ], + SampleCases: + [ + new("Example 1", [new("12", "integer"), new("18", "integer")], [new("6", "integer")]), + new("Example 2", [new("17", "integer"), new("5", "integer")], [new("1", "integer")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("100", "integer"), new("75", "integer")], [new("25", "integer")]), + new("Hidden 2", [new("7", "integer"), new("7", "integer")], [new("7", "integer")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def gcd_of_two_numbers(a: int, b: int) -> int: + while b: + a, b = b, a % b + return a + """, + Parameters: + [ + new("a", "integer", Min: 1, Max: 1_000_000, LengthMin: null, LengthMax: null, Charset: null), + new("b", "integer", Min: 1, Max: 1_000_000, LengthMin: null, LengthMax: null, Charset: null), + ], + OutputValueType: "integer", + TargetCaseCount: 20, + Seed: 32323 + ) + ), + new( + Slug: "valid-anagram", + Title: "Valid Anagram", + Question: "Given two strings s and t, return true if t is an anagram of s (the same characters with the same frequencies, in any order), otherwise return false.\n\nExample 1:\nInput: s = \"listen\", t = \"silent\"\nOutput: true\n\nExample 2:\nInput: s = \"rat\", t = \"car\"\nOutput: false\n\nConstraints:\n1 <= s.length, t.length <= 30\ns and t consist of lowercase English letters.", + Difficulty: 390, + Tags: ["string", "hash-table", "easy"], + Setups: + [ + new("javascript", "function isAnagram(s, t) {\n \n}", "isAnagram"), + new("python", "def is_anagram(s: str, t: str) -> bool:\n pass", "is_anagram"), + new( + "java", + "class Solution {\n public boolean isAnagram(String s, String t) {\n return false;\n }\n}", + "isAnagram" + ), + new( + "cpp", + "class Solution {\npublic:\n bool isAnagram(string s, string t) {\n return false;\n }\n};", + "isAnagram" + ), + ], + SampleCases: + [ + new("Example 1", [new("listen", "string"), new("silent", "string")], [new("true", "boolean")]), + new("Example 2", [new("rat", "string"), new("car", "string")], [new("false", "boolean")]), + ], + HiddenCases: + [ + new("Hidden 1", [new("a", "string"), new("a", "string")], [new("true", "boolean")]), + new("Hidden 2", [new("ab", "string"), new("a", "string")], [new("false", "boolean")]), + ], + Generation: new TestCaseGenerationSeed( + ReferenceSolutionCode: """ + def is_anagram(s: str, t: str) -> bool: + return sorted(s) == sorted(t) + """, + Parameters: + [ + new( + "s", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 15, + Charset: "abcdefghijklmnopqrstuvwxyz" + ), + new( + "t", + "string", + Min: null, + Max: null, + LengthMin: 1, + LengthMax: 15, + Charset: "abcdefghijklmnopqrstuvwxyz" + ), + ], + OutputValueType: "boolean", + TargetCaseCount: 20, + Seed: 34343 + ) + ), + ]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + Dictionary versionsBySlug = new(StringComparer.OrdinalIgnoreCase) + { + ["javascript"] = await GetVersionAsync("javascript", cancellationToken), + ["python"] = await GetVersionAsync("python", cancellationToken), + ["java"] = await GetVersionAsync("java", cancellationToken), + ["cpp"] = await GetVersionAsync("cpp", cancellationToken), + }; + + foreach (ProblemSeedDefinition problemSeed in ProblemSeeds) + { + Guid problemId = await EnsureProblemWithSetupsAsync( + problemSeed, + versionsBySlug, + pipelineId, + cancellationToken + ); + await EnsureTestSuitesLinkedAsync(problemId, problemSeed, cancellationToken); + await EnsureTagsLinkedAsync(problemId, problemSeed.Tags, cancellationToken); + + if (problemSeed.Generation is not null) + { + context.ChangeTracker.Clear(); + await EnsureGeneratedTestCasesAsync(problemId, problemSeed, cancellationToken); + } + } + } + + private async Task EnsureGeneratedTestCasesAsync( + Guid problemId, + ProblemSeedDefinition problemSeed, + CancellationToken cancellationToken + ) + { + TestCaseGenerationSeed generation = problemSeed.Generation!; + + LanguageVersionEntry pyVersion = await GetVersionAsync("python", cancellationToken); + + var setupInfo = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId && s.LanguageVersionId == pyVersion.Id) + .Select(s => new { s.Id, s.GenerationSpecId }) + .FirstOrDefaultAsync(cancellationToken); + + if (setupInfo is null) + return; + + await TestCaseGenerationSeederHelper.EnqueueIfNeededAsync( + context, + testCaseGenerationJobRepository, + problemSeed.Title, + setupInfo.Id, + setupInfo.GenerationSpecId, + generation.ReferenceSolutionCode, + generation.Parameters, + generation.OutputValueType, + generation.TargetCaseCount, + generation.Seed, + cancellationToken + ); + + context.ChangeTracker.Clear(); + } + + private async Task EnsureProblemWithSetupsAsync( + ProblemSeedDefinition problemSeed, + IReadOnlyDictionary versionsBySlug, + Guid pipelineId, + CancellationToken cancellationToken + ) + { + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(problem => problem.Slug.Value == problemSeed.Slug) + .Select(problem => (Guid?)problem.Id) + .FirstOrDefaultAsync(cancellationToken); + + List<(Guid versionId, string code, string functionName)> desiredSetups = + [ + .. problemSeed.Setups.Select(setup => + (versionsBySlug[setup.LanguageSlug].Id, setup.Code, setup.FunctionName) + ), + ]; + + if (existingId is null) + { + Problem problem = new( + new Slug(problemSeed.Slug), + new Title(problemSeed.Title), + new Question(problemSeed.Question), + new Difficulty(problemSeed.Difficulty), + new TimeLimit(1000), + new MemoryLimit(64) + ); + + problem.Publish(); + + foreach ((Guid versionId, string code, string functionName) in desiredSetups) + problem.AddSetup(versionId, code, functionName, pipelineId); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + Guid problemId = existingId.Value; + + HashSet existingVersionIds = await context + .Set() + .AsNoTracking() + .Where(setup => EF.Property(setup, "problem_id") == problemId) + .Select(setup => setup.LanguageVersionId) + .ToHashSetAsync(cancellationToken); + + List<(Guid versionId, string code, string functionName)> missingSetups = desiredSetups + .Where(template => !existingVersionIds.Contains(template.versionId)) + .ToList(); + + foreach ((Guid versionId, string code, string functionName) in missingSetups) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id) + VALUES ({Guid.NewGuid()}, {problemId}, {versionId}, {code}, {functionName}, {pipelineId}) + """, + cancellationToken + ); + } + + return problemId; + } + + private async Task EnsureTestSuitesLinkedAsync( + Guid problemId, + ProblemSeedDefinition problemSeed, + CancellationToken cancellationToken + ) + { + TestSuite[] suites = + [ + BuildSuite($"{problemSeed.Title} - Sample Cases", TestSuiteType.Sample, problemSeed.SampleCases), + BuildSuite($"{problemSeed.Title} - Hidden Cases", TestSuiteType.Hidden, problemSeed.HiddenCases), + ]; + + foreach (TestSuite suite in suites) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(testSuite => testSuite.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(setup => EF.Property(setup, "problem_id") == problemId) + .Select(setup => setup.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync( + Guid problemId, + IReadOnlyCollection tags, + CancellationToken cancellationToken + ) + { + foreach (string tagName in tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private static TestSuite BuildSuite(string name, TestSuiteType suiteType, IReadOnlyCollection cases) + { + TestSuite suite = new(name, suiteType); + + foreach (TestCaseSeed testCaseSeed in cases) + { + TestCase testCase = suite.AddTestCase(testCaseSeed.Name); + + foreach (TestCaseValue input in testCaseSeed.Inputs) + testCase.AddInput(input.Value, input.ValueType); + + foreach (TestCaseValue expectedOutput in testCaseSeed.ExpectedOutputs) + testCase.AddExpectedOutput(expectedOutput.Value, expectedOutput.ValueType); + } + + return suite; + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.Where(language => language.Slug == new LanguageSlug(slug)) + .SelectMany(language => language.Versions) + .OrderBy(version => version.Version) + .FirstAsync(cancellationToken); + + private sealed record SetupSeed(string LanguageSlug, string Code, string FunctionName); + + private sealed record TestCaseValue(string Value, string ValueType); + + private sealed record TestCaseSeed( + string Name, + IReadOnlyCollection Inputs, + IReadOnlyCollection ExpectedOutputs + ); + + private sealed record TestCaseGenerationSeed( + string ReferenceSolutionCode, + IReadOnlyList Parameters, + string OutputValueType, + int TargetCaseCount, + int Seed + ); + + private sealed record ProblemSeedDefinition( + string Slug, + string Title, + string Question, + int Difficulty, + IReadOnlyCollection Tags, + IReadOnlyCollection Setups, + IReadOnlyCollection SampleCases, + IReadOnlyCollection HiddenCases, + TestCaseGenerationSeed? Generation = null + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/ArtistDiscographyProblemSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/ArtistDiscographyProblemSeeder.cs new file mode 100644 index 0000000..889ebd5 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/ArtistDiscographyProblemSeeder.cs @@ -0,0 +1,307 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class ArtistDiscographyProblemSeeder(StackDuelDbContext context, Judge0PipelineSeeder pipelineSeeder) + : IStaticSeeder +{ + private const string ProblemSlug = "artist-discography"; + private const string FunctionName = "ArtistList"; + + private static readonly string[] Tags = ["react", "state", "lists", "components"]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid problemId = await EnsureProblemWithSetupsAsync(cancellationToken); + await EnsureTestSuitesLinkedAsync(problemId, cancellationToken); + await EnsureTagsLinkedAsync(problemId, cancellationToken); + } + + private async Task EnsureProblemWithSetupsAsync(CancellationToken cancellationToken) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry reactVersion = await GetVersionAsync("react", cancellationToken); + + context.ChangeTracker.Clear(); + + const string starterCode = """ + const { RecordItem } = require("./RecordItem"); + + function ArtistList({ artists }) { + return null; + } + """; + + (Guid versionId, string code, string funcName)[] desiredSetups = [(reactVersion.Id, starterCode, FunctionName)]; + + ProblemSetupFile[] additionalFiles = + [ + new( + "RecordItem.jsx", + """ + function RecordItem({ record }) { + return ( +
  • + {record.title} ({record.year}) +
  • + ); + } + + module.exports.RecordItem = RecordItem; + """ + ), + ]; + + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == ProblemSlug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(ProblemSlug), + new Title("Artist Discography"), + new Question( + """ + Implement a React component named `ArtistList` that receives an `artists` prop — an array of `{ id, name, records }` objects, where `records` is an array of `{ id, title, year }` objects — and renders each artist with their records collapsed by default, expandable independently on click. + + ### Requirements + + - Render a `
    ` wrapping one entry per artist. + - Each artist entry needs a ` + ``` + + ### Example 2 + + ``` + Input: artists = [{ id: "a1", name: "Radiohead", records: [{ id: "r1", title: "OK Computer", year: 1997 }] }] + After clicking "Radiohead": adds
    • OK Computer (1997)
    + ``` + """ + ), + new Difficulty(600), + new TimeLimit(2000), + new MemoryLimit(64) + ); + + foreach ((Guid versionId, string code, string funcName) in desiredSetups) + { + ProblemSetup setup = problem.AddSetup(versionId, code, funcName, pipelineId); + setup.SetAdditionalFiles(additionalFiles); + } + + problem.Archive(); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + Guid problemId = existingId.Value; + + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problems SET status = 3 WHERE id = {problemId} AND status <> 3 + """, + cancellationToken + ); + + HashSet existingVersionIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.LanguageVersionId) + .ToHashSetAsync(cancellationToken); + + List<(Guid versionId, string code, string funcName)> missing = desiredSetups + .Where(d => !existingVersionIds.Contains(d.versionId)) + .ToList(); + + if (missing.Count > 0) + { + foreach ((Guid versionId, string code, string funcName) in missing) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id, additional_files) + VALUES ({Guid.NewGuid()}, {problemId}, {versionId}, {code}, {funcName}, {pipelineId}, {System.Text.Json.JsonSerializer.Serialize( + additionalFiles, + (System.Text.Json.JsonSerializerOptions?)null + )}) + """, + cancellationToken + ); + } + } + + ProblemSetup? reactSetup = await context + .Set() + .Where(s => EF.Property(s, "problem_id") == problemId && s.LanguageVersionId == reactVersion.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (reactSetup is not null) + { + reactSetup.UpdateInitialCode(starterCode); + reactSetup.SetAdditionalFiles(additionalFiles); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + } + + return existingId.Value; + } + + private async Task EnsureTestSuitesLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + TestSuite[] desired = [BuildSampleSuite(), BuildHiddenSuite()]; + + foreach (TestSuite suite in desired) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + foreach (string tagName in Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.IgnoreQueryFilters() + .Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + private const string TwoArtistsProps = + """{"artists":[{"id":"a1","name":"Radiohead","records":[{"id":"r1","title":"OK Computer","year":1997},{"id":"r2","title":"Kid A","year":2000}]},{"id":"a2","name":"Portishead","records":[{"id":"r3","title":"Dummy","year":1994}]}]}"""; + + private const string ThreeArtistsProps = + """{"artists":[{"id":"b1","name":"Fontaines D.C.","records":[{"id":"r1","title":"Dogrel","year":2019}]},{"id":"b2","name":"black midi","records":[]},{"id":"b3","name":"Black Country, New Road","records":[{"id":"r2","title":"Ants From Up There","year":2022},{"id":"r3","title":"For the first time","year":2021}]}]}"""; + + // Expected outputs are the exact stdout produced by the harness (a normalized JSON + // render-tree) for a correct reference solution — generated by actually running the + // real strategy + harness rather than hand-typed, since the grading comparison + // (SetEquality) reformats whitespace but does not reorder JSON object keys. + private static TestSuite BuildSampleSuite() + { + TestSuite suite = new("Artist Discography - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Collapsed by default"); + case1.AddInput(TwoArtistsProps, "props"); + case1.AddInput("[]", "actions"); + case1.AddExpectedOutput( + """{"type":"div","props":{"data-testid":"artist-list"},"children":[{"type":"div","props":{"data-testid":"artist-a1"},"children":[{"type":"button","props":{"data-testid":"toggle-a1"},"children":["Radiohead"]}]},{"type":"div","props":{"data-testid":"artist-a2"},"children":[{"type":"button","props":{"data-testid":"toggle-a2"},"children":["Portishead"]}]}]}""", + "json" + ); + + TestCase case2 = suite.AddTestCase("Expands on click"); + case2.AddInput(TwoArtistsProps, "props"); + case2.AddInput("""[{"testId":"toggle-a1","type":"click"}]""", "actions"); + case2.AddExpectedOutput( + """{"type":"div","props":{"data-testid":"artist-list"},"children":[{"type":"div","props":{"data-testid":"artist-a1"},"children":[{"type":"button","props":{"data-testid":"toggle-a1"},"children":["Radiohead"]},{"type":"ul","props":{"data-testid":"records-a1"},"children":[{"type":"li","props":{"data-testid":"record-r1"},"children":["OK Computer"," (","1997",")"]},{"type":"li","props":{"data-testid":"record-r2"},"children":["Kid A"," (","2000",")"]}]}]},{"type":"div","props":{"data-testid":"artist-a2"},"children":[{"type":"button","props":{"data-testid":"toggle-a2"},"children":["Portishead"]}]}]}""", + "json" + ); + + return suite; + } + + private static TestSuite BuildHiddenSuite() + { + TestSuite suite = new("Artist Discography - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Toggle twice collapses again"); + case1.AddInput(TwoArtistsProps, "props"); + case1.AddInput("""[{"testId":"toggle-a1","type":"click"},{"testId":"toggle-a1","type":"click"}]""", "actions"); + case1.AddExpectedOutput( + """{"type":"div","props":{"data-testid":"artist-list"},"children":[{"type":"div","props":{"data-testid":"artist-a1"},"children":[{"type":"button","props":{"data-testid":"toggle-a1"},"children":["Radiohead"]}]},{"type":"div","props":{"data-testid":"artist-a2"},"children":[{"type":"button","props":{"data-testid":"toggle-a2"},"children":["Portishead"]}]}]}""", + "json" + ); + + TestCase case2 = suite.AddTestCase("Independent expansion and empty records"); + case2.AddInput(ThreeArtistsProps, "props"); + case2.AddInput( + """[{"testId":"toggle-b1","type":"click"},{"testId":"toggle-b2","type":"click"},{"testId":"toggle-b3","type":"click"}]""", + "actions" + ); + case2.AddExpectedOutput( + """{"type":"div","props":{"data-testid":"artist-list"},"children":[{"type":"div","props":{"data-testid":"artist-b1"},"children":[{"type":"button","props":{"data-testid":"toggle-b1"},"children":["Fontaines D.C."]},{"type":"ul","props":{"data-testid":"records-b1"},"children":[{"type":"li","props":{"data-testid":"record-r1"},"children":["Dogrel"," (","2019",")"]}]}]},{"type":"div","props":{"data-testid":"artist-b2"},"children":[{"type":"button","props":{"data-testid":"toggle-b2"},"children":["black midi"]},{"type":"ul","props":{"data-testid":"records-b2"},"children":[]}]},{"type":"div","props":{"data-testid":"artist-b3"},"children":[{"type":"button","props":{"data-testid":"toggle-b3"},"children":["Black Country, New Road"]},{"type":"ul","props":{"data-testid":"records-b3"},"children":[{"type":"li","props":{"data-testid":"record-r2"},"children":["Ants From Up There"," (","2022",")"]},{"type":"li","props":{"data-testid":"record-r3"},"children":["For the first time"," (","2021",")"]}]}]}]}""", + "json" + ); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/ChinookSqlProblemsSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/ChinookSqlProblemsSeeder.cs new file mode 100644 index 0000000..68ec4cc --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/ChinookSqlProblemsSeeder.cs @@ -0,0 +1,293 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; +using System.Reflection; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class ChinookSqlProblemsSeeder( + StackDuelDbContext context, + Judge0PipelineSeeder pipelineSeeder, + AdditionalFileBundleSeeder additionalFileBundleSeeder +) : IStaticSeeder +{ + private const string BundleName = "chinook-sqlite-db"; + private const string ResourceName = "StackDuel.Infrastructure.Persistence.Seeders.Assets.chinook-sqlite.zip"; + + private const string ArtistsTableDescription = """ + | Column | Type | Description | + |----------|---------|--------------------------| + | ArtistId | INTEGER | Primary key | + | Name | TEXT | Artist name (nullable) | + """; + + private const string InitialCode = """ + -- Write your SQLite query statement below + + """; + + private static readonly ProblemSeedDefinition[] ProblemSeeds = + [ + new( + Slug: "artists-starting-with-a-sql", + Title: "Artists Starting With A", + Question: $""" + Write a query that returns the names of all artists whose name starts with 'A', ordered by `ArtistId`. + + The `Artists` table: + + {ArtistsTableDescription} + """, + Difficulty: 200, + Tags: ["sql", "filtering"], + ExpectedOutput: """ + AC/DC + Accept + Aerosmith + Alanis Morissette + Alice In Chains + Antônio Carlos Jobim + Apocalyptica + Audioslave + Azymuth + A Cor Do Som + Aquaman + Aerosmith & Sierra Leone's Refugee Allstars + Avril Lavigne + Aisha Duo + Aaron Goldberg + Alberto Turco & Nova Schola Gregoriana + Anne-Sophie Mutter, Herbert Von Karajan & Wiener Philharmoniker + Academy of St. Martin in the Fields & Sir Neville Marriner + Academy of St. Martin in the Fields Chamber Ensemble & Sir Neville Marriner + Academy of St. Martin in the Fields, John Birch, Sir Neville Marriner & Sylvia McNair + Aaron Copland & London Symphony Orchestra + Academy of St. Martin in the Fields, Sir Neville Marriner & William Bennett + Antal Doráti & London Symphony Orchestra + Amy Winehouse + Academy of St. Martin in the Fields, Sir Neville Marriner & Thurston Dart + Adrian Leaper & Doreen de Feis + """ + ), + new( + Slug: "count-all-artists-sql", + Title: "Count All Artists", + Question: $""" + Write a query that returns the total number of artists in the table. + + The `Artists` table: + + {ArtistsTableDescription} + """, + Difficulty: 100, + Tags: ["sql", "aggregation"], + ExpectedOutput: "275" + ), + ]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + byte[] bundleContent = ReadEmbeddedBundle(); + Guid bundleId = await additionalFileBundleSeeder.GetOrCreateAsync(BundleName, bundleContent, cancellationToken); + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry sqliteVersion = await GetVersionAsync("sqlite", cancellationToken); + + context.ChangeTracker.Clear(); + + foreach (ProblemSeedDefinition seed in ProblemSeeds) + { + Guid problemId = await EnsureProblemWithSetupAsync( + seed, + sqliteVersion.Id, + bundleId, + pipelineId, + cancellationToken + ); + await EnsureTestSuiteLinkedAsync(seed, problemId, cancellationToken); + await EnsureTagsLinkedAsync(seed, problemId, cancellationToken); + } + } + + private static byte[] ReadEmbeddedBundle() + { + using Stream? stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ResourceName); + if (stream is null) + throw new InvalidOperationException($"Embedded resource '{ResourceName}' not found."); + + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + private async Task EnsureProblemWithSetupAsync( + ProblemSeedDefinition seed, + Guid sqliteVersionId, + Guid bundleId, + Guid pipelineId, + CancellationToken cancellationToken + ) + { + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == seed.Slug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(seed.Slug), + new Title(seed.Title), + new Question(seed.Question), + new Difficulty(seed.Difficulty), + new TimeLimit(1000), + new MemoryLimit(64) + ); + + problem.Publish(); + + ProblemSetup setup = problem.AddSetup(sqliteVersionId, InitialCode, null, pipelineId); + problem.SetAdditionalFileBundle(setup.Id, bundleId); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problems SET title = {seed.Title}, question = {seed.Question} WHERE id = {existingId.Value} + """, + cancellationToken + ); + + Guid? setupId = await context + .Set() + .AsNoTracking() + .Where(s => + EF.Property(s, "problem_id") == existingId.Value && s.LanguageVersionId == sqliteVersionId + ) + .Select(s => (Guid?)s.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (setupId is null) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id, additional_file_bundle_id) + VALUES ({Guid.NewGuid()}, {existingId.Value}, {sqliteVersionId}, {InitialCode}, {(string?) + null}, {pipelineId}, {bundleId}) + """, + cancellationToken + ); + } + else + { + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problem_setups SET initial_code = {InitialCode}, additional_file_bundle_id = {bundleId} WHERE id = {setupId.Value} + """, + cancellationToken + ); + } + + return existingId.Value; + } + + private async Task EnsureTestSuiteLinkedAsync( + ProblemSeedDefinition seed, + Guid problemId, + CancellationToken cancellationToken + ) + { + TestSuite suite = new($"{seed.Slug} - Sample Cases", TestSuiteType.Sample); + TestCase testCase = suite.AddTestCase("Chinook fixture"); + testCase.AddExpectedOutput(seed.ExpectedOutput, "string"); + + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task EnsureTagsLinkedAsync( + ProblemSeedDefinition seed, + Guid problemId, + CancellationToken cancellationToken + ) + { + foreach (string tagName in seed.Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + private sealed record ProblemSeedDefinition( + string Slug, + string Title, + string Question, + int Difficulty, + string[] Tags, + string ExpectedOutput + ); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/ClickCounterProblemSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/ClickCounterProblemSeeder.cs new file mode 100644 index 0000000..bf02ade --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/ClickCounterProblemSeeder.cs @@ -0,0 +1,247 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class ClickCounterProblemSeeder(StackDuelDbContext context, Judge0PipelineSeeder pipelineSeeder) + : IStaticSeeder +{ + private const string ProblemSlug = "click-counter"; + private const string FunctionName = "Counter"; + + private static readonly string[] Tags = ["react", "state", "components"]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid problemId = await EnsureProblemWithSetupsAsync(cancellationToken); + await EnsureTestSuitesLinkedAsync(problemId, cancellationToken); + await EnsureTagsLinkedAsync(problemId, cancellationToken); + } + + private async Task EnsureProblemWithSetupsAsync(CancellationToken cancellationToken) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry reactVersion = await GetVersionAsync("react", cancellationToken); + + context.ChangeTracker.Clear(); + + const string starterCode = """ + function Counter({ start }) { + return null; + } + """; + + (Guid versionId, string code, string funcName)[] desiredSetups = [(reactVersion.Id, starterCode, FunctionName)]; + + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == ProblemSlug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(ProblemSlug), + new Title("Click Counter"), + new Question( + """ + Implement a React component named `Counter` that receives a `start` prop and renders a single ` + ``` + + ### Example 2 + + ``` + Input: start = 5 + After one click: + ``` + """ + ), + new Difficulty(200), + new TimeLimit(2000), + new MemoryLimit(64) + ); + + foreach ((Guid versionId, string code, string funcName) in desiredSetups) + problem.AddSetup(versionId, code, funcName, pipelineId); + + problem.Archive(); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + Guid resolvedProblemId = existingId.Value; + + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problems SET status = 3 WHERE id = {resolvedProblemId} AND status <> 3 + """, + cancellationToken + ); + + HashSet existingVersionIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == resolvedProblemId) + .Select(s => s.LanguageVersionId) + .ToHashSetAsync(cancellationToken); + + List<(Guid versionId, string code, string funcName)> missing = desiredSetups + .Where(d => !existingVersionIds.Contains(d.versionId)) + .ToList(); + + if (missing.Count > 0) + { + foreach ((Guid versionId, string code, string funcName) in missing) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id) + VALUES ({Guid.NewGuid()}, {resolvedProblemId}, {versionId}, {code}, {funcName}, {pipelineId}) + """, + cancellationToken + ); + } + } + + return resolvedProblemId; + } + + private async Task EnsureTestSuitesLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + TestSuite[] desired = [BuildSampleSuite(), BuildHiddenSuite()]; + + foreach (TestSuite suite in desired) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + foreach (string tagName in Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.IgnoreQueryFilters() + .Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + // Expected outputs are the exact stdout produced by the harness (a normalized JSON + // render-tree) for a correct reference solution — generated by actually running the + // real strategy + harness rather than hand-typed, since the grading comparison + // (SetEquality) reformats whitespace but does not reorder JSON object keys. + private static TestSuite BuildSampleSuite() + { + TestSuite suite = new("Click Counter - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Initial render"); + case1.AddInput("""{"start":0}""", "props"); + case1.AddInput("[]", "actions"); + case1.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["0"]}""", "json"); + + TestCase case2 = suite.AddTestCase("Increments on click"); + case2.AddInput("""{"start":5}""", "props"); + case2.AddInput("""[{"testId":"count","type":"click"}]""", "actions"); + case2.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["6"]}""", "json"); + + return suite; + } + + private static TestSuite BuildHiddenSuite() + { + TestSuite suite = new("Click Counter - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Two clicks"); + case1.AddInput("""{"start":10}""", "props"); + case1.AddInput("""[{"testId":"count","type":"click"},{"testId":"count","type":"click"}]""", "actions"); + case1.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["12"]}""", "json"); + + TestCase case2 = suite.AddTestCase("Negative start back to zero"); + case2.AddInput("""{"start":-3}""", "props"); + case2.AddInput( + """[{"testId":"count","type":"click"},{"testId":"count","type":"click"},{"testId":"count","type":"click"}]""", + "actions" + ); + case2.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["0"]}""", "json"); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/HelloOrGoodbyeProblemSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/HelloOrGoodbyeProblemSeeder.cs new file mode 100644 index 0000000..fe1fae7 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/HelloOrGoodbyeProblemSeeder.cs @@ -0,0 +1,305 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class HelloOrGoodbyeProblemSeeder( + StackDuelDbContext context, + Judge0PipelineSeeder pipelineSeeder, + ITestCaseGenerationJobRepository testCaseGenerationJobRepository +) : IStaticSeeder +{ + private const string ProblemSlug = "hello-or-goodbye"; + + private static readonly string[] Tags = ["math", "conditionals"]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid problemId = await EnsureProblemWithSetupsAsync(cancellationToken); + await EnsureTestSuitesLinkedAsync(problemId, cancellationToken); + await EnsureTagsLinkedAsync(problemId, cancellationToken); + + context.ChangeTracker.Clear(); + await EnsureGeneratedTestCasesAsync(problemId, cancellationToken); + } + + private async Task EnsureGeneratedTestCasesAsync(Guid problemId, CancellationToken cancellationToken) + { + LanguageVersionEntry pyVersion = await GetVersionAsync("python", cancellationToken); + + var setupInfo = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId && s.LanguageVersionId == pyVersion.Id) + .Select(s => new { s.Id, s.GenerationSpecId }) + .FirstOrDefaultAsync(cancellationToken); + + if (setupInfo is null) + return; + + const string referenceSolution = """ + def hello_or_goodbye(n: int) -> str: + return "Hello" if n % 2 == 0 else "Goodbye" + """; + + GenerationParameterSpec[] parameters = + [ + new("n", "integer", Min: -1000, Max: 1000, LengthMin: null, LengthMax: null, Charset: null), + ]; + + await TestCaseGenerationSeederHelper.EnqueueIfNeededAsync( + context, + testCaseGenerationJobRepository, + "Hello or Goodbye", + setupInfo.Id, + setupInfo.GenerationSpecId, + referenceSolution, + parameters, + "string", + targetCaseCount: 20, + seed: 11111, + cancellationToken + ); + + context.ChangeTracker.Clear(); + } + + private async Task EnsureProblemWithSetupsAsync(CancellationToken cancellationToken) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry jsVersion = await GetVersionAsync("javascript", cancellationToken); + LanguageVersionEntry pyVersion = await GetVersionAsync("python", cancellationToken); + LanguageVersionEntry javaVersion = await GetVersionAsync("java", cancellationToken); + LanguageVersionEntry cppVersion = await GetVersionAsync("cpp", cancellationToken); + + context.ChangeTracker.Clear(); + + (Guid versionId, string code, string funcName)[] desiredSetups = + [ + (jsVersion.Id, "function helloOrGoodbye(n) {\n \n}", "helloOrGoodbye"), + (pyVersion.Id, "def hello_or_goodbye(n: int) -> str:\n pass", "hello_or_goodbye"), + ( + javaVersion.Id, + "class Solution {\n public String helloOrGoodbye(int n) {\n return null;\n }\n}", + "helloOrGoodbye" + ), + ( + cppVersion.Id, + "class Solution {\npublic:\n string helloOrGoodbye(int n) {\n return \"\";\n }\n};", + "helloOrGoodbye" + ), + ]; + + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == ProblemSlug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(ProblemSlug), + new Title("Hello or Goodbye"), + new Question( + """ + Given an integer `n`, return `"Hello"` if `n` is even, or `"Goodbye"` if `n` is odd. + + ### Example 1 + + ``` + Input: n = 4 + Output: "Hello" + Explanation: 4 is even, so the answer is "Hello". + ``` + + ### Example 2 + + ``` + Input: n = 7 + Output: "Goodbye" + Explanation: 7 is odd, so the answer is "Goodbye". + ``` + + ### Example 3 + + ``` + Input: n = 0 + Output: "Hello" + Explanation: 0 is even, so the answer is "Hello". + ``` + + ### Constraints + + 1. `-10^9 <= n <= 10^9` + """ + ), + new Difficulty(100), + new TimeLimit(1000), + new MemoryLimit(64) + ); + + problem.Publish(); + + foreach ((Guid versionId, string code, string funcName) in desiredSetups) + problem.AddSetup(versionId, code, funcName, pipelineId); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + HashSet existingVersionIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == existingId.Value) + .Select(s => s.LanguageVersionId) + .ToHashSetAsync(cancellationToken); + + List<(Guid versionId, string code, string funcName)> missing = desiredSetups + .Where(d => !existingVersionIds.Contains(d.versionId)) + .ToList(); + + if (missing.Count > 0) + { + foreach ((Guid versionId, string code, string funcName) in missing) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id) + VALUES ({Guid.NewGuid()}, {existingId.Value}, {versionId}, {code}, {funcName}, {pipelineId}) + """, + cancellationToken + ); + } + } + + return existingId.Value; + } + + private async Task EnsureTestSuitesLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + TestSuite[] desired = [BuildSampleSuite(), BuildHiddenSuite()]; + + foreach (TestSuite suite in desired) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + foreach (string tagName in Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + private static TestSuite BuildSampleSuite() + { + TestSuite suite = new("Hello or Goodbye - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Even number"); + case1.AddInput("4", "integer"); + case1.AddExpectedOutput("Hello", "string"); + + TestCase case2 = suite.AddTestCase("Odd number"); + case2.AddInput("7", "integer"); + case2.AddExpectedOutput("Goodbye", "string"); + + TestCase case3 = suite.AddTestCase("Zero"); + case3.AddInput("0", "integer"); + case3.AddExpectedOutput("Hello", "string"); + + return suite; + } + + private static TestSuite BuildHiddenSuite() + { + TestSuite suite = new("Hello or Goodbye - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Large even"); + case1.AddInput("1000000", "integer"); + case1.AddExpectedOutput("Hello", "string"); + + TestCase case2 = suite.AddTestCase("Large odd"); + case2.AddInput("999999", "integer"); + case2.AddExpectedOutput("Goodbye", "string"); + + TestCase case3 = suite.AddTestCase("Negative even"); + case3.AddInput("-2", "integer"); + case3.AddExpectedOutput("Hello", "string"); + + TestCase case4 = suite.AddTestCase("Negative odd"); + case4.AddInput("-3", "integer"); + case4.AddExpectedOutput("Goodbye", "string"); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/SumOfNumbersSqlProblemSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/SumOfNumbersSqlProblemSeeder.cs new file mode 100644 index 0000000..53b7e9a --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/SumOfNumbersSqlProblemSeeder.cs @@ -0,0 +1,252 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class SumOfNumbersSqlProblemSeeder(StackDuelDbContext context, Judge0PipelineSeeder pipelineSeeder) + : IStaticSeeder +{ + private const string ProblemSlug = "sum-of-numbers-sql"; + private const string ProblemTitle = "Sum of Numbers"; + + private static readonly string[] Tags = ["sql", "aggregation"]; + + private const string Question = """ + Write a query that returns the sum of all values in the `numbers` table. + + | Column | Type | Description | + |--------|---------|----------------------| + | value | INTEGER | One number per row | + + ### Example 1 + + ``` + numbers table: + value + ------- + 1 + 2 + 3 + + Output: 6 + ``` + + ### Example 2 + + ``` + numbers table: + value + ------- + 10 + -4 + 5 + + Output: 11 + ``` + """; + + private const string InitialCode = """ + -- Write your SQLite query statement below + + """; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid problemId = await EnsureProblemWithSetupsAsync(cancellationToken); + await EnsureTestSuitesLinkedAsync(problemId, cancellationToken); + await EnsureTagsLinkedAsync(problemId, cancellationToken); + } + + private async Task EnsureProblemWithSetupsAsync(CancellationToken cancellationToken) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry sqliteVersion = await GetVersionAsync("sqlite", cancellationToken); + + context.ChangeTracker.Clear(); + + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == ProblemSlug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(ProblemSlug), + new Title(ProblemTitle), + new Question(Question), + new Difficulty(150), + new TimeLimit(1000), + new MemoryLimit(64) + ); + + problem.Publish(); + problem.AddSetup(sqliteVersion.Id, InitialCode, null, pipelineId); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problems SET title = {ProblemTitle}, question = {Question} WHERE id = {existingId.Value} + """, + cancellationToken + ); + + Guid? setupId = await context + .Set() + .AsNoTracking() + .Where(s => + EF.Property(s, "problem_id") == existingId.Value && s.LanguageVersionId == sqliteVersion.Id + ) + .Select(s => (Guid?)s.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (setupId is null) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id) + VALUES ({Guid.NewGuid()}, {existingId.Value}, {sqliteVersion.Id}, {InitialCode}, {(string?) + null}, {pipelineId}) + """, + cancellationToken + ); + } + else + { + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problem_setups SET initial_code = {InitialCode} WHERE id = {setupId.Value} + """, + cancellationToken + ); + } + + return existingId.Value; + } + + private async Task EnsureTestSuitesLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + TestSuite[] desired = [BuildSampleSuite(), BuildHiddenSuite()]; + + foreach (TestSuite suite in desired) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + foreach (string tagName in Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + private static TestSuite BuildSampleSuite() + { + TestSuite suite = new("Sum of Numbers - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Three positive values"); + case1.AddInput( + "CREATE TABLE numbers(value INTEGER);\nINSERT INTO numbers(value) VALUES (1),(2),(3);", + "sql_setup" + ); + case1.AddExpectedOutput("6", "integer"); + + TestCase case2 = suite.AddTestCase("Includes a negative value"); + case2.AddInput( + "CREATE TABLE numbers(value INTEGER);\nINSERT INTO numbers(value) VALUES (10),(-4),(5);", + "sql_setup" + ); + case2.AddExpectedOutput("11", "integer"); + + return suite; + } + + private static TestSuite BuildHiddenSuite() + { + TestSuite suite = new("Sum of Numbers - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Single row"); + case1.AddInput("CREATE TABLE numbers(value INTEGER);\nINSERT INTO numbers(value) VALUES (42);", "sql_setup"); + case1.AddExpectedOutput("42", "integer"); + + TestCase case2 = suite.AddTestCase("Larger set"); + case2.AddInput( + "CREATE TABLE numbers(value INTEGER);\nINSERT INTO numbers(value) VALUES (1),(2),(3),(4),(5),(6),(7),(8),(9),(10);", + "sql_setup" + ); + case2.AddExpectedOutput("55", "integer"); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/TestCaseGenerationSeederHelper.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/TestCaseGenerationSeederHelper.cs new file mode 100644 index 0000000..58a3d8c --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/TestCaseGenerationSeederHelper.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.Enums; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +/// +/// Enqueues a test case generation request for a seeded problem — shared by every problem +/// seeder so the idempotency check (already generated? already queued?) and console summary +/// look the same regardless of which problem triggered it. Enqueuing, not running: seeders +/// don't need Judge0 available and don't block waiting on it — a background job (see +/// StackDuel.Infrastructure/Jobs/TestCaseGeneration) picks up pending requests on its own +/// schedule. +/// +internal static class TestCaseGenerationSeederHelper +{ + public static async Task EnqueueIfNeededAsync( + StackDuelDbContext context, + ITestCaseGenerationJobRepository jobRepository, + string problemLabel, + Guid problemSetupId, + Guid? existingGenerationSpecId, + string referenceSolutionCode, + IReadOnlyList parameters, + string outputValueType, + int targetCaseCount, + int seed, + CancellationToken cancellationToken + ) + { + if (existingGenerationSpecId is not null) + return; + + bool hasPendingJob = await context + .Set() + .AsNoTracking() + .AnyAsync( + j => + j.ProblemSetupId == problemSetupId + && ( + j.Status == TestCaseGenerationJobStatus.Pending + || j.Status == TestCaseGenerationJobStatus.Processing + ), + cancellationToken + ); + + if (hasPendingJob) + { + Console.WriteLine($"{problemLabel}: test case generation already queued, skipping."); + return; + } + + var job = new TestCaseGenerationJob( + problemSetupId, + referenceSolutionCode, + parameters, + outputValueType, + targetCaseCount, + seed + ); + + await jobRepository.AddAsync(job, cancellationToken); + + Console.WriteLine( + $"{problemLabel}: queued test case generation (target {targetCaseCount} cases, job {job.Id})." + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/TwoSumProblemSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/TwoSumProblemSeeder.cs new file mode 100644 index 0000000..3c6401e --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/TwoSumProblemSeeder.cs @@ -0,0 +1,323 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class TwoSumProblemSeeder( + StackDuelDbContext context, + Judge0PipelineSeeder pipelineSeeder, + ITestCaseGenerationJobRepository testCaseGenerationJobRepository +) : IStaticSeeder +{ + private const string ProblemSlug = "two-sum"; + + private static readonly string[] Tags = ["array", "hash-table"]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid problemId = await EnsureProblemWithSetupsAsync(cancellationToken); + + await EnsureTestSuitesLinkedAsync(problemId, cancellationToken); + + await EnsureTagsLinkedAsync(problemId, cancellationToken); + + context.ChangeTracker.Clear(); + await EnsureGeneratedTestCasesAsync(problemId, cancellationToken); + } + + /// + /// Worked example of the random-test-case-generation feature: attaches a reference + /// solution + declarative constraint spec to the Python setup and queues a background job + /// to generate a verified batch of random hidden cases. The reference solution raises when + /// no pair sums to the target, or when more than one pair does — Two Sum's contract + /// guarantees exactly one solution, so an ambiguous random input (multiple valid pairs) is + /// just as invalid as an unsolvable one: a correct submission that happens to find a + /// different valid pair than whichever one got recorded as "the" answer would be marked + /// wrong for no real reason. Both cases are correctly skipped rather than persisted — + /// expect a meaningful chunk of the requested count to be skipped in the job's result, + /// that's the sanity check working. + /// + private async Task EnsureGeneratedTestCasesAsync(Guid problemId, CancellationToken cancellationToken) + { + LanguageVersionEntry pyVersion = await GetVersionAsync("python", cancellationToken); + + var setupInfo = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId && s.LanguageVersionId == pyVersion.Id) + .Select(s => new { s.Id, s.GenerationSpecId }) + .FirstOrDefaultAsync(cancellationToken); + + if (setupInfo is null) + return; + + const string referenceSolution = """ + def two_sum(nums: list[int], target: int) -> list[int]: + solutions = [] + for i in range(len(nums)): + for j in range(i + 1, len(nums)): + if nums[i] + nums[j] == target: + solutions.append([i, j]) + if len(solutions) != 1: + raise ValueError("no unique solution") + return solutions[0] + """; + + GenerationParameterSpec[] parameters = + [ + new("nums", "integer_array", Min: -10, Max: 10, LengthMin: 2, LengthMax: 6, Charset: null), + new("target", "integer", Min: -10, Max: 10, LengthMin: null, LengthMax: null, Charset: null), + ]; + + await TestCaseGenerationSeederHelper.EnqueueIfNeededAsync( + context, + testCaseGenerationJobRepository, + "Two Sum", + setupInfo.Id, + setupInfo.GenerationSpecId, + referenceSolution, + parameters, + "integer_array", + targetCaseCount: 20, + seed: 12345, + cancellationToken + ); + + context.ChangeTracker.Clear(); + } + + private async Task EnsureProblemWithSetupsAsync(CancellationToken cancellationToken) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry jsVersion = await GetVersionAsync("javascript", cancellationToken); + LanguageVersionEntry pyVersion = await GetVersionAsync("python", cancellationToken); + LanguageVersionEntry javaVersion = await GetVersionAsync("java", cancellationToken); + LanguageVersionEntry cppVersion = await GetVersionAsync("cpp", cancellationToken); + + context.ChangeTracker.Clear(); + + (Guid versionId, string code, string funcName)[] desiredSetups = + [ + (jsVersion.Id, "function twoSum(nums, target) {\n \n}", "twoSum"), + (pyVersion.Id, "def two_sum(nums: list[int], target: int) -> list[int]:\n pass", "two_sum"), + ( + javaVersion.Id, + "class Solution {\n public int[] twoSum(int[] nums, int target) {\n return null;\n }\n}", + "twoSum" + ), + ( + cppVersion.Id, + "class Solution {\npublic:\n vector twoSum(vector& nums, int target) {\n return {};\n }\n};", + "twoSum" + ), + ]; + + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == ProblemSlug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(ProblemSlug), + new Title("Two Sum"), + new Question( + """ + Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`. + + You may assume that each input would have exactly one solution, and you may not use the same element twice. You can return the answer in any order. + + ### Example 1 + + ``` + Input: nums = [2,7,11,15], target = 9 + Output: [0,1] + Explanation: nums[0] + nums[1] = 2 + 7 = 9, so the answer is [0, 1]. + ``` + + ### Example 2 + + ``` + Input: nums = [3,2,4], target = 6 + Output: [1,2] + Explanation: nums[1] + nums[2] = 2 + 4 = 6, so the answer is [1, 2]. + ``` + + ### Constraints + + 1. `2 <= nums.length <= 10^4` + 2. `-10^9 <= nums[i] <= 10^9` + 3. `-10^9 <= target <= 10^9` + 4. Exactly one valid pair exists. + """ + ), + new Difficulty(500), + new TimeLimit(1000), + new MemoryLimit(64) + ); + + problem.Publish(); + + foreach ((Guid versionId, string code, string funcName) in desiredSetups) + problem.AddSetup(versionId, code, funcName, pipelineId); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + // Problem exists — add only missing setups + HashSet existingVersionIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == existingId.Value) + .Select(s => s.LanguageVersionId) + .ToHashSetAsync(cancellationToken); + + List<(Guid versionId, string code, string funcName)> missing = desiredSetups + .Where(d => !existingVersionIds.Contains(d.versionId)) + .ToList(); + + if (missing.Count > 0) + { + // Bypass EF change tracking entirely for setup inserts to avoid + // DbUpdateConcurrencyException caused by PropertyAccessMode.Field + // collections (_history, _setups) being in an unresolvable state. + foreach ((Guid versionId, string code, string funcName) in missing) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id) + VALUES ({Guid.NewGuid()}, {existingId.Value}, {versionId}, {code}, {funcName}, {pipelineId}) + """, + cancellationToken + ); + } + } + + return existingId.Value; + } + + private async Task EnsureTestSuitesLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + TestSuite[] desired = [BuildSampleSuite(), BuildHiddenSuite()]; + + foreach (TestSuite suite in desired) + { + // Upsert test suite by name + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + // Get all setup IDs for this problem + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + // Insert join row only if not already linked (raw SQL to avoid field-backed collection issues) + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + foreach (string tagName in Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + private static TestSuite BuildSampleSuite() + { + TestSuite suite = new("Two Sum - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Example 1"); + case1.AddInput("[2,7,11,15]", "integer_array"); + case1.AddInput("9", "integer"); + case1.AddExpectedOutput("[0,1]", "integer_array"); + + TestCase case2 = suite.AddTestCase("Example 2"); + case2.AddInput("[3,2,4]", "integer_array"); + case2.AddInput("6", "integer"); + case2.AddExpectedOutput("[1,2]", "integer_array"); + + return suite; + } + + private static TestSuite BuildHiddenSuite() + { + TestSuite suite = new("Two Sum - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Hidden 1"); + case1.AddInput("[1,2,3,4,5]", "integer_array"); + case1.AddInput("9", "integer"); + case1.AddExpectedOutput("[3,4]", "integer_array"); + + TestCase case2 = suite.AddTestCase("Hidden 2"); + case2.AddInput("[0,4,3,0]", "integer_array"); + case2.AddInput("0", "integer"); + case2.AddExpectedOutput("[0,3]", "integer_array"); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/Problems/VanillaClickCounterProblemSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/Problems/VanillaClickCounterProblemSeeder.cs new file mode 100644 index 0000000..b87a390 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/Problems/VanillaClickCounterProblemSeeder.cs @@ -0,0 +1,245 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; + +namespace StackDuel.Infrastructure.Persistence.Seeders.Problems; + +internal sealed class VanillaClickCounterProblemSeeder(StackDuelDbContext context, Judge0PipelineSeeder pipelineSeeder) + : IStaticSeeder +{ + private const string ProblemSlug = "vanilla-click-counter"; + private const string FunctionName = "render"; + + private static readonly string[] Tags = ["vanilla-js", "dom", "javascript"]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + Guid problemId = await EnsureProblemWithSetupsAsync(cancellationToken); + await EnsureTestSuitesLinkedAsync(problemId, cancellationToken); + await EnsureTagsLinkedAsync(problemId, cancellationToken); + } + + private async Task EnsureProblemWithSetupsAsync(CancellationToken cancellationToken) + { + Guid pipelineId = await pipelineSeeder.GetOrCreateAsync(cancellationToken); + + LanguageVersionEntry vanillaJsVersion = await GetVersionAsync("vanilla-js", cancellationToken); + + context.ChangeTracker.Clear(); + + const string starterCode = """ + function render(container, { start }) { + + } + """; + + (Guid versionId, string code, string funcName)[] desiredSetups = + [ + (vanillaJsVersion.Id, starterCode, FunctionName), + ]; + + Guid? existingId = await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => p.Slug.Value == ProblemSlug) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (existingId is null) + { + Problem problem = new( + new Slug(ProblemSlug), + new Title("Click Counter (Vanilla JS)"), + new Question( + """ + Implement a function named `render` that receives a `container` DOM element and a `props` object with a `start` field, and appends a single ` + ``` + + ### Example 2 + + ``` + Input: start = 5 + After one click: + ``` + """ + ), + new Difficulty(200), + new TimeLimit(2000), + new MemoryLimit(64) + ); + + foreach ((Guid versionId, string code, string funcName) in desiredSetups) + problem.AddSetup(versionId, code, funcName, pipelineId); + + context.Problems.Add(problem); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + return problem.Id; + } + + Guid resolvedProblemId = existingId.Value; + + await context.Database.ExecuteSqlAsync( + $""" + UPDATE problems SET status = 1 WHERE id = {resolvedProblemId} AND status <> 1 + """, + cancellationToken + ); + + HashSet existingVersionIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == resolvedProblemId) + .Select(s => s.LanguageVersionId) + .ToHashSetAsync(cancellationToken); + + List<(Guid versionId, string code, string funcName)> missing = desiredSetups + .Where(d => !existingVersionIds.Contains(d.versionId)) + .ToList(); + + if (missing.Count > 0) + { + foreach ((Guid versionId, string code, string funcName) in missing) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setups (id, problem_id, language_version_id, initial_code, function_name, pipeline_id) + VALUES ({Guid.NewGuid()}, {resolvedProblemId}, {versionId}, {code}, {funcName}, {pipelineId}) + """, + cancellationToken + ); + } + } + + return resolvedProblemId; + } + + private async Task EnsureTestSuitesLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + TestSuite[] desired = [BuildSampleSuite(), BuildHiddenSuite()]; + + foreach (TestSuite suite in desired) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suite.Name, cancellationToken); + + Guid suiteId; + if (existing is null) + { + context.TestSuites.Add(suite); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + List setupIds = await context + .Set() + .AsNoTracking() + .Where(s => EF.Property(s, "problem_id") == problemId) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + foreach (Guid setupId in setupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + } + + private async Task EnsureTagsLinkedAsync(Guid problemId, CancellationToken cancellationToken) + { + foreach (string tagName in Tags) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO tags (id, name) + VALUES ({Guid.NewGuid()}, {tagName}) + ON CONFLICT (name) DO NOTHING + """, + cancellationToken + ); + + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_tags ("ProblemsId", "TagsId") + SELECT {problemId}, id FROM tags WHERE name = {tagName} + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + } + + private async Task GetVersionAsync(string slug, CancellationToken cancellationToken) => + await context + .Languages.IgnoreQueryFilters() + .Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstAsync(cancellationToken); + + private static TestSuite BuildSampleSuite() + { + TestSuite suite = new("Vanilla Click Counter - Sample Cases", TestSuiteType.Sample); + + TestCase case1 = suite.AddTestCase("Initial render"); + case1.AddInput("""{"start":0}""", "props"); + case1.AddInput("[]", "actions"); + case1.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["0"]}""", "json"); + + TestCase case2 = suite.AddTestCase("Increments on click"); + case2.AddInput("""{"start":5}""", "props"); + case2.AddInput("""[{"testId":"count","type":"click"}]""", "actions"); + case2.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["6"]}""", "json"); + + return suite; + } + + private static TestSuite BuildHiddenSuite() + { + TestSuite suite = new("Vanilla Click Counter - Hidden Cases", TestSuiteType.Hidden); + + TestCase case1 = suite.AddTestCase("Two clicks"); + case1.AddInput("""{"start":10}""", "props"); + case1.AddInput("""[{"testId":"count","type":"click"},{"testId":"count","type":"click"}]""", "actions"); + case1.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["12"]}""", "json"); + + TestCase case2 = suite.AddTestCase("Negative start back to zero"); + case2.AddInput("""{"start":-3}""", "props"); + case2.AddInput( + """[{"testId":"count","type":"click"},{"testId":"count","type":"click"},{"testId":"count","type":"click"}]""", + "actions" + ); + case2.AddExpectedOutput("""{"type":"button","props":{"data-testid":"count"},"children":["0"]}""", "json"); + + return suite; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/RequiredProblemLanguageSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/RequiredProblemLanguageSeeder.cs new file mode 100644 index 0000000..34e027e --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/RequiredProblemLanguageSeeder.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.ValueObjects; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +/// +/// Seeds the initial set of languages every new problem must ship a reference solution for. +/// Admin-editable afterwards via the required-languages settings page — this only sets the +/// starting point on a fresh database. +/// +internal sealed class RequiredProblemLanguageSeeder(StackDuelDbContext context) : IStaticSeeder +{ + private static readonly string[] DesiredSlugs = ["python", "javascript"]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + if (await context.RequiredProblemLanguages.AnyAsync(cancellationToken)) + return; + + int sortOrder = 0; + foreach (string slug in DesiredSlugs) + { + LanguageVersionEntry? version = await context + .Languages.Where(l => l.Slug == new LanguageSlug(slug)) + .SelectMany(l => l.Versions) + .FirstOrDefaultAsync(cancellationToken); + + if (version is null) + continue; + + context.RequiredProblemLanguages.Add(RequiredProblemLanguage.Create(version.Id, sortOrder)); + sortOrder++; + } + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/SeederOptions.cs b/StackDuel.Infrastructure/Persistence/Seeders/SeederOptions.cs new file mode 100644 index 0000000..b420f6e --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/SeederOptions.cs @@ -0,0 +1,15 @@ +namespace StackDuel.Infrastructure.Persistence.Seeders; + +public sealed class SeederOptions +{ + /// + /// Seeds reference data (languages, versions, problems). Idempotent — safe to run on every deploy. + /// + public bool SeedStaticData { get; init; } + + /// + /// Seeds demo/sample data (example problems, test suites). + /// Intended for development and staging environments only. + /// + public bool SeedDemoData { get; init; } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/Seeders/TrackSeeder.cs b/StackDuel.Infrastructure/Persistence/Seeders/TrackSeeder.cs new file mode 100644 index 0000000..e7012e5 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/Seeders/TrackSeeder.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Tracks.Entities; + +namespace StackDuel.Infrastructure.Persistence.Seeders; + +internal sealed class TrackSeeder(StackDuelDbContext context) : IStaticSeeder +{ + public const string GeneralPurposeKey = "general-purpose"; + public const string SqlKey = "sql"; + public const string FrontendKey = "frontend"; + + private static readonly DesiredTrack[] DesiredTracks = + [ + new(GeneralPurposeKey, "General Purpose", IsActive: true, AllowsLanguageSelection: false), + new(SqlKey, "SQL", IsActive: true, AllowsLanguageSelection: false), + new(FrontendKey, "Frontend", IsActive: false, AllowsLanguageSelection: true), + ]; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + List existingTracks = await context.Tracks.ToListAsync(cancellationToken); + + foreach (DesiredTrack desired in DesiredTracks) + { + Track? track = existingTracks.FirstOrDefault(t => t.Key == desired.Key); + + if (track is null) + { + track = new Track(desired.Key, desired.Name); + context.Tracks.Add(track); + + if (!desired.IsActive) + track.Deactivate(); + + if (!desired.AllowsLanguageSelection) + track.DisableLanguageSelection(); + + continue; + } + + if (desired.IsActive && !track.IsActive) + track.Activate(); + else if (!desired.IsActive && track.IsActive) + track.Deactivate(); + + if (desired.AllowsLanguageSelection && !track.AllowsLanguageSelection) + track.EnableLanguageSelection(); + else if (!desired.AllowsLanguageSelection && track.AllowsLanguageSelection) + track.DisableLanguageSelection(); + } + + await context.SaveChangesAsync(cancellationToken); + } + + private sealed record DesiredTrack(string Key, string Name, bool IsActive, bool AllowsLanguageSelection); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/StackDuelDbContext.cs b/StackDuel.Infrastructure/Persistence/StackDuelDbContext.cs new file mode 100644 index 0000000..529c0b1 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/StackDuelDbContext.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Achievements.Entities; +using StackDuel.Domain.Audit.Entities; +using StackDuel.Domain.Authorization.Rbac.Entities; +using StackDuel.Domain.Authorization.Security.Entities; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Domain.ExecutionAssets.Entities; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.Enums; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; +using StackDuel.Domain.ProblemValidation.Entities; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.Tracks.Entities; +using StackDuel.Domain.Users.Entities; +using StackDuel.Infrastructure.ExecutionEngine.Assert; +using StackDuel.Infrastructure.ExecutionEngine.Judge0; + +namespace StackDuel.Infrastructure.Persistence; + +internal sealed class StackDuelDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet AdditionalFileBundles { get; init; } + + public DbSet AchievementDefinitions { get; init; } + + public DbSet UserAchievements { get; init; } + + public DbSet UserAchievementStats { get; init; } + + public DbSet UserAchievementStatLanguages { get; init; } + + public DbSet Campaigns { get; init; } + + public DbSet CampaignModules { get; init; } + + public DbSet CampaignUnits { get; init; } + + public DbSet UnitProblems { get; init; } + + public DbSet CampaignPrerequisites { get; init; } + + public DbSet CampaignEnrollments { get; init; } + + public DbSet UnitCompletions { get; init; } + + public DbSet DailyChallenges { get; init; } + + public DbSet AuditLogEntries { get; init; } + + public DbSet ExecutionPipelines { get; init; } + + public DbSet FeedbackSubmissions { get; init; } + + public DbSet FeatureFlags { get; init; } + + public DbSet FeatureFlagUserOverrides { get; init; } + + public DbSet FeatureFlagGroupOverrides { get; init; } + + public DbSet Groups { get; init; } + + public DbSet Games { get; init; } + + public DbSet GameModes { get; init; } + + public DbSet GameModeTimeOptions { get; init; } + + public DbSet GameParticipants { get; init; } + + public DbSet GameTracks { get; init; } + + public DbSet Leaderboards { get; init; } + + public DbSet LeaderboardParticipants { get; init; } + + public DbSet Languages { get; init; } + + public DbSet Permissions { get; init; } + + public DbSet Problems { get; init; } + + public DbSet ProblemPools { get; init; } + + public DbSet ProblemReactions { get; init; } + + public DbSet ProblemReactionTypes { get; init; } + + public DbSet Tags { get; init; } + + public DbSet RequiredProblemLanguages { get; init; } + + public DbSet ProblemValidationJobs { get; init; } + + public DbSet Roles { get; init; } + + public DbSet RolePermissions { get; init; } + + public DbSet SecurityRestrictions { get; init; } + + public DbSet Submissions { get; init; } + + public DbSet SubmissionJobs { get; init; } + + public DbSet TestCaseGenerationSpecs { get; init; } + + public DbSet TestCaseGenerationJobs { get; init; } + + public DbSet TestSuites { get; init; } + + public DbSet Tracks { get; init; } + + public DbSet Users { get; init; } + + public DbSet UserAvatars { get; init; } + + // Infrastructure-only — not domain aggregates + public DbSet Judge0StepConfigurations { get; init; } + public DbSet AssertStepConfigurations { get; init; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly( + typeof(StackDuelDbContext).Assembly, + t => t.Namespace is null || !t.Namespace.StartsWith("StackDuel.Infrastructure.Persistence.Read") + ); + + modelBuilder.Entity().HasQueryFilter(problem => problem.Status == ProblemStatus.Published); + modelBuilder.Entity().HasQueryFilter(language => language.Status == LanguageStatus.Active); + modelBuilder + .Entity() + .HasQueryFilter(version => version.Status == LanguageVersionStatus.Active); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Persistence/StackDuelDbContextFactory.cs b/StackDuel.Infrastructure/Persistence/StackDuelDbContextFactory.cs new file mode 100644 index 0000000..02fb5d2 --- /dev/null +++ b/StackDuel.Infrastructure/Persistence/StackDuelDbContextFactory.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace StackDuel.Infrastructure.Persistence; + +internal sealed class StackDuelDbContextFactory : IDesignTimeDbContextFactory +{ + public StackDuelDbContext CreateDbContext(string[] args) + { + string environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; + + // Directory.GetCurrentDirectory() resolves to StackDuel.Api when run via + // `dotnet ef --startup-project StackDuel.Api`, giving access to its appsettings. + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + string connectionString = + configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException( + "No connection string found. Ensure 'ConnectionStrings:DefaultConnection' is configured." + ); + + var options = new DbContextOptionsBuilder().UseNpgsql(connectionString).Options; + + return new StackDuelDbContext(options); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/ProblemValidation/ReferenceSolutionValidationService.cs b/StackDuel.Infrastructure/ProblemValidation/ReferenceSolutionValidationService.cs new file mode 100644 index 0000000..5efd93c --- /dev/null +++ b/StackDuel.Infrastructure/ProblemValidation/ReferenceSolutionValidationService.cs @@ -0,0 +1,133 @@ +using StackDuel.Application.ExecutionEngine; +using StackDuel.Application.Languages; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; +using StackDuel.Infrastructure.ExecutionEngine; + +namespace StackDuel.Infrastructure.ProblemValidation; + +internal interface IReferenceSolutionValidationService +{ + Task ValidateAsync( + Guid problemId, + CancellationToken cancellationToken = default + ); +} + +internal sealed record SetupValidationOutcome( + Guid ProblemSetupId, + string LanguageLabel, + bool Passed, + IReadOnlyList Warnings +); + +internal sealed record ReferenceSolutionValidationResult(bool AllPassed, IReadOnlyList Outcomes) +{ + public string BuildFailureSummary() => + string.Join( + "\n", + Outcomes.Where(o => !o.Passed).Select(o => $"{o.LanguageLabel}: {string.Join("; ", o.Warnings)}") + ); +} + +internal sealed class ReferenceSolutionValidationService( + IProblemRepository problemRepository, + ILanguageReadRepository languageReadRepository, + ICodeTemplateStrategyResolver templateResolver, + Judge0ReferenceSolutionRunner runner +) : IReferenceSolutionValidationService +{ + public async Task ValidateAsync( + Guid problemId, + CancellationToken cancellationToken = default + ) + { + Problem? problem = await problemRepository.FindByIdAsync(problemId, cancellationToken); + + if (problem is null) + return new ReferenceSolutionValidationResult( + false, + [new SetupValidationOutcome(Guid.Empty, "problem", false, ["Problem not found."])] + ); + + IEnumerable languages = await languageReadRepository.FindLanguagesByVersionId( + [.. problem.Setups.Select(s => s.LanguageVersionId)], + cancellationToken + ); + + Dictionary versionLookup = languages + .SelectMany(language => + language.Versions.Select(version => (version.Id, Language: language, Version: version)) + ) + .ToDictionary(x => x.Id, x => (x.Language, x.Version)); + + List outcomes = []; + + foreach (ProblemSetup setup in problem.Setups) + { + if (!versionLookup.TryGetValue(setup.LanguageVersionId, out var resolved)) + { + outcomes.Add( + new SetupValidationOutcome( + setup.Id, + setup.LanguageVersionId.ToString(), + false, + ["Language version not found."] + ) + ); + continue; + } + + string languageLabel = $"{resolved.Language.Name.Value} ({resolved.Version.Version.Value})"; + + if (string.IsNullOrWhiteSpace(setup.ReferenceSolutionCode)) + { + outcomes.Add( + new SetupValidationOutcome(setup.Id, languageLabel, false, ["No reference solution provided."]) + ); + continue; + } + + List sampleTestCases = + [ + .. setup + .TestSuites.Where(ts => ts.Type == TestSuiteType.Sample) + .SelectMany(ts => ts.TestCases) + .Where(tc => tc.Source == TestCaseSource.Authored && tc.RetiredAt is null), + ]; + + if (sampleTestCases.Count == 0) + { + outcomes.Add( + new SetupValidationOutcome( + setup.Id, + languageLabel, + false, + ["No sample test cases to validate against."] + ) + ); + continue; + } + + ICodeTemplateStrategy templateStrategy = templateResolver.Resolve(resolved.Language.Name.Value); + int judge0LanguageId = resolved.Version.Judge0Id.Value; + + List warnings = await runner.RunAgainstCasesAsync( + setup.ReferenceSolutionCode, + setup.FunctionName, + templateStrategy, + judge0LanguageId, + sampleTestCases, + cancellationToken + ); + + outcomes.Add(new SetupValidationOutcome(setup.Id, languageLabel, warnings.Count == 0, warnings)); + } + + bool allPassed = outcomes.Count > 0 && outcomes.All(o => o.Passed); + return new ReferenceSolutionValidationResult(allPassed, outcomes); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/AuditLog/AuditLogReadRepository.cs b/StackDuel.Infrastructure/Repositories/AuditLog/AuditLogReadRepository.cs new file mode 100644 index 0000000..2b206c7 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/AuditLog/AuditLogReadRepository.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Audit; +using StackDuel.Application.Audit.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.AuditLog; + +internal sealed class AuditLogReadRepository(StackDuelDbContext context) : IAuditLogReadRepository +{ + public async Task> GetPageableAsync( + PaginationRequest pagination, + CancellationToken cancellationToken = default + ) + { + int offset = (pagination.Page - 1) * pagination.Size; + + var query = context.AuditLogEntries.AsNoTracking(); + + int total = await query.CountAsync(cancellationToken); + + List results = await query + .OrderByDescending(a => a.CreatedAt) + .ThenByDescending(a => a.Id) + .Skip(offset) + .Take(pagination.Size) + .Select(a => new AuditLogEntryDto( + a.Id, + a.ActorUserId, + a.ActorUsername, + a.Action, + a.TargetType, + a.TargetId, + a.DetailsJson, + a.CreatedAt + )) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = results, + Total = total, + Page = pagination.Page, + Size = pagination.Size, + }; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/AuditLog/AuditLogWriteRepository.cs b/StackDuel.Infrastructure/Repositories/AuditLog/AuditLogWriteRepository.cs new file mode 100644 index 0000000..5d69d8b --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/AuditLog/AuditLogWriteRepository.cs @@ -0,0 +1,14 @@ +using StackDuel.Application.Audit; +using StackDuel.Domain.Audit.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.AuditLog; + +internal sealed class AuditLogWriteRepository(StackDuelDbContext context) : IAuditLogWriteRepository +{ + public async Task AddAsync(AuditLogEntry entry, CancellationToken cancellationToken = default) + { + await context.AuditLogEntries.AddAsync(entry, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Authorization/AuthorizationReadRepository.cs b/StackDuel.Infrastructure/Repositories/Authorization/AuthorizationReadRepository.cs new file mode 100644 index 0000000..a4422f9 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Authorization/AuthorizationReadRepository.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Authorization; +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Infrastructure.Persistence.Read; + +namespace StackDuel.Infrastructure.Repositories.Authorization; + +internal class AuthorizationReadRepository(StackDuelReadDbContext context) : IAuthorizationReadRepository +{ + public async Task> GetUserPermissionsAsync( + Guid userId, + CancellationToken cancellationToken = default + ) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + + List<(string Code, DecisionEffect Effect)> restrictionRows = await context + .SecurityRestrictions.Where(r => r.UserId == userId && r.ExpiresAt >= now) + .Select(r => new ValueTuple(r.PermissionCode, r.Effect)) + .ToListAsync(cancellationToken); + + List<(string Code, DecisionEffect Effect)> roleRows = await ( + from userGroup in context.UserGroups + join groupRole in context.GroupRoles on userGroup.GroupId equals groupRole.GroupId + join rolePermission in context.RolePermissions on groupRole.RoleId equals rolePermission.RoleId + join permission in context.Permissions on rolePermission.PermissionId equals permission.Id + where userGroup.UserId == userId + select new ValueTuple(permission.Code, rolePermission.Effect) + ).ToListAsync(cancellationToken); + + Dictionary> restrictionsByCode = restrictionRows + .GroupBy(r => r.Code) + .ToDictionary(g => g.Key, g => g.Select(r => r.Effect).ToHashSet()); + + Dictionary> roleEffectsByCode = roleRows + .GroupBy(r => r.Code) + .ToDictionary(g => g.Key, g => g.Select(r => r.Effect).ToHashSet()); + + IEnumerable allCodes = restrictionsByCode.Keys.Union(roleEffectsByCode.Keys); + + return allCodes.Where(code => HasAccess(code, restrictionsByCode, roleEffectsByCode)).ToList(); + } + + public async Task> GetUserRoleNamesAsync( + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await ( + from userGroup in context.UserGroups + join groupRole in context.GroupRoles on userGroup.GroupId equals groupRole.GroupId + join role in context.Roles on groupRole.RoleId equals role.Id + where userGroup.UserId == userId + select role.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + } + + private static bool HasAccess( + string code, + Dictionary> restrictionsByCode, + Dictionary> roleEffectsByCode + ) + { + if (restrictionsByCode.TryGetValue(code, out var restrictionEffects)) + { + if (restrictionEffects.Contains(DecisionEffect.Deny)) + return false; + + if (restrictionEffects.Contains(DecisionEffect.Allow)) + return true; + } + + if (roleEffectsByCode.TryGetValue(code, out var roleEffects)) + { + if (roleEffects.Contains(DecisionEffect.Deny)) + return false; + + return roleEffects.Contains(DecisionEffect.Allow); + } + + return false; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Authorization/GroupReadRepository.cs b/StackDuel.Infrastructure/Repositories/Authorization/GroupReadRepository.cs new file mode 100644 index 0000000..40dfbc9 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Authorization/GroupReadRepository.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Groups; +using StackDuel.Application.Groups.Dtos; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Authorization; + +internal sealed class GroupReadRepository(StackDuelDbContext context) : IGroupReadRepository +{ + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await context + .Groups.AsNoTracking() + .OrderBy(g => g.Name) + .Select(g => new GroupDto(g.Id.Value, g.Name.Value)) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Campaigns/CampaignEnrollmentWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignEnrollmentWriteRepository.cs new file mode 100644 index 0000000..e5c2585 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignEnrollmentWriteRepository.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Campaigns; + +internal sealed class CampaignEnrollmentWriteRepository(StackDuelDbContext context) : ICampaignEnrollmentWriteRepository +{ + public async Task AddAsync(CampaignEnrollment entity, CancellationToken cancellationToken = default) + { + await context.CampaignEnrollments.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context.CampaignEnrollments.FirstOrDefaultAsync(e => e.Id == id, cancellationToken); + + public async Task SaveChangesAsync(CampaignEnrollment entity, CancellationToken cancellationToken = default) + { + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Campaigns/CampaignProgressReadRepository.cs b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignProgressReadRepository.cs new file mode 100644 index 0000000..76b922f --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignProgressReadRepository.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Campaigns; + +internal sealed class CampaignProgressReadRepository(StackDuelDbContext context) : ICampaignProgressReadRepository +{ + public async Task FindEnrollmentAsync( + Guid userId, + Guid campaignId, + CancellationToken cancellationToken = default + ) => + await context + .CampaignEnrollments.AsNoTracking() + .FirstOrDefaultAsync(e => e.UserId == userId && e.CampaignId == campaignId, cancellationToken); + + public async Task> GetEnrollmentsForUserAsync( + Guid userId, + CancellationToken cancellationToken = default + ) => await context.CampaignEnrollments.AsNoTracking().Where(e => e.UserId == userId).ToListAsync(cancellationToken); + + public async Task> GetCompletedUnitIdsAsync( + Guid userId, + Guid campaignId, + CancellationToken cancellationToken = default + ) + { + IQueryable unitIdsInCampaign = context + .CampaignUnits.Where(u => + context + .CampaignModules.Where(m => m.CampaignId == campaignId) + .Select(m => m.Id) + .Contains(u.CampaignModuleId) + ) + .Select(u => u.Id); + + return await context + .UnitCompletions.AsNoTracking() + .Where(uc => uc.UserId == userId && unitIdsInCampaign.Contains(uc.CampaignUnitId)) + .Select(uc => uc.CampaignUnitId) + .ToListAsync(cancellationToken); + } + + public async Task HasCompletedUnitAsync( + Guid userId, + Guid unitId, + CancellationToken cancellationToken = default + ) => + await context + .UnitCompletions.AsNoTracking() + .AnyAsync(uc => uc.UserId == userId && uc.CampaignUnitId == unitId, cancellationToken); + + public async Task> GetCompletedUnitTypesAsync( + Guid userId, + CancellationToken cancellationToken = default + ) => + await context + .UnitCompletions.AsNoTracking() + .Where(uc => uc.UserId == userId) + .Join(context.CampaignUnits, uc => uc.CampaignUnitId, u => u.Id, (uc, u) => u.UnitType) + .ToListAsync(cancellationToken); + + public async Task> GetCompletionTimestampsAsync( + Guid userId, + CancellationToken cancellationToken = default + ) => + await context + .UnitCompletions.AsNoTracking() + .Where(uc => uc.UserId == userId) + .Select(uc => uc.CompletedAt) + .ToListAsync(cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Campaigns/CampaignReadRepository.cs b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignReadRepository.cs new file mode 100644 index 0000000..1c908d6 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignReadRepository.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Campaigns; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Domain.Campaigns.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Campaigns; + +internal sealed class CampaignReadRepository(StackDuelDbContext context) : ICampaignReadRepository +{ + public async Task FindPublishedBySlugAsync(string slug, CancellationToken cancellationToken = default) => + await FullGraph() + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Slug == slug && c.Status == CampaignStatus.Published, cancellationToken); + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await FullGraph().AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + public async Task FindByUnitIdAsync(Guid unitId, CancellationToken cancellationToken = default) => + await FullGraph() + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Modules.Any(m => m.Units.Any(u => u.Id == unitId)), cancellationToken); + + public async Task SlugExistsAsync(string slug, CancellationToken cancellationToken = default) => + await context.Campaigns.AsNoTracking().AnyAsync(c => c.Slug == slug, cancellationToken); + + public async Task ExistsAsync(Guid id, CancellationToken cancellationToken = default) => + await context.Campaigns.AsNoTracking().AnyAsync(c => c.Id == id, cancellationToken); + + public async Task> GetPublishedPathAsync(CancellationToken cancellationToken = default) => + await FullGraph() + .AsNoTracking() + .Where(c => c.Status == CampaignStatus.Published) + .OrderBy(c => c.SortOrder) + .ToListAsync(cancellationToken); + + public async Task> GetAdminPagedAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ) + { + IQueryable query = FullGraph().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + string term = search.Trim(); + query = query.Where(c => + EF.Functions.ILike(c.Title, $"%{term}%") || EF.Functions.ILike(c.Slug, $"%{term}%") + ); + } + + int total = await query.CountAsync(cancellationToken); + + List results = await query + .OrderBy(c => c.SortOrder) + .ThenBy(c => c.Title) + .Skip((pagination.Page - 1) * pagination.Size) + .Take(pagination.Size) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = results, + Total = total, + Page = pagination.Page, + Size = pagination.Size, + }; + } + + private IQueryable FullGraph() => + context + .Campaigns.Include(c => c.Modules) + .ThenInclude(m => m.Units) + .ThenInclude(u => u.Problems) + .Include(c => c.Prerequisites) + .AsSplitQuery(); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Campaigns/CampaignWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignWriteRepository.cs new file mode 100644 index 0000000..a961606 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Campaigns/CampaignWriteRepository.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Campaigns; + +internal sealed class CampaignWriteRepository(StackDuelDbContext context) : ICampaignWriteRepository +{ + public async Task AddAsync(Campaign entity, CancellationToken cancellationToken = default) + { + await context.Campaigns.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context + .Campaigns.Include(c => c.Modules) + .ThenInclude(m => m.Units) + .ThenInclude(u => u.Problems) + .Include(c => c.Prerequisites) + .AsSplitQuery() + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + public async Task SaveChangesAsync(Campaign entity, CancellationToken cancellationToken = default) + { + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Campaigns/UnitCompletionWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Campaigns/UnitCompletionWriteRepository.cs new file mode 100644 index 0000000..7cfb227 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Campaigns/UnitCompletionWriteRepository.cs @@ -0,0 +1,14 @@ +using StackDuel.Domain.Campaigns; +using StackDuel.Domain.Campaigns.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Campaigns; + +internal sealed class UnitCompletionWriteRepository(StackDuelDbContext context) : IUnitCompletionWriteRepository +{ + public async Task AddAsync(UnitCompletion entity, CancellationToken cancellationToken = default) + { + await context.UnitCompletions.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/DailyChallenges/DailyChallengeRepository.cs b/StackDuel.Infrastructure/Repositories/DailyChallenges/DailyChallengeRepository.cs new file mode 100644 index 0000000..588483f --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/DailyChallenges/DailyChallengeRepository.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using StackDuel.Application.DailyChallenges; +using StackDuel.Domain.DailyChallenges.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.DailyChallenges; + +internal sealed class DailyChallengeRepository(StackDuelDbContext context, IMemoryCache cache) + : IDailyChallengeRepository +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1); + private const string AllOrderedCacheKey = "daily-challenges:all-ordered"; + + private static string ByDateCacheKey(DateOnly date) => $"daily-challenges:by-date:{date:O}"; + + public async Task FindByDateAsync(DateOnly date, CancellationToken cancellationToken = default) + { + string cacheKey = ByDateCacheKey(date); + + if (cache.TryGetValue(cacheKey, out DailyChallenge? cached)) + return cached; + + DailyChallenge? challenge = await context + .DailyChallenges.AsNoTracking() + .FirstOrDefaultAsync(c => c.ChallengeDate == date, cancellationToken); + + cache.Set(cacheKey, challenge, CacheDuration); + + return challenge; + } + + public async Task> GetAllOrderedByDateDescendingAsync( + CancellationToken cancellationToken = default + ) + { + if (cache.TryGetValue(AllOrderedCacheKey, out IReadOnlyList? cached) && cached is not null) + return cached; + + IReadOnlyList challenges = await context + .DailyChallenges.AsNoTracking() + .OrderByDescending(c => c.ChallengeDate) + .ToListAsync(cancellationToken); + + cache.Set(AllOrderedCacheKey, challenges, CacheDuration); + + return challenges; + } + + public async Task> GetUpcomingAsync( + DateOnly afterDate, + CancellationToken cancellationToken = default + ) => + await context + .DailyChallenges.AsNoTracking() + .Where(c => c.ChallengeDate > afterDate) + .OrderBy(c => c.ChallengeDate) + .ToListAsync(cancellationToken); + + public async Task AddAsync(DailyChallenge challenge, CancellationToken cancellationToken = default) + { + await context.DailyChallenges.AddAsync(challenge, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + cache.Remove(AllOrderedCacheKey); + } + + public async Task UpdateAsync(DailyChallenge challenge, CancellationToken cancellationToken = default) + { + if (context.Entry(challenge).State == EntityState.Detached) + context.DailyChallenges.Update(challenge); + + await context.SaveChangesAsync(cancellationToken); + + cache.Remove(ByDateCacheKey(challenge.ChallengeDate)); + cache.Remove(AllOrderedCacheKey); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Dashboard/DashboardReadRepository.cs b/StackDuel.Infrastructure/Repositories/Dashboard/DashboardReadRepository.cs new file mode 100644 index 0000000..eeca4c9 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Dashboard/DashboardReadRepository.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Dashboard; +using StackDuel.Application.Dashboard.Dtos; +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Dashboard; + +internal sealed class DashboardReadRepository(StackDuelDbContext context) : IDashboardReadRepository +{ + public async Task GetAdminDashboardStatsAsync( + int newUsersDays, + CancellationToken cancellationToken = default + ) + { + int totalUsers = await context.Users.AsNoTracking().CountAsync(cancellationToken); + int totalProblems = await context.Problems.AsNoTracking().CountAsync(cancellationToken); + int totalGames = await context.Games.AsNoTracking().CountAsync(cancellationToken); + int totalSubmissions = await context.Submissions.AsNoTracking().CountAsync(cancellationToken); + + var newUsersByDay = await GetNewUsersByDayAsync(newUsersDays, cancellationToken); + var feedbackByStatus = await GetFeedbackByStatusAsync(cancellationToken); + + return new AdminDashboardStatsDto( + totalUsers, + totalProblems, + totalGames, + totalSubmissions, + newUsersByDay, + feedbackByStatus + ); + } + + private async Task> GetNewUsersByDayAsync( + int newUsersDays, + CancellationToken cancellationToken + ) + { + DateTime cutoff = DateTime.UtcNow.Date.AddDays(-(newUsersDays - 1)); + + var rawCounts = await context + .Users.AsNoTracking() + .Where(u => u.CreatedAt >= cutoff) + .GroupBy(u => u.CreatedAt.Date) + .Select(g => new { Date = g.Key, Count = g.Count() }) + .ToListAsync(cancellationToken); + + var countsByDate = rawCounts.ToDictionary(x => DateOnly.FromDateTime(x.Date), x => x.Count); + DateOnly startDate = DateOnly.FromDateTime(cutoff); + + return Enumerable + .Range(0, newUsersDays) + .Select(offset => startDate.AddDays(offset)) + .Select(date => new DailyUserCountDto(date, countsByDate.GetValueOrDefault(date))) + .ToList(); + } + + private async Task> GetFeedbackByStatusAsync( + CancellationToken cancellationToken + ) + { + var rawCounts = await context + .FeedbackSubmissions.AsNoTracking() + .GroupBy(f => f.Status) + .Select(g => new { Status = g.Key, Count = g.Count() }) + .ToListAsync(cancellationToken); + + var countsByStatus = rawCounts.ToDictionary(x => x.Status, x => x.Count); + + return Enum.GetValues() + .Select(status => new FeedbackStatusCountDto(status, countsByStatus.GetValueOrDefault(status))) + .ToList(); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/ExecutionAssets/AdditionalFileBundleReadRepository.cs b/StackDuel.Infrastructure/Repositories/ExecutionAssets/AdditionalFileBundleReadRepository.cs new file mode 100644 index 0000000..bfe12ee --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/ExecutionAssets/AdditionalFileBundleReadRepository.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.ExecutionAssets; +using StackDuel.Domain.ExecutionAssets.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.ExecutionAssets; + +internal sealed class AdditionalFileBundleReadRepository(StackDuelDbContext context) + : IAdditionalFileBundleReadRepository +{ + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) => + await context.AdditionalFileBundles.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/ExecutionPipelines/ExecutionPipelineRepository.cs b/StackDuel.Infrastructure/Repositories/ExecutionPipelines/ExecutionPipelineRepository.cs new file mode 100644 index 0000000..8c9183d --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/ExecutionPipelines/ExecutionPipelineRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.ExecutionPipelines; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.ExecutionPipelines; + +internal sealed class ExecutionPipelineRepository(StackDuelDbContext context) : IExecutionPipelineRepository +{ + public async Task AddAsync(ExecutionPipeline entity, CancellationToken cancellationToken = default) + { + await context.ExecutionPipelines.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context.ExecutionPipelines.FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + + public async Task FindByIdWithStepsAsync( + Guid id, + CancellationToken cancellationToken = default + ) => await context.ExecutionPipelines.Include(p => p.Steps).FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + + public async Task FindIdByNameAsync(string name, CancellationToken cancellationToken = default) => + await context + .ExecutionPipelines.Where(p => p.Name == name) + .Select(p => (Guid?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + public async Task UpdateAsync(ExecutionPipeline entity, CancellationToken cancellationToken = default) + { + context.ExecutionPipelines.Update(entity); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagAdminReadRepository.cs b/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagAdminReadRepository.cs new file mode 100644 index 0000000..62de1fb --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagAdminReadRepository.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.FeatureFlags; +using StackDuel.Application.FeatureFlags.Dtos; +using StackDuel.Domain.FeatureFlags.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.FeatureFlags; + +internal sealed class FeatureFlagAdminReadRepository(StackDuelDbContext context) : IFeatureFlagAdminReadRepository +{ + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await context + .FeatureFlags.AsNoTracking() + .OrderBy(f => f.Key) + .Select(f => new FeatureFlagAdminDto( + f.Id.Value, + f.Key.Value, + f.Name, + f.Description, + f.DefaultEnabled, + f.RolloutPercentage.Value, + f.CreatedAt, + f.UpdatedAt, + f.UserOverrides.Select(o => new FeatureFlagUserOverrideDto(o.UserId.Value, o.Effect)).ToList(), + f.GroupOverrides.Select(o => new FeatureFlagGroupOverrideDto(o.GroupId.Value, o.Effect)).ToList() + )) + .ToListAsync(cancellationToken); + } + + public async Task GetByKeyAsync(string key, CancellationToken cancellationToken = default) + { + FeatureFlagKey flagKey = new(key); + + return await context + .FeatureFlags.AsNoTracking() + .Where(f => f.Key == flagKey) + .Select(f => new FeatureFlagAdminDto( + f.Id.Value, + f.Key.Value, + f.Name, + f.Description, + f.DefaultEnabled, + f.RolloutPercentage.Value, + f.CreatedAt, + f.UpdatedAt, + f.UserOverrides.Select(o => new FeatureFlagUserOverrideDto(o.UserId.Value, o.Effect)).ToList(), + f.GroupOverrides.Select(o => new FeatureFlagGroupOverrideDto(o.GroupId.Value, o.Effect)).ToList() + )) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagEvaluationRepository.cs b/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagEvaluationRepository.cs new file mode 100644 index 0000000..4962e07 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagEvaluationRepository.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Authorization.Rbac.Enums; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.FeatureFlags; + +internal sealed class FeatureFlagEvaluationRepository(StackDuelDbContext context) : IFeatureFlagEvaluationRepository +{ + public async Task GetEvaluationDataAsync( + string flagKey, + Guid? userId, + CancellationToken cancellationToken + ) + { + FeatureFlagKey key = new(flagKey); + + var flag = await context + .FeatureFlags.Where(f => f.Key == key) + .Select(f => new + { + f.Id, + f.DefaultEnabled, + RolloutPercentage = f.RolloutPercentage.Value, + }) + .FirstOrDefaultAsync(cancellationToken); + + if (flag is null) + return null; + + DecisionEffect? userEffect = null; + DecisionEffect? groupEffect = null; + + if (userId is Guid uid) + { + UserId vUserId = new(uid); + + List userRows = await context + .FeatureFlagUserOverrides.Where(o => + EF.Property(o, "feature_flag_id") == flag.Id && o.UserId == vUserId + ) + .Select(o => o.Effect) + .ToListAsync(cancellationToken); + + userEffect = Collapse(userRows); + + if (userEffect is null) + { + List groupRows = await ( + from go in context.FeatureFlagGroupOverrides + join ug in context.Set>("user_groups") + on go.GroupId equals EF.Property(ug, "group_id") + where + EF.Property(ug, "user_id") == uid + && EF.Property(go, "feature_flag_id") == flag.Id + select go.Effect + ).ToListAsync(cancellationToken); + + groupEffect = Collapse(groupRows); + } + } + + return new FeatureFlagEvaluationData( + flagKey, + flag.DefaultEnabled, + flag.RolloutPercentage, + userEffect, + groupEffect + ); + } + + public async Task> GetAllEvaluationDataAsync( + Guid? userId, + CancellationToken cancellationToken + ) + { + List<(FeatureFlagId Id, string Key, bool DefaultEnabled, int RolloutPercentage)> flags = await context + .FeatureFlags.Select(f => new ValueTuple( + f.Id, + f.Key.Value, + f.DefaultEnabled, + f.RolloutPercentage.Value + )) + .ToListAsync(cancellationToken); + + Dictionary> userEffectsByFlag = []; + Dictionary> groupEffectsByFlag = []; + + if (userId is Guid uid) + { + UserId vUserId = new(uid); + + List<(FeatureFlagId FlagId, DecisionEffect Effect)> userRows = await context + .FeatureFlagUserOverrides.Where(o => o.UserId == vUserId) + .Select(o => new ValueTuple( + EF.Property(o, "feature_flag_id"), + o.Effect + )) + .ToListAsync(cancellationToken); + + userEffectsByFlag = userRows + .GroupBy(r => r.FlagId) + .ToDictionary(g => g.Key, g => g.Select(r => r.Effect).ToHashSet()); + + List<(FeatureFlagId FlagId, DecisionEffect Effect)> groupRows = await ( + from go in context.FeatureFlagGroupOverrides + join ug in context.Set>("user_groups") + on go.GroupId equals EF.Property(ug, "group_id") + where EF.Property(ug, "user_id") == uid + select new ValueTuple( + EF.Property(go, "feature_flag_id"), + go.Effect + ) + ).ToListAsync(cancellationToken); + + groupEffectsByFlag = groupRows + .GroupBy(r => r.FlagId) + .ToDictionary(g => g.Key, g => g.Select(r => r.Effect).ToHashSet()); + } + + Dictionary result = []; + + foreach (var flag in flags) + { + DecisionEffect? userEffect = userEffectsByFlag.TryGetValue(flag.Id, out var uEffects) + ? Collapse(uEffects) + : null; + DecisionEffect? groupEffect = groupEffectsByFlag.TryGetValue(flag.Id, out var gEffects) + ? Collapse(gEffects) + : null; + + result[flag.Key] = new FeatureFlagEvaluationData( + flag.Key, + flag.DefaultEnabled, + flag.RolloutPercentage, + userEffect, + groupEffect + ); + } + + return result; + } + + private static DecisionEffect? Collapse(IReadOnlyCollection effects) + { + if (effects.Contains(DecisionEffect.Deny)) + return DecisionEffect.Deny; + if (effects.Contains(DecisionEffect.Allow)) + return DecisionEffect.Allow; + return null; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagWriteRepository.cs b/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagWriteRepository.cs new file mode 100644 index 0000000..e169237 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/FeatureFlags/FeatureFlagWriteRepository.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.FeatureFlags; +using StackDuel.Domain.FeatureFlags.Entities; +using StackDuel.Domain.FeatureFlags.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.FeatureFlags; + +internal sealed class FeatureFlagWriteRepository(StackDuelDbContext context) : IFeatureFlagWriteRepository +{ + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + FeatureFlagId flagId = new(id); + + return await context + .FeatureFlags.Include(f => f.UserOverrides) + .Include(f => f.GroupOverrides) + .AsSplitQuery() + .FirstOrDefaultAsync(f => f.Id == flagId, cancellationToken); + } + + public async Task FindByKeyAsync(string key, CancellationToken cancellationToken) + { + FeatureFlagKey flagKey = new(key); + + return await context + .FeatureFlags.Include(f => f.UserOverrides) + .Include(f => f.GroupOverrides) + .AsSplitQuery() + .FirstOrDefaultAsync(f => f.Key == flagKey, cancellationToken); + } + + public async Task AddAsync(FeatureFlag flag, CancellationToken cancellationToken) => + await context.FeatureFlags.AddAsync(flag, cancellationToken); + + public async Task SaveChangesAsync(CancellationToken cancellationToken) => + await context.SaveChangesAsync(cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Feedback/FeedbackReadRepository.cs b/StackDuel.Infrastructure/Repositories/Feedback/FeedbackReadRepository.cs new file mode 100644 index 0000000..0301eef --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Feedback/FeedbackReadRepository.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Feedback; +using StackDuel.Application.Feedback.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Domain.Feedback.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Feedback; + +internal sealed class FeedbackReadRepository(StackDuelDbContext context) : IFeedbackReadRepository +{ + public async Task> GetAdminFeedbackPagedAsync( + PaginationRequest paginationRequest, + FeedbackType? type, + FeedbackStatus? status, + CancellationToken cancellationToken = default + ) + { + var query = context.FeedbackSubmissions.AsNoTracking(); + + if (type is FeedbackType feedbackType) + query = query.Where(f => f.Type == feedbackType); + + if (status is FeedbackStatus feedbackStatus) + query = query.Where(f => f.Status == feedbackStatus); + + int total = await query.CountAsync(cancellationToken); + + var page = query + .OrderByDescending(f => f.CreatedAt) + .Skip((paginationRequest.Page - 1) * paginationRequest.Size) + .Take(paginationRequest.Size); + + var items = await ProjectToAdminFeedbackListItem(page).ToListAsync(cancellationToken); + + return new PageResult + { + Results = items, + Total = total, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + public async Task FindAdminFeedbackByIdAsync( + Guid feedbackId, + CancellationToken cancellationToken = default + ) + { + return await context + .FeedbackSubmissions.AsNoTracking() + .Where(f => f.Id == feedbackId) + .Join( + context.Users.AsNoTracking(), + feedback => feedback.UserId, + user => user.Id, + (feedback, user) => + new AdminFeedbackDetailDto( + feedback.Id, + feedback.Type, + feedback.Status, + feedback.Message.Value, + feedback.Rating != null ? feedback.Rating.Value : null, + feedback.ContextType, + feedback.ContextEntityId, + feedback.AdminNote, + feedback.PageUrl, + feedback.UserAgent, + new FeedbackUserDto(user.Username.Value, user.ImageUrl != null ? user.ImageUrl.Value : null), + feedback.CreatedAt, + feedback.UpdatedAt + ) + ) + .FirstOrDefaultAsync(cancellationToken); + } + + private IQueryable ProjectToAdminFeedbackListItem(IQueryable feedback) + { + return feedback.Join( + context.Users.AsNoTracking(), + f => f.UserId, + user => user.Id, + (f, user) => + new AdminFeedbackListItemDto( + f.Id, + f.Type, + f.Status, + f.Message.Value, + f.Rating != null ? f.Rating.Value : null, + f.ContextType, + f.ContextEntityId, + new FeedbackUserDto(user.Username.Value, user.ImageUrl != null ? user.ImageUrl.Value : null), + f.CreatedAt, + f.UpdatedAt + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Feedback/FeedbackWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Feedback/FeedbackWriteRepository.cs new file mode 100644 index 0000000..2e4149a --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Feedback/FeedbackWriteRepository.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Feedback; +using StackDuel.Domain.Feedback.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Feedback; + +internal sealed class FeedbackWriteRepository(StackDuelDbContext context) : IFeedbackWriteRepository +{ + public async Task AddAsync(FeedbackSubmission entity, CancellationToken cancellationToken = default) + { + await context.FeedbackSubmissions.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context.FeedbackSubmissions.FirstOrDefaultAsync(f => f.Id == id, cancellationToken); + } + + public async Task UpdateAsync(FeedbackSubmission entity, CancellationToken cancellationToken = default) + { + context.FeedbackSubmissions.Update(entity); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Games/GameReadRepository.cs b/StackDuel.Infrastructure/Repositories/Games/GameReadRepository.cs new file mode 100644 index 0000000..0b56422 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Games/GameReadRepository.cs @@ -0,0 +1,228 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Games; +using StackDuel.Application.Games.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Games.Entities; +using StackDuel.Domain.Games.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Games; + +internal sealed class GameReadRepository(StackDuelDbContext context) : IGameReadRepository +{ + public async Task FindGameModeByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context + .GameModes.Include(m => m.TimeOptions) + .FirstOrDefaultAsync(m => m.Id == id && m.IsActive, cancellationToken); + } + + public async Task FindGameModeByIdIncludingInactiveAsync( + Guid id, + CancellationToken cancellationToken = default + ) + { + return await context.GameModes.FirstOrDefaultAsync(m => m.Id == id, cancellationToken); + } + + public async Task FindGameModeByKeyAsync(string key, CancellationToken cancellationToken = default) + { + return await context + .GameModes.Include(m => m.TimeOptions) + .FirstOrDefaultAsync(m => m.Key == key && m.IsActive, cancellationToken); + } + + public async Task FindGameByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context + .Games.AsSplitQuery() + .Include(g => g.Participants) + .Include(g => g.Tracks) + .ThenInclude(t => t.Languages) + .Include(g => g.ProblemSequence) + .FirstOrDefaultAsync(g => g.Id == id, cancellationToken); + } + + public async Task FindGameByJoinCodeAsync(string joinCode, CancellationToken cancellationToken = default) + { + string normalizedJoinCode = joinCode.Trim().ToUpperInvariant(); + + return await context + .Games.AsNoTracking() + .AsSplitQuery() + .Include(g => g.Participants) + .Include(g => g.Tracks) + .FirstOrDefaultAsync(g => g.JoinCode == normalizedJoinCode, cancellationToken); + } + + public async Task> GetActiveGameModesAsync(CancellationToken cancellationToken = default) + { + return await context + .GameModes.Include(m => m.TimeOptions) + .Where(m => m.IsActive) + .OrderBy(m => m.Name) + .ToListAsync(cancellationToken); + } + + public async Task> GetRunningGamesPastExpiryAsync( + DateTime cutoffUtc, + int maxBatchSize, + CancellationToken cancellationToken = default + ) + { + return await context + .Games.AsNoTracking() + .Include(g => g.Participants) + .Where(g => + g.Status == GameStatus.Running + && g.StartedAt != null + && g.StartedAt.Value.AddSeconds(g.TimeLimitInSeconds) <= cutoffUtc + ) + .OrderBy(g => g.StartedAt) + .Take(maxBatchSize) + .ToListAsync(cancellationToken); + } + + public async Task> GetPendingGamesAsync( + string? gameModeKey, + PaginationRequest paginationRequest, + CancellationToken cancellationToken = default + ) + { + var query = context + .Games.AsNoTracking() + .AsSplitQuery() + .Include(g => g.Participants) + .Include(g => g.Tracks) + .Where(g => g.Status == GameStatus.Pending); + + if (!string.IsNullOrWhiteSpace(gameModeKey)) + { + Guid? gameModeId = await context + .GameModes.Where(m => m.Key == gameModeKey) + .Select(m => (Guid?)m.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (gameModeId is null) + { + return new PageResult + { + Results = [], + Total = 0, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + query = query.Where(g => g.GameModeId == gameModeId.Value); + } + + int total = await query.CountAsync(cancellationToken); + + List results = await query + .OrderByDescending(g => g.CreatedAt) + .Skip((paginationRequest.Page - 1) * paginationRequest.Size) + .Take(paginationRequest.Size) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = results, + Total = total, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + public async Task> GetAdminGamesPagedAsync( + GameStatus? status, + PaginationRequest paginationRequest, + CancellationToken cancellationToken = default + ) + { + var query = context.Games.AsNoTracking().Include(g => g.Participants).AsQueryable(); + + if (status is GameStatus gameStatus) + query = query.Where(g => g.Status == gameStatus); + + int total = await query.CountAsync(cancellationToken); + + List results = await query + .OrderByDescending(g => g.CreatedAt) + .Skip((paginationRequest.Page - 1) * paginationRequest.Size) + .Take(paginationRequest.Size) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = results, + Total = total, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + public async Task> GetActiveGamesForUserAsync( + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await context + .Games.AsNoTracking() + .AsSplitQuery() + .Include(g => g.Participants) + .Include(g => g.Tracks) + .Where(g => + g.Participants.Any(p => p.UserId == userId) + && ( + g.Status == GameStatus.Pending + || ( + g.Status == GameStatus.Running + && g.Participants.Any(self => + self.UserId == userId && self.ForfeitedAt == null && self.FinishedAt == null + ) + ) + ) + ) + .OrderByDescending(g => g.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetCompletedGamesForUserAsync( + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await context + .Games.AsNoTracking() + .Include(g => g.Participants) + .Where(g => g.Status == GameStatus.Completed && g.Participants.Any(p => p.UserId == userId)) + .OrderByDescending(g => g.EndedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetRecentCompletedSoloRushRunsAsync( + Guid gameModeId, + int timeLimitInSeconds, + Guid excludeUserId, + int take, + CancellationToken cancellationToken = default + ) + { + return await context + .Games.AsNoTracking() + .Where(g => + g.GameModeId == gameModeId + && g.TimeLimitInSeconds == timeLimitInSeconds + && g.Status == GameStatus.Completed + && g.Participants.Any(p => p.UserId != excludeUserId) + ) + .OrderByDescending(g => g.EndedAt) + .Take(take) + .Select(g => new RecentSoloRushRunDto( + g.Participants.Select(p => p.UserId).First(), + g.Participants.Select(p => p.Score).First() + )) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Games/GameWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Games/GameWriteRepository.cs new file mode 100644 index 0000000..54908fb --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Games/GameWriteRepository.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Npgsql; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Games; + +internal sealed class GameWriteRepository(StackDuelDbContext context) : IGameWriteRepository +{ + private const string ProblemSequencePositionIndexName = "IX_game_problems_game_id_position"; + + public async Task AddAsync(Game entity, CancellationToken cancellationToken = default) + { + await context.Games.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context.Games.Include(g => g.Participants).FirstOrDefaultAsync(g => g.Id == id, cancellationToken); + } + + public async Task SaveChangesAsync(Game entity, CancellationToken cancellationToken = default) + { + // Every Entity subclass gets its Id assigned client-side (Entity's constructor calls + // Guid.NewGuid()), so whenever a new child is appended to an already-tracked collection + // (Game.Join() -> Participants, Game.AppendProblem() -> ProblemSequence), EF's change + // detection can't tell it's new from a default/temporary key value — it sees a real Guid + // already set and assumes the row already exists, generating an UPDATE instead of an + // INSERT. That UPDATE matches 0 rows and throws DbUpdateConcurrencyException. + // + // Auto-detect-changes is disabled for this loop because context.Entry(...) itself triggers + // a full change-detection pass — with it left on, the very first Entry() call (for a + // still-Unchanged existing row) would already discover and misclassify the new ones as + // Modified before this loop ever reaches them, making the Detached check below always + // false. With it off, Entry() reports each entity's real tracked-or-not status, so only + // genuinely new rows get forced to Added; re-enabling it before SaveChanges still detects + // ordinary scalar edits (e.g. score, problem session) on existing rows. + context.ChangeTracker.AutoDetectChangesEnabled = false; + foreach (EntityEntry entry in entity.Participants.Select(context.Entry)) + { + if (entry.State == EntityState.Detached) + { + entry.State = EntityState.Added; + } + } + foreach (EntityEntry entry in entity.ProblemSequence.Select(context.Entry)) + { + if (entry.State == EntityState.Detached) + { + entry.State = EntityState.Added; + } + } + context.ChangeTracker.AutoDetectChangesEnabled = true; + + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsProblemSequencePositionConflict(ex)) + { + // The losing side of the (game_id, position) unique index race (see + // GameProblemConfiguration). The failed SaveChanges leaves every entity from this + // attempt sitting in the context's change tracker in whatever state it was pushed to + // (Added/Modified) — detach the whole graph so a retry's fresh FindGameByIdAsync, on + // this same scoped DbContext, actually hits the database instead of being handed back + // this same stale, half-failed instance by EF's identity map. + DetachGraph(entity); + throw new GameProblemPositionConflictException(entity.Id, ex); + } + } + + private static bool IsProblemSequencePositionConflict(DbUpdateException ex) => + ex.InnerException + is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: ProblemSequencePositionIndexName + }; + + private void DetachGraph(Game entity) + { + context.Entry(entity).State = EntityState.Detached; + foreach (GameParticipant participant in entity.Participants) + context.Entry(participant).State = EntityState.Detached; + foreach (GameProblem problem in entity.ProblemSequence) + context.Entry(problem).State = EntityState.Detached; + foreach (GameTrack track in entity.Tracks) + context.Entry(track).State = EntityState.Detached; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Languages/LanguageReadRepository.cs b/StackDuel.Infrastructure/Repositories/Languages/LanguageReadRepository.cs new file mode 100644 index 0000000..ec2dc5e --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Languages/LanguageReadRepository.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Languages; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Languages.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Languages; + +internal sealed class LanguageReadRepository(StackDuelDbContext context) : ILanguageReadRepository +{ + public async Task> FindLanguagesByVersionId( + IEnumerable versionIds, + CancellationToken cancellationToken + ) => + await context + .Languages.AsNoTracking() + .Include(l => l.Versions) + .Where(l => l.Versions.Any(v => versionIds.Contains(v.Id))) + .ToListAsync(cancellationToken); + + public async Task> GetActiveLanguagesAsync(CancellationToken cancellationToken) => + await context + .Languages.AsNoTracking() + .Include(l => l.Versions) + .Where(l => l.Status == LanguageStatus.Active) + .ToListAsync(cancellationToken); + + public async Task> GetActiveLanguagesByTrackIdsAsync( + IEnumerable trackIds, + CancellationToken cancellationToken + ) + { + List trackIdList = trackIds.ToList(); + + return await context + .Languages.AsNoTracking() + .Include(l => l.Versions) + .Where(l => l.Status == LanguageStatus.Active && trackIdList.Contains(l.TrackId)) + .ToListAsync(cancellationToken); + } + + public async Task> GetLanguageVersionIdsByLanguageIdsAsync( + IEnumerable languageIds, + CancellationToken cancellationToken + ) + { + List languageIdList = languageIds.ToList(); + + return await context + .Languages.AsNoTracking() + .Where(l => languageIdList.Contains(l.Id)) + .SelectMany(l => l.Versions) + .Select(v => v.Id) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Leaderboards/LeaderboardReadRepository.cs b/StackDuel.Infrastructure/Repositories/Leaderboards/LeaderboardReadRepository.cs new file mode 100644 index 0000000..a98f1b1 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Leaderboards/LeaderboardReadRepository.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Leaderboards; +using StackDuel.Application.Pagination; +using StackDuel.Domain.Games.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Leaderboards; + +internal sealed class LeaderboardReadRepository(StackDuelDbContext context) : ILeaderboardReadRepository +{ + public async Task> GetRankingsAsync( + Guid gameModeId, + int timeLimitInSeconds, + PaginationRequest paginationRequest, + CancellationToken cancellationToken = default + ) + { + IQueryable query = + from participant in context.LeaderboardParticipants + join leaderboard in context.Leaderboards + on EF.Property(participant, "leaderboard_id") equals leaderboard.Id + where leaderboard.GameModeId == gameModeId && leaderboard.TimeLimitInSeconds == timeLimitInSeconds + orderby participant.HighScore descending, participant.Id + select participant; + + int total = await query.CountAsync(cancellationToken); + + List results = await query + .Skip((paginationRequest.Page - 1) * paginationRequest.Size) + .Take(paginationRequest.Size) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = results, + Total = total, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + public async Task FindParticipantForUserAsync( + Guid gameModeId, + int timeLimitInSeconds, + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await ( + from participant in context.LeaderboardParticipants + join leaderboard in context.Leaderboards + on EF.Property(participant, "leaderboard_id") equals leaderboard.Id + where + leaderboard.GameModeId == gameModeId + && leaderboard.TimeLimitInSeconds == timeLimitInSeconds + && participant.UserId == userId + select participant + ).FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Leaderboards/LeaderboardWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Leaderboards/LeaderboardWriteRepository.cs new file mode 100644 index 0000000..4faf875 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Leaderboards/LeaderboardWriteRepository.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using StackDuel.Domain.Games; +using StackDuel.Domain.Games.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Leaderboards; + +internal sealed class LeaderboardWriteRepository(StackDuelDbContext context) : ILeaderboardWriteRepository +{ + public async Task AddAsync(Leaderboard entity, CancellationToken cancellationToken = default) + { + await context.Leaderboards.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByGameModeAndTimeLimitAsync( + Guid gameModeId, + int timeLimitInSeconds, + CancellationToken cancellationToken = default + ) + { + return await context + .Leaderboards.Include(l => l.Participants) + .FirstOrDefaultAsync( + l => l.GameModeId == gameModeId && l.TimeLimitInSeconds == timeLimitInSeconds, + cancellationToken + ); + } + + public async Task SaveChangesAsync(Leaderboard entity, CancellationToken cancellationToken = default) + { + // See GameWriteRepository.SaveChangesAsync — LeaderboardParticipant.Id is likewise + // assigned client-side, so a newly appended participant on an already-tracked + // Leaderboard is indistinguishable from an existing one to EF's change detection + // unless we force it to Added explicitly. + context.ChangeTracker.AutoDetectChangesEnabled = false; + foreach (LeaderboardParticipant participant in entity.Participants) + { + EntityEntry entry = context.Entry(participant); + if (entry.State == EntityState.Detached) + { + entry.State = EntityState.Added; + } + } + context.ChangeTracker.AutoDetectChangesEnabled = true; + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/ProblemValidation/ProblemValidationJobRepository.cs b/StackDuel.Infrastructure/Repositories/ProblemValidation/ProblemValidationJobRepository.cs new file mode 100644 index 0000000..4a9e5e1 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/ProblemValidation/ProblemValidationJobRepository.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.ProblemValidation; +using StackDuel.Domain.ProblemValidation.Entities; +using StackDuel.Domain.ProblemValidation.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.ProblemValidation; + +internal sealed class ProblemValidationJobRepository(StackDuelDbContext context) : IProblemValidationJobRepository +{ + public async Task AddAsync(ProblemValidationJob job, CancellationToken cancellationToken = default) + { + await context.ProblemValidationJobs.AddAsync(job, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(ProblemValidationJob job, CancellationToken cancellationToken = default) + { + context.ProblemValidationJobs.Update(job); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context.ProblemValidationJobs.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + + public async Task> FindPendingAsync( + int batchSize, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + + return await context + .ProblemValidationJobs.Where(j => + j.Status == ProblemValidationJobStatus.Pending && (j.LockedUntil == null || j.LockedUntil < now) + ) + .OrderBy(j => j.CreatedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task> FindAwaitingGenerationAsync( + int batchSize, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + + return await context + .ProblemValidationJobs.Where(j => + j.Status == ProblemValidationJobStatus.AwaitingGeneration + && (j.LockedUntil == null || j.LockedUntil < now) + ) + .OrderBy(j => j.CreatedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task TryClaimAsync( + Guid jobId, + TimeSpan leaseDuration, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + var lockedUntil = now.Add(leaseDuration); + + int rows = await context + .ProblemValidationJobs.Where(j => + j.Id == jobId + && ( + j.Status == ProblemValidationJobStatus.Pending + || j.Status == ProblemValidationJobStatus.AwaitingGeneration + ) + && (j.LockedUntil == null || j.LockedUntil < now) + ) + .ExecuteUpdateAsync(s => s.SetProperty(j => j.LockedUntil, lockedUntil), cancellationToken); + + return rows == 1; + } + + public async Task ReleaseLockAsync(Guid jobId, CancellationToken cancellationToken = default) + { + await context + .ProblemValidationJobs.Where(j => j.Id == jobId) + .ExecuteUpdateAsync(s => s.SetProperty(j => j.LockedUntil, (DateTime?)null), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/ProblemPoolRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/ProblemPoolRepository.cs new file mode 100644 index 0000000..be023c0 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/ProblemPoolRepository.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class ProblemPoolRepository(StackDuelDbContext context) : IProblemPoolRepository +{ + public async Task FindByKeyAsync(string key, CancellationToken cancellationToken = default) => + await context.ProblemPools.Include("_items").FirstOrDefaultAsync(pool => pool.Key == key, cancellationToken); + + public async Task ExistsByKeyAsync(string key, CancellationToken cancellationToken = default) => + await context.ProblemPools.AnyAsync(pool => pool.Key == key, cancellationToken); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + Dictionary counts = await context + .Set() + .GroupBy(item => EF.Property(item, "pool_id")) + .Select(group => new { PoolId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(x => x.PoolId, x => x.Count, cancellationToken); + + List pools = await context + .ProblemPools.AsNoTracking() + .OrderBy(pool => pool.Name) + .ToListAsync(cancellationToken); + + return + [ + .. pools.Select(pool => new ProblemPoolDto( + pool.Id, + pool.Key, + pool.Name, + pool.Description, + counts.TryGetValue(pool.Id, out int count) ? count : 0, + pool.CreatedAt + )), + ]; + } + + public async Task> GetPoolKeysForProblemAsync( + Guid problemId, + CancellationToken cancellationToken = default + ) + { + List poolIds = await context + .Set() + .Where(item => item.ProblemId == problemId) + .Select(item => EF.Property(item, "pool_id")) + .ToListAsync(cancellationToken); + + if (poolIds.Count == 0) + return []; + + return await context + .ProblemPools.AsNoTracking() + .Where(pool => poolIds.Contains(pool.Id)) + .OrderBy(pool => pool.Name) + .Select(pool => pool.Key) + .ToListAsync(cancellationToken); + } + + public async Task AddAsync(ProblemPool pool, CancellationToken cancellationToken = default) + { + await context.ProblemPools.AddAsync(pool, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(ProblemPool pool, CancellationToken cancellationToken = default) + { + if (context.Entry(pool).State == EntityState.Detached) + context.ProblemPools.Update(pool); + + IEnumerable items = + context.Entry(pool).Collection("_items").CurrentValue?.Cast() ?? []; + await NewChildEntityTracker.MarkNewAsAddedAsync(context, items, cancellationToken); + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/ProblemReactionReadRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/ProblemReactionReadRepository.cs new file mode 100644 index 0000000..74a6c8d --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/ProblemReactionReadRepository.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class ProblemReactionReadRepository(StackDuelDbContext context) : IProblemReactionReadRepository +{ + public async Task FindReactionTypeByKeyAsync( + string key, + CancellationToken cancellationToken = default + ) + { + string normalizedKey = key.Trim().ToLowerInvariant(); + + return await context + .ProblemReactionTypes.AsNoTracking() + .FirstOrDefaultAsync(type => type.Key == normalizedKey, cancellationToken); + } + + public async Task GetSummaryAsync( + Guid problemId, + Guid? userId, + CancellationToken cancellationToken = default + ) + { + var counts = await context + .ProblemReactions.AsNoTracking() + .Where(reaction => reaction.ProblemId == problemId && reaction.IsActive) + .GroupBy(reaction => reaction.ReactionTypeId) + .Select(group => new { ReactionTypeId = group.Key, Count = group.Count() }) + .Join( + context.ProblemReactionTypes.AsNoTracking(), + group => group.ReactionTypeId, + type => type.Id, + (group, type) => + new + { + type.Key, + type.Name, + type.Emoji, + type.SortOrder, + group.Count, + } + ) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + string? currentUserReactionKey = null; + if (userId is Guid uid) + { + currentUserReactionKey = await context + .ProblemReactions.AsNoTracking() + .Where(reaction => reaction.ProblemId == problemId && reaction.UserId == uid && reaction.IsActive) + .Join( + context.ProblemReactionTypes.AsNoTracking(), + reaction => reaction.ReactionTypeId, + type => type.Id, + (reaction, type) => type.Key + ) + .FirstOrDefaultAsync(cancellationToken); + } + + return new ProblemReactionSummaryDto( + counts.Select(x => new ProblemReactionCountDto(x.Key, x.Name, x.Emoji, x.Count)).ToList(), + currentUserReactionKey + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/ProblemReactionWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/ProblemReactionWriteRepository.cs new file mode 100644 index 0000000..516b0ee --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/ProblemReactionWriteRepository.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class ProblemReactionWriteRepository(StackDuelDbContext context) : IProblemReactionWriteRepository +{ + public async Task FindByProblemUserAndTypeAsync( + Guid problemId, + Guid userId, + Guid reactionTypeId, + CancellationToken cancellationToken = default + ) + { + return await context.ProblemReactions.FirstOrDefaultAsync( + reaction => + reaction.ProblemId == problemId + && reaction.UserId == userId + && reaction.ReactionTypeId == reactionTypeId, + cancellationToken + ); + } + + public async Task FindActiveByProblemAndUserAsync( + Guid problemId, + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await context.ProblemReactions.FirstOrDefaultAsync( + reaction => reaction.ProblemId == problemId && reaction.UserId == userId && reaction.IsActive, + cancellationToken + ); + } + + public async Task AddAsync(ProblemReaction entity, CancellationToken cancellationToken = default) + { + await context.ProblemReactions.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(ProblemReaction entity, CancellationToken cancellationToken = default) + { + context.ProblemReactions.Update(entity); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/ProblemReadRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/ProblemReadRepository.cs new file mode 100644 index 0000000..9d794e9 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/ProblemReadRepository.cs @@ -0,0 +1,357 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Pagination; +using StackDuel.Application.Problems; +using StackDuel.Application.Problems.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class ProblemReadRepository(StackDuelDbContext context) : IProblemReadRepository +{ + public async Task FindBySlugAsync(string slug, CancellationToken cancellationToken = default) => + await context + .Problems.AsNoTracking() + .AsSplitQuery() + .Include(problem => problem.CreatedBy) + .Include(problem => problem.Tags) + .Include(problem => problem.Setups) + .ThenInclude(setup => setup.TestSuites) + .ThenInclude(testSuite => testSuite.TestCases) + .ThenInclude(testCase => testCase.Inputs.OrderBy(i => i.Position)) + .Include(problem => problem.Setups) + .ThenInclude(setup => setup.TestSuites) + .ThenInclude(testSuite => testSuite.TestCases) + .ThenInclude(testCase => testCase.ExpectedOutputs) + .Where(problem => problem.Slug.Value == slug) + .SingleOrDefaultAsync(cancellationToken); + + public async Task ExistsAsync(Guid id, CancellationToken cancellationToken = default) => + await context.Problems.AsNoTracking().AnyAsync(problem => problem.Id == id, cancellationToken); + + public async Task ExistsForAdminAsync(Guid id, CancellationToken cancellationToken = default) => + await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .AnyAsync(problem => problem.Id == id, cancellationToken); + + public async Task GetIdBySlugAsync(string slug, CancellationToken cancellationToken = default) => + await context + .Problems.AsNoTracking() + .Where(p => p.Slug.Value == slug) + .Select(p => p.Id) + .SingleOrDefaultAsync(cancellationToken); + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context + .Problems.AsNoTracking() + .AsSplitQuery() + .Include(problem => problem.CreatedBy) + .Include(problem => problem.Tags) + .Include(problem => problem.Setups) + .ThenInclude(setup => setup.TestSuites) + .ThenInclude(testSuite => testSuite.TestCases) + .ThenInclude(testCase => testCase.Inputs.OrderBy(i => i.Position)) + .Include(problem => problem.Setups) + .ThenInclude(setup => setup.TestSuites) + .ThenInclude(testSuite => testSuite.TestCases) + .ThenInclude(testCase => testCase.ExpectedOutputs) + .Where(problem => problem.Id == id) + .SingleOrDefaultAsync(cancellationToken); + + public async Task FindByIdForAdminAsync(Guid id, CancellationToken cancellationToken = default) => + await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .AsSplitQuery() + .Include(problem => problem.CreatedBy) + .Include(problem => problem.Tags) + .Include(problem => problem.Setups) + .ThenInclude(setup => setup.TestSuites) + .ThenInclude(testSuite => testSuite.TestCases) + .ThenInclude(testCase => testCase.Inputs.OrderBy(i => i.Position)) + .Include(problem => problem.Setups) + .ThenInclude(setup => setup.TestSuites) + .ThenInclude(testSuite => testSuite.TestCases) + .ThenInclude(testCase => testCase.ExpectedOutputs) + .Where(problem => problem.Id == id) + .SingleOrDefaultAsync(cancellationToken); + + public async Task> GetPagedAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ) + { + int offset = (pagination.Page - 1) * pagination.Size; + + var query = context.Problems.AsNoTracking().Where(p => p.Status == ProblemStatus.Published); + + string? term = search?.Trim(); + if (!string.IsNullOrEmpty(term)) + { + string pattern = $"%{term}%"; + query = query.Where(p => + EF.Functions.ILike(p.Title.Value, pattern) || EF.Functions.ILike(p.Slug.Value, pattern) + ); + } + + int total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderBy(p => p.Id) + .Skip(offset) + .Take(pagination.Size) + .Select(p => new ProblemListRowDto( + p.Id, + p.Slug.Value, + p.Title.Value, + p.Difficulty.Value, + p.Tags.Select(t => t.Name.Value).ToList(), + p.Setups.Select(s => s.LanguageVersionId).Distinct().ToList() + )) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = items, + Total = total, + Page = pagination.Page, + Size = pagination.Size, + }; + } + + public async Task> GetAdminProblemsPagedAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ) + { + int offset = (pagination.Page - 1) * pagination.Size; + + var query = ApplyAdminSearchFilter(context.Problems.IgnoreQueryFilters().AsNoTracking().AsQueryable(), search); + + int total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(p => p.CreatedAt) + .ThenBy(p => p.Id) + .Skip(offset) + .Take(pagination.Size) + .Select(p => new AdminProblemListRowDto( + p.Id, + p.Slug.Value, + p.Title.Value, + p.Difficulty.Value, + p.Status, + p.TimeLimit.Milliseconds, + p.MemoryLimit.Megabytes, + p.Tags.Select(t => t.Name.Value).ToList(), + p.Setups.Select(s => s.LanguageVersionId).Distinct().ToList(), + p.Setups.Count, + p.CreatedAt, + p.CreatedBy != null ? p.CreatedBy.Username.Value : null + )) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = items, + Total = total, + Page = pagination.Page, + Size = pagination.Size, + }; + } + + public async Task> FindByIdsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken = default + ) + { + if (ids.Count == 0) + return []; + + return await context + .Problems.IgnoreQueryFilters() + .AsNoTracking() + .Where(p => ids.Contains(p.Id)) + .OrderBy(p => p.Title.Value) + .Select(p => new AdminProblemListRowDto( + p.Id, + p.Slug.Value, + p.Title.Value, + p.Difficulty.Value, + p.Status, + p.TimeLimit.Milliseconds, + p.MemoryLimit.Megabytes, + p.Tags.Select(t => t.Name.Value).ToList(), + p.Setups.Select(s => s.LanguageVersionId).Distinct().ToList(), + p.Setups.Count, + p.CreatedAt, + p.CreatedBy != null ? p.CreatedBy.Username.Value : null + )) + .ToListAsync(cancellationToken); + } + + public async Task> GetPoolMembersPagedAsync( + Guid poolId, + PaginationRequest pagination, + CancellationToken cancellationToken = default + ) + { + var poolItems = context.Set().Where(item => EF.Property(item, "pool_id") == poolId); + + int total = await poolItems.CountAsync(cancellationToken); + + var items = await poolItems + .Join( + context.Problems.IgnoreQueryFilters().AsNoTracking(), + item => item.ProblemId, + p => p.Id, + (item, p) => new { item.AddedAt, Problem = p } + ) + .OrderByDescending(x => x.AddedAt) + .Skip((pagination.Page - 1) * pagination.Size) + .Take(pagination.Size) + .Select(x => new AdminProblemListRowDto( + x.Problem.Id, + x.Problem.Slug.Value, + x.Problem.Title.Value, + x.Problem.Difficulty.Value, + x.Problem.Status, + x.Problem.TimeLimit.Milliseconds, + x.Problem.MemoryLimit.Megabytes, + x.Problem.Tags.Select(t => t.Name.Value).ToList(), + x.Problem.Setups.Select(s => s.LanguageVersionId).Distinct().ToList(), + x.Problem.Setups.Count, + x.Problem.CreatedAt, + x.Problem.CreatedBy != null ? x.Problem.CreatedBy.Username.Value : null + )) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = items, + Total = total, + Page = pagination.Page, + Size = pagination.Size, + }; + } + + public async Task> GetAdminProblemIdsMatchingAsync( + string? search, + CancellationToken cancellationToken = default + ) + { + var query = ApplyAdminSearchFilter(context.Problems.IgnoreQueryFilters().AsNoTracking().AsQueryable(), search); + + return await query.Select(p => p.Id).ToListAsync(cancellationToken); + } + + private static IQueryable ApplyAdminSearchFilter(IQueryable query, string? search) + { + string? term = search?.Trim(); + if (string.IsNullOrEmpty(term)) + return query; + + if (Guid.TryParse(term, out Guid id)) + return query.Where(p => p.Id == id); + + string pattern = $"%{term}%"; + return query.Where(p => + EF.Functions.ILike(p.Title.Value, pattern) || EF.Functions.ILike(p.Slug.Value, pattern) + ); + } + + public async Task GetRandomProblemIdByDifficultyAsync( + Guid poolId, + int minDifficulty, + int maxDifficulty, + IReadOnlyCollection excludedProblemIds, + long selectionSeed, + IReadOnlyCollection allowedLanguageVersionIds, + CancellationToken cancellationToken = default + ) + { + excludedProblemIds ??= []; + var excludedList = excludedProblemIds.ToList(); + allowedLanguageVersionIds ??= []; + var allowedLanguageVersionIdList = allowedLanguageVersionIds.ToList(); + + var poolProblemIds = context + .Set() + .Where(item => EF.Property(item, "pool_id") == poolId) + .Select(item => item.ProblemId); + + var query = context + .Problems.AsNoTracking() + .Where(problem => problem.Status == ProblemStatus.Published) + .Where(problem => poolProblemIds.Contains(problem.Id)) + .Where(problem => problem.Difficulty.Value >= minDifficulty) + .Where(problem => problem.Difficulty.Value <= maxDifficulty); + + if (excludedList.Count > 0) + query = query.Where(problem => !excludedList.Contains(problem.Id)); + + if (allowedLanguageVersionIdList.Count > 0) + query = query.Where(problem => + problem.Setups.Any(setup => allowedLanguageVersionIdList.Contains(setup.LanguageVersionId)) + ); + + // Ordered so the seeded pick below is reproducible across calls — without a stable + // order, the same seed could land on a different problem if the database returned + // candidates in a different sequence. + var candidateIds = await query + .OrderBy(problem => problem.Id) + .Select(problem => problem.Id) + .ToListAsync(cancellationToken); + + if (candidateIds.Count == 0) + return null; + + int selectedIndex = (int)(unchecked((ulong)selectionSeed) % (ulong)candidateIds.Count); + return candidateIds[selectedIndex]; + } + + public async Task> GetOrderedEligibleProblemIdsAsync( + Guid poolId, + CancellationToken cancellationToken = default + ) + { + var poolItems = context + .Set() + .Where(item => EF.Property(item, "pool_id") == poolId); + + return await poolItems + .Join( + context.Problems.Where(problem => problem.Status == ProblemStatus.Published), + item => item.ProblemId, + problem => problem.Id, + (item, problem) => new { item.Position, problem.Id } + ) + .OrderBy(x => x.Position) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task FindDifficultyByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ) + { + return await context + .Set() + .AsNoTracking() + .IgnoreQueryFilters() + .Where(setup => setup.Id == problemSetupId) + .Join( + context.Problems.AsNoTracking().IgnoreQueryFilters(), + setup => EF.Property(setup, "problem_id"), + problem => problem.Id, + (setup, problem) => new ProblemDifficultyLookupDto(problem.Id, problem.Difficulty.Value) + ) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/ProblemRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/ProblemRepository.cs new file mode 100644 index 0000000..31cdd53 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/ProblemRepository.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Problems; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class ProblemRepository(StackDuelDbContext context) : IProblemRepository +{ + public async Task AddAsync(Problem entity, CancellationToken cancellationToken = default) + { + await context.Problems.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Problem entity, CancellationToken cancellationToken = default) + { + if (context.Entry(entity).State == EntityState.Detached) + context.Problems.Update(entity); + + await NewChildEntityTracker.MarkNewAsAddedAsync(context, entity.History, cancellationToken); + await NewChildEntityTracker.MarkNewAsAddedAsync(context, entity.Setups, cancellationToken); + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context + .Problems.IgnoreQueryFilters() + .Include(p => p.Tags) + .Include(p => p.Setups) + .ThenInclude(s => s.TestSuites) + .ThenInclude(ts => ts.TestCases) + .ThenInclude(tc => tc.Inputs.OrderBy(i => i.Position)) + .Include(p => p.Setups) + .ThenInclude(s => s.TestSuites) + .ThenInclude(ts => ts.TestCases) + .ThenInclude(tc => tc.ExpectedOutputs) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + + public async Task FindBySlugAsync(Slug slug, CancellationToken cancellationToken = default) => + await context + .Problems.IgnoreQueryFilters() + .Include(p => p.Setups) + .ThenInclude(s => s.TestSuites) + .ThenInclude(ts => ts.TestCases) + .ThenInclude(tc => tc.Inputs.OrderBy(i => i.Position)) + .Include(p => p.Setups) + .ThenInclude(s => s.TestSuites) + .ThenInclude(ts => ts.TestCases) + .ThenInclude(tc => tc.ExpectedOutputs) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Slug.Value == slug.Value, cancellationToken); + + public async Task FindBySetupIdAsync(Guid setupId, CancellationToken cancellationToken = default) => + await context + .Problems.IgnoreQueryFilters() + .Include(p => p.Setups) + .ThenInclude(s => s.TestSuites) + .ThenInclude(ts => ts.TestCases) + .ThenInclude(tc => tc.Inputs.OrderBy(i => i.Position)) + .Include(p => p.Setups) + .ThenInclude(s => s.TestSuites) + .ThenInclude(ts => ts.TestCases) + .ThenInclude(tc => tc.ExpectedOutputs) + .Where(p => p.Setups.Any(s => s.Id == setupId)) + .AsSplitQuery() + .FirstOrDefaultAsync(cancellationToken); + + public async Task> FindOrCreateTagsAsync( + IReadOnlyCollection tagNames, + CancellationToken cancellationToken = default + ) + { + if (tagNames.Count == 0) + return []; + + // Filtered in-memory rather than via a `.Name.Value` predicate — member access on a + // HasConversion-mapped property is unreliable to translate outside a terminal Select + // (see the OrderBy(g => g.Name.Value) failure fixed in GroupReadRepository). The tags + // table is small, so loading it fully here is cheap. + List allTags = await context.Tags.ToListAsync(cancellationToken); + List existing = [.. allTags.Where(t => tagNames.Contains(t.Name.Value))]; + + HashSet existingNames = [.. existing.Select(t => t.Name.Value)]; + + List created = []; + foreach (string name in tagNames.Where(n => !existingNames.Contains(n))) + { + var tag = new ProblemTag(new Tag(name)); + context.Tags.Add(tag); + created.Add(tag); + } + + return [.. existing, .. created]; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/RequiredProblemLanguageReadRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/RequiredProblemLanguageReadRepository.cs new file mode 100644 index 0000000..8f749c0 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/RequiredProblemLanguageReadRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Problems.RequiredLanguages; +using StackDuel.Application.Problems.RequiredLanguages.Dtos; +using StackDuel.Domain.Languages.Entities; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; +using StackDuel.Domain.Tracks.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class RequiredProblemLanguageReadRepository(StackDuelDbContext context) + : IRequiredProblemLanguageReadRepository +{ + public async Task> GetAllAsync( + CancellationToken cancellationToken = default + ) + { + List required = await context + .RequiredProblemLanguages.AsNoTracking() + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + if (required.Count == 0) + return []; + + List languages = await context.Languages.AsNoTracking().ToListAsync(cancellationToken); + + List tracks = await context.Tracks.AsNoTracking().ToListAsync(cancellationToken); + + List result = []; + foreach (RequiredProblemLanguage entry in required) + { + Language? language = languages.FirstOrDefault(l => l.Versions.Any(v => v.Id == entry.LanguageVersionId)); + LanguageVersionEntry? version = language?.Versions.FirstOrDefault(v => v.Id == entry.LanguageVersionId); + Track? track = language is not null ? tracks.FirstOrDefault(t => t.Id == language.TrackId) : null; + + result.Add( + new RequiredProblemLanguageAdminDto( + entry.Id, + entry.LanguageVersionId, + language?.Name.Value ?? "Unknown language", + version?.Version.Value ?? "Unknown version", + entry.SortOrder, + track?.Id, + track?.Key, + track?.Name + ) + ); + } + + return result; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Problems/RequiredProblemLanguageRepository.cs b/StackDuel.Infrastructure/Repositories/Problems/RequiredProblemLanguageRepository.cs new file mode 100644 index 0000000..3857e6a --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Problems/RequiredProblemLanguageRepository.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Problems.RequiredLanguages; +using StackDuel.Domain.Problems.RequiredLanguages.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Problems; + +internal sealed class RequiredProblemLanguageRepository(StackDuelDbContext context) : IRequiredProblemLanguageRepository +{ + public async Task AddAsync(RequiredProblemLanguage entity, CancellationToken cancellationToken = default) + { + await context.RequiredProblemLanguages.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(RequiredProblemLanguage entity, CancellationToken cancellationToken = default) + { + context.RequiredProblemLanguages.Update(entity); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context.RequiredProblemLanguages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + public async Task> GetAllOrderedAsync( + CancellationToken cancellationToken = default + ) => await context.RequiredProblemLanguages.OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); + + public async Task> GetAllOrderedForTrackAsync( + Guid trackId, + CancellationToken cancellationToken = default + ) + { + List trackLanguageVersionIds = await context + .Languages.Where(l => l.TrackId == trackId) + .SelectMany(l => l.Versions) + .Select(v => v.Id) + .ToListAsync(cancellationToken); + + return await context + .RequiredProblemLanguages.Where(x => trackLanguageVersionIds.Contains(x.LanguageVersionId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + } + + public async Task FindByLanguageVersionIdAsync( + Guid languageVersionId, + CancellationToken cancellationToken = default + ) => + await context.RequiredProblemLanguages.FirstOrDefaultAsync( + x => x.LanguageVersionId == languageVersionId, + cancellationToken + ); + + public async Task RemoveAsync(Guid id, CancellationToken cancellationToken = default) + { + await context.RequiredProblemLanguages.Where(x => x.Id == id).ExecuteDeleteAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/SubmissionJobs/SubmissionJobRepository.cs b/StackDuel.Infrastructure/Repositories/SubmissionJobs/SubmissionJobRepository.cs new file mode 100644 index 0000000..920fc1a --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/SubmissionJobs/SubmissionJobRepository.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.SubmissionJobs; +using StackDuel.Domain.SubmissionJobs.Entities; +using StackDuel.Domain.SubmissionJobs.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.SubmissionJobs; + +internal sealed class SubmissionJobRepository(StackDuelDbContext context) : ISubmissionJobRepository +{ + public async Task AddAsync(SubmissionJob entity, CancellationToken cancellationToken = default) + { + await context.SubmissionJobs.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context.SubmissionJobs.Include(j => j.Attempts).FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + + public async Task> FindPendingAsync( + int batchSize, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + + // Excludes jobs currently held by another worker's lock lease so the + // Quartz sweep only ever picks up genuinely idle/orphaned jobs, not + // ones actively being driven by the RabbitMQ continuation flow. + return await context + .SubmissionJobs.Include(j => j.Attempts) + .Where(j => + (j.Status == SubmissionJobStatus.Pending || j.Status == SubmissionJobStatus.Running) + && (j.LockedUntil == null || j.LockedUntil < now) + ) + .OrderBy(j => j.CreatedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task TryClaimAsync( + Guid jobId, + TimeSpan leaseDuration, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + var lockedUntil = now.Add(leaseDuration); + + int rows = await context + .SubmissionJobs.Where(j => + j.Id == jobId + && (j.Status == SubmissionJobStatus.Pending || j.Status == SubmissionJobStatus.Running) + && (j.LockedUntil == null || j.LockedUntil < now) + ) + .ExecuteUpdateAsync(s => s.SetProperty(j => j.LockedUntil, lockedUntil), cancellationToken); + + return rows == 1; + } + + public async Task ReleaseLockAsync(Guid jobId, CancellationToken cancellationToken = default) + { + await context + .SubmissionJobs.Where(j => j.Id == jobId) + .ExecuteUpdateAsync(s => s.SetProperty(j => j.LockedUntil, (DateTime?)null), cancellationToken); + } + + public async Task FindBySubmissionIdAsync( + Guid submissionId, + CancellationToken cancellationToken = default + ) => + await context + .SubmissionJobs.Include(j => j.Attempts) + .FirstOrDefaultAsync(j => j.SubmissionId == submissionId, cancellationToken); + + public async Task UpdateAsync(SubmissionJob entity, CancellationToken cancellationToken = default) + { + // The attempt was already INSERT-ed by PersistAttemptAsync before the handler ran. + // All entities are tracked; SaveChangesAsync detects property changes via snapshot + // and issues only UPDATEs — no INSERT needed here. + await context.SaveChangesAsync(cancellationToken); + } + + public async Task PersistAttemptAsync( + SubmissionJob job, + SubmissionJobAttempt attempt, + CancellationToken cancellationToken = default + ) + { + // Explicitly track the new attempt as Added and wire up the shadow FK, + // then flush it to the DB immediately. This guarantees the row exists + // before the step handler runs so that UpdateAsync only ever does UPDATEs. + var entry = context.Entry(attempt); + entry.State = EntityState.Added; + entry.Property("job_id").CurrentValue = job.Id; + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Submissions/SubmissionReadRepository.cs b/StackDuel.Infrastructure/Repositories/Submissions/SubmissionReadRepository.cs new file mode 100644 index 0000000..2153107 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Submissions/SubmissionReadRepository.cs @@ -0,0 +1,369 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Pagination; +using StackDuel.Application.Submissions; +using StackDuel.Application.Submissions.Dtos; +using StackDuel.Application.Users.Dtos; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Domain.Submissions.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Submissions; + +internal sealed class SubmissionReadRepository(StackDuelDbContext context) : ISubmissionReadRepository +{ + public async Task> GetProblemSubmissionsPagedAsync( + Guid problemId, + PaginationRequest paginationRequest, + Guid? userId, + SubmissionFilter filter, + SubmissionSortOrder sort, + CancellationToken cancellationToken = default + ) + { + int offset = (paginationRequest.Page - 1) * paginationRequest.Size; + + var setupIds = await context + .Problems.AsNoTracking() + .Include(p => p.Setups) + .Where(p => p.Id == problemId) + .SelectMany(p => p.Setups) + .Select(ps => ps.Id) + .ToListAsync(cancellationToken); + + var query = context + .Submissions.AsNoTracking() + .Where(s => setupIds.Contains(s.ProblemSetupId) && s.Type == SubmissionType.Submit); + + query = filter switch + { + SubmissionFilter.MySubmissions => query.Where(s => s.UserId == userId), + SubmissionFilter.UserSolutions => query.Where(s => s.Status == SubmissionStatus.Accepted), + _ => query, + }; + + int total = await query.CountAsync(cancellationToken); + + var languageVersions = context + .Languages.AsNoTracking() + .SelectMany(language => language.Versions, (language, version) => new { language, version }); + + var orderedQuery = + sort == SubmissionSortOrder.Oldest + ? query.OrderBy(s => s.CreatedAt) + : query.OrderByDescending(s => s.CreatedAt); + + var items = await orderedQuery + .Skip(offset) + .Take(paginationRequest.Size) + .Join( + context.Users.AsNoTracking(), + submission => submission.UserId, + user => user.Id, + (submission, user) => new { submission, user } + ) + .Join( + context.Set().AsNoTracking(), + x => x.submission.ProblemSetupId, + setup => setup.Id, + (x, setup) => + new + { + x.submission, + x.user, + setup, + } + ) + .Join( + languageVersions, + x => x.setup.LanguageVersionId, + lv => lv.version.Id, + (x, lv) => + new ProblemSubmissionDto( + x.submission.Id, + x.submission.Status, + new SubmissionLanguageDto(lv.language.Id, lv.language.Name.Value, lv.version.Version.Value), + x.submission.SourceCode.Value, + x.submission.CreatedAt, + new SubmissionUserDto( + x.user.Username.Value, + x.user.ImageUrl != null ? x.user.ImageUrl.Value : null + ), + // Submission.MemoryUsage/ExecutionTime are C#-computed properties + // (Results.Max(...)) wrapping a field-backed collection — not something + // EF's query translator can reliably turn into SQL, so they always came + // back null here. Aggregating over the Results navigation directly (a + // real mapped collection) translates to a correlated MAX subquery instead. + x.submission.Results.Max(r => r.MemoryUsed), + x.submission.Results.Max(r => r.Runtime) + ) + ) + .ToListAsync(cancellationToken); + + return new PageResult + { + Results = items, + Total = total, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + public async Task> GetAdminSubmissionsPagedAsync( + PaginationRequest paginationRequest, + Guid? id, + CancellationToken cancellationToken = default + ) + { + var query = context.Submissions.AsNoTracking(); + + if (id is Guid submissionId) + query = query.Where(s => s.Id == submissionId); + + int total = await query.CountAsync(cancellationToken); + + var ordered = query.OrderByDescending(s => s.CreatedAt); + + var page = id is null + ? ordered.Skip((paginationRequest.Page - 1) * paginationRequest.Size).Take(paginationRequest.Size) + : ordered; + + var items = await ProjectToAdminSubmissionListItem(page).ToListAsync(cancellationToken); + + return new PageResult + { + Results = items, + Total = total, + Page = paginationRequest.Page, + Size = paginationRequest.Size, + }; + } + + public async Task FindAdminSubmissionByIdAsync( + Guid submissionId, + CancellationToken cancellationToken = default + ) + { + var query = context.Submissions.AsNoTracking().Where(s => s.Id == submissionId); + + return await ProjectToAdminSubmissionListItem(query).FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetRecentSubmissionsForUserAsync( + Guid userId, + int limit, + CancellationToken cancellationToken = default + ) + { + var languageVersions = context + .Languages.AsNoTracking() + .SelectMany(language => language.Versions, (language, version) => new { language, version }); + + return await context + .Submissions.AsNoTracking() + .Where(s => s.UserId == userId && s.Type == SubmissionType.Submit) + .OrderByDescending(s => s.CreatedAt) + .Take(limit) + .Join( + context.Set().AsNoTracking(), + submission => submission.ProblemSetupId, + setup => setup.Id, + (submission, setup) => new { submission, setup } + ) + .Join( + context.Problems.AsNoTracking(), + x => EF.Property(x.setup, "problem_id"), + problem => problem.Id, + (x, problem) => + new + { + x.submission, + x.setup, + problem, + } + ) + .Join( + languageVersions, + x => x.setup.LanguageVersionId, + lv => lv.version.Id, + (x, lv) => + new ProfileSubmissionDto( + x.submission.Id, + x.submission.Status, + x.problem.Title.Value, + x.problem.Slug.Value, + new SubmissionLanguageDto(lv.language.Id, lv.language.Name.Value, lv.version.Version.Value), + x.submission.CreatedAt + ) + ) + .ToListAsync(cancellationToken); + } + + public async Task> GetSubmissionCountsByDayForUserAsync( + Guid userId, + DateOnly fromDate, + CancellationToken cancellationToken = default + ) + { + DateTime fromDateTime = DateTime.SpecifyKind(fromDate.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc); + + return await context + .Submissions.AsNoTracking() + .Where(s => s.UserId == userId && s.Type == SubmissionType.Submit && s.CreatedAt >= fromDateTime) + .GroupBy(s => s.CreatedAt.Date) + .Select(g => new SubmissionDayCountDto(DateOnly.FromDateTime(g.Key), g.Count())) + .ToListAsync(cancellationToken); + } + + public async Task> GetAcceptedProblemIdsForUserAsync( + Guid userId, + IReadOnlyCollection problemIds, + CancellationToken cancellationToken = default + ) + { + if (problemIds.Count == 0) + return []; + + return await context + .Submissions.AsNoTracking() + .Where(s => s.UserId == userId && s.Status == SubmissionStatus.Accepted && s.Type == SubmissionType.Submit) + .Join( + context.Set().AsNoTracking(), + submission => submission.ProblemSetupId, + setup => setup.Id, + (submission, setup) => EF.Property(setup, "problem_id") + ) + .Where(problemId => problemIds.Contains(problemId)) + .Distinct() + .ToListAsync(cancellationToken); + } + + public async Task FindSummaryAsync( + Guid submissionId, + CancellationToken cancellationToken = default + ) + { + return await context + .Submissions.AsNoTracking() + .Where(s => s.Id == submissionId) + .Select(s => new SubmissionSummaryDto(s.UserId, s.ProblemSetupId, s.Type, s.Status)) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetSubmissionsForGameAsync( + Guid gameId, + CancellationToken cancellationToken = default + ) + { + var languageVersions = context + .Languages.AsNoTracking() + .SelectMany(language => language.Versions, (language, version) => new { language, version }); + + return await context + .Submissions.AsNoTracking() + .Where(s => + s.GameId == gameId + && s.Type == SubmissionType.Submit + && (s.Status == SubmissionStatus.Accepted || s.Status == SubmissionStatus.WrongAnswer) + ) + .Join( + context.Set().AsNoTracking(), + submission => submission.ProblemSetupId, + setup => setup.Id, + (submission, setup) => new { submission, setup } + ) + .Join( + context.Problems.AsNoTracking(), + x => EF.Property(x.setup, "problem_id"), + problem => problem.Id, + (x, problem) => + new + { + x.submission, + x.setup, + problem, + } + ) + .Join( + languageVersions, + x => x.setup.LanguageVersionId, + lv => lv.version.Id, + (x, lv) => + new GameSubmissionEventDto( + x.submission.Id, + x.submission.UserId, + x.problem.Id, + x.problem.Title.Value, + x.problem.Slug.Value, + x.submission.Status, + x.submission.CreatedAt, + lv.language.Name.Value + ) + ) + .OrderBy(dto => dto.CreatedAt) + .ToListAsync(cancellationToken); + } + + private IQueryable ProjectToAdminSubmissionListItem(IQueryable submissions) + { + var languageVersions = context + .Languages.AsNoTracking() + .SelectMany(language => language.Versions, (language, version) => new { language, version }); + + return submissions + .Join( + context.Users.AsNoTracking(), + submission => submission.UserId, + user => user.Id, + (submission, user) => new { submission, user } + ) + .Join( + context.Set().AsNoTracking(), + x => x.submission.ProblemSetupId, + setup => setup.Id, + (x, setup) => + new + { + x.submission, + x.user, + setup, + } + ) + .Join( + context.Problems.AsNoTracking(), + x => EF.Property(x.setup, "problem_id"), + problem => problem.Id, + (x, problem) => + new + { + x.submission, + x.user, + x.setup, + problem, + } + ) + .Join( + languageVersions, + x => x.setup.LanguageVersionId, + lv => lv.version.Id, + (x, lv) => + new AdminSubmissionListItemDto( + x.submission.Id, + x.submission.Type, + x.submission.Status, + x.setup.Id, + x.problem.Id, + x.problem.Title.Value, + x.problem.Slug.Value, + new SubmissionLanguageDto(lv.language.Id, lv.language.Name.Value, lv.version.Version.Value), + new SubmissionUserDto( + x.user.Username.Value, + x.user.ImageUrl != null ? x.user.ImageUrl.Value : null + ), + x.submission.CreatedAt, + x.submission.Results.Max(r => r.MemoryUsed), + x.submission.Results.Max(r => r.Runtime) + ) + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Submissions/SubmissionWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Submissions/SubmissionWriteRepository.cs new file mode 100644 index 0000000..8fa4048 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Submissions/SubmissionWriteRepository.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Submissions; +using StackDuel.Domain.Submissions.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Submissions; + +internal sealed class SubmissionWriteRepository(StackDuelDbContext context) : ISubmissionWriteRepository +{ + public async Task AddAsync(Submission entity, CancellationToken cancellationToken = default) + { + await context.Submissions.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context + .Submissions.Include(s => s.Results) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } + + public async Task UpdateAsync(Submission entity, CancellationToken cancellationToken = default) + { + context.Submissions.Update(entity); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/TestCaseGeneration/TestCaseGenerationJobRepository.cs b/StackDuel.Infrastructure/Repositories/TestCaseGeneration/TestCaseGenerationJobRepository.cs new file mode 100644 index 0000000..bcec54a --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/TestCaseGeneration/TestCaseGenerationJobRepository.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.TestCaseGeneration; + +internal sealed class TestCaseGenerationJobRepository(StackDuelDbContext context) : ITestCaseGenerationJobRepository +{ + public async Task AddAsync(TestCaseGenerationJob job, CancellationToken cancellationToken = default) + { + await context.TestCaseGenerationJobs.AddAsync(job, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(TestCaseGenerationJob job, CancellationToken cancellationToken = default) + { + context.TestCaseGenerationJobs.Update(job); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context.TestCaseGenerationJobs.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + } + + public async Task> FindPendingAsync( + int batchSize, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + + return await context + .TestCaseGenerationJobs.Where(j => + j.Status == TestCaseGenerationJobStatus.Pending && (j.LockedUntil == null || j.LockedUntil < now) + ) + .OrderBy(j => j.CreatedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + } + + public async Task TryClaimAsync( + Guid jobId, + TimeSpan leaseDuration, + CancellationToken cancellationToken = default + ) + { + var now = DateTime.UtcNow; + var lockedUntil = now.Add(leaseDuration); + + int rows = await context + .TestCaseGenerationJobs.Where(j => + j.Id == jobId + && j.Status == TestCaseGenerationJobStatus.Pending + && (j.LockedUntil == null || j.LockedUntil < now) + ) + .ExecuteUpdateAsync(s => s.SetProperty(j => j.LockedUntil, lockedUntil), cancellationToken); + + return rows == 1; + } + + public async Task ReleaseLockAsync(Guid jobId, CancellationToken cancellationToken = default) + { + await context + .TestCaseGenerationJobs.Where(j => j.Id == jobId) + .ExecuteUpdateAsync(s => s.SetProperty(j => j.LockedUntil, (DateTime?)null), cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/TestSuites/TestSuiteWriteRepository.cs b/StackDuel.Infrastructure/Repositories/TestSuites/TestSuiteWriteRepository.cs new file mode 100644 index 0000000..7086604 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/TestSuites/TestSuiteWriteRepository.cs @@ -0,0 +1,385 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestSuites; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; +using StackDuel.Domain.TestSuites.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.TestSuites; + +internal sealed class TestSuiteWriteRepository(StackDuelDbContext context, ITestCaseGeneratorService testCaseGenerator) + : ITestSuiteWriteRepository +{ + public async Task AddAsync(TestSuite testSuite, CancellationToken cancellationToken = default) + { + await context.TestSuites.AddAsync(testSuite, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(TestSuite testSuite, CancellationToken cancellationToken = default) + { + context.TestSuites.Update(testSuite); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context + .TestSuites.Include(s => s.TestCases) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } + + public async Task> FindTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ) + { + return await context + .Problems.AsNoTracking() + .SelectMany(p => p.Setups) + .Where(s => s.Id == problemSetupId) + .SelectMany(s => s.TestSuites) + .SelectMany(ts => ts.TestCases) + .Select(tc => tc.Id) + .ToListAsync(cancellationToken); + } + + public async Task> FindPublicTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ) + { + return await context + .Problems.AsNoTracking() + .SelectMany(p => p.Setups) + .Where(s => s.Id == problemSetupId) + .SelectMany(s => s.TestSuites) + .Where(ts => ts.Type == TestSuiteType.Sample) + .SelectMany(ts => ts.TestCases) + .Select(tc => tc.Id) + .ToListAsync(cancellationToken); + } + + public async Task> FindGradingTestCaseIdsByProblemSetupIdAsync( + Guid problemSetupId, + int targetCount, + CancellationToken cancellationToken = default + ) + { + var rows = await context + .Problems.AsNoTracking() + .SelectMany(p => p.Setups) + .Where(s => s.Id == problemSetupId) + .SelectMany(s => s.TestSuites) + .SelectMany( + ts => ts.TestCases, + (ts, tc) => + new + { + tc.Id, + ts.Type, + tc.Source, + tc.RetiredAt, + } + ) + .Where(r => r.RetiredAt == null) + .ToListAsync(cancellationToken); + + // Guaranteed: every public example and every hand-authored hidden case — these always + // run, they're not part of the random draw. Only the generated pool is sampled, and + // only to fill whatever's left after the guaranteed set. + List guaranteedIds = + [ + .. rows.Where(r => + r.Type == TestSuiteType.Sample + || (r.Type == TestSuiteType.Hidden && r.Source == TestCaseSource.Authored) + ) + .Select(r => r.Id), + ]; + + List generatedPoolIds = + [ + .. rows.Where(r => r.Type == TestSuiteType.Hidden && r.Source == TestCaseSource.Generated) + .Select(r => r.Id), + ]; + + int remaining = targetCount <= 0 ? generatedPoolIds.Count : Math.Max(targetCount - guaranteedIds.Count, 0); + int take = Math.Min(remaining, generatedPoolIds.Count); + + List randomPicks = [.. generatedPoolIds.OrderBy(_ => Guid.NewGuid()).Take(take)]; + + return [.. guaranteedIds, .. randomPicks]; + } + + public async Task> CreateAdHocTestCasesAsync( + Guid problemSetupId, + IReadOnlyCollection> customTestCaseInputs, + CancellationToken cancellationToken = default + ) + { + if (customTestCaseInputs.Count == 0) + return []; + + var valueTypesByPosition = await context + .Problems.AsNoTracking() + .SelectMany(p => p.Setups) + .Where(s => s.Id == problemSetupId) + .SelectMany(s => s.TestSuites) + .SelectMany(ts => ts.TestCases) + .Where(tc => tc.RetiredAt == null) + .SelectMany(tc => tc.Inputs) + .GroupBy(i => i.Position) + .Select(g => new { Position = g.Key, ValueType = g.First().ValueType }) + .ToDictionaryAsync(g => g.Position, g => g.ValueType, cancellationToken); + + var suite = new TestSuite($"AdHoc Submission Suite {Guid.NewGuid():N}", TestSuiteType.Sample); + + int index = 1; + foreach (var customInputs in customTestCaseInputs) + { + var testCase = suite.AddTestCase($"Custom {index}"); + int position = 0; + foreach (string input in customInputs) + { + if (string.IsNullOrWhiteSpace(input)) + { + position++; + continue; + } + + string valueType = valueTypesByPosition.GetValueOrDefault(position, "json"); + testCase.AddInput(input, valueType); + position++; + } + + index++; + } + + await context.TestSuites.AddAsync(suite, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return [.. suite.TestCases.Select(tc => tc.Id)]; + } + + public async Task> FindExpectedOutputsByTestCaseIdsAsync( + IEnumerable testCaseIds, + CancellationToken cancellationToken = default + ) + { + var ids = testCaseIds.Distinct().ToArray(); + if (ids.Length == 0) + return []; + + var rows = await context + .TestSuites.AsNoTracking() + .SelectMany(ts => ts.TestCases) + .Where(tc => ids.Contains(tc.Id)) + .Select(tc => new + { + tc.Id, + ExpectedOutput = string.Join(", ", tc.ExpectedOutputs.Select(output => output.Value)), + }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(x => x.Id, x => x.ExpectedOutput); + } + + public async Task> FindInputsByTestCaseIdsAsync( + IEnumerable testCaseIds, + CancellationToken cancellationToken = default + ) + { + var ids = testCaseIds.Distinct().ToArray(); + if (ids.Length == 0) + return []; + + var testCases = await context + .Set() + .AsNoTracking() + .Include(tc => tc.Inputs.OrderBy(i => i.Position)) + .Where(tc => ids.Contains(tc.Id)) + .ToListAsync(cancellationToken); + + var generationSpecIds = testCases + .Where(tc => tc.Source == TestCaseSource.Generated) + .Select(tc => tc.GenerationSpecId!.Value) + .Distinct() + .ToArray(); + + Dictionary specsById = []; + if (generationSpecIds.Length > 0) + { + specsById = await context + .Set() + .AsNoTracking() + .Where(s => generationSpecIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, cancellationToken); + } + + var result = new Dictionary(); + foreach (var testCase in testCases) + { + if ( + testCase.Source == TestCaseSource.Generated + && testCase.GenerationSpecId is Guid specId + && testCase.GenerationCaseIndex is int caseIndex + && specsById.TryGetValue(specId, out var spec) + ) + { + var generatedInputs = testCaseGenerator.GenerateInputs(spec, caseIndex); + result[testCase.Id] = string.Join(", ", generatedInputs.Select(v => v.Value)); + } + else + { + result[testCase.Id] = string.Join(", ", testCase.Inputs.Select(i => i.Value)); + } + } + + return result; + } + + public async Task FindPipelineIdByProblemSetupIdAsync( + Guid problemSetupId, + CancellationToken cancellationToken = default + ) + { + return await context + .Problems.AsNoTracking() + .SelectMany(p => p.Setups) + .Where(s => s.Id == problemSetupId) + .Select(s => (Guid?)s.PipelineId) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task FindOrCreateGeneratedSuiteAsync( + string suiteName, + IReadOnlyCollection problemSetupIds, + CancellationToken cancellationToken = default + ) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suiteName, cancellationToken); + + Guid suiteId; + if (existing is null) + { + var suite = new TestSuite(suiteName, TestSuiteType.Hidden); + await context.TestSuites.AddAsync(suite, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + foreach (Guid setupId in problemSetupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({setupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + + return suiteId; + } + + public async Task RetireGeneratedTestCasesAsync(Guid testSuiteId, CancellationToken cancellationToken = default) + { + // Retire (not delete) — a submission_result FK is ON DELETE RESTRICT, so a generated + // case that's ever been graded against can never actually be removed. Retired cases + // are simply excluded from FindGradingTestCaseIdsByProblemSetupIdAsync's selection. + await context.Database.ExecuteSqlAsync( + $""" + UPDATE test_cases + SET retired_at = {DateTime.UtcNow} + WHERE test_suite_id = {testSuiteId} AND source = {(int)TestCaseSource.Generated} AND retired_at IS NULL + """, + cancellationToken + ); + } + + public async Task ReplaceSampleTestCasesAsync( + IReadOnlyCollection problemSetupIds, + string suiteName, + IReadOnlyList testCases, + CancellationToken cancellationToken = default + ) + { + TestSuite? existing = await context + .TestSuites.AsNoTracking() + .FirstOrDefaultAsync(t => t.Name == suiteName, cancellationToken); + + Guid suiteId; + if (existing is null) + { + var suite = new TestSuite(suiteName, TestSuiteType.Sample); + await context.TestSuites.AddAsync(suite, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + suiteId = suite.Id; + } + else + { + suiteId = existing.Id; + } + + foreach (Guid problemSetupId in problemSetupIds) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO problem_setup_test_suites (problem_setup_id, test_suite_id) + VALUES ({problemSetupId}, {suiteId}) + ON CONFLICT DO NOTHING + """, + cancellationToken + ); + } + + // Retire (not delete) the suite's previous authored cases — same reasoning as + // RetireGeneratedTestCasesAsync: a submission may already have graded against one. + await context.Database.ExecuteSqlAsync( + $""" + UPDATE test_cases + SET retired_at = {DateTime.UtcNow} + WHERE test_suite_id = {suiteId} AND source = {(int)TestCaseSource.Authored} AND retired_at IS NULL + """, + cancellationToken + ); + + TestSuite reloadedSuite = + await FindByIdAsync(suiteId, cancellationToken) + ?? throw new InvalidOperationException($"Sample suite {suiteId} could not be reloaded."); + + int index = 1; + foreach (AuthoredTestCaseSpec spec in testCases) + { + TestCase testCase = reloadedSuite.AddTestCase(spec.Name ?? $"Sample {index}"); + context.Entry(testCase).State = EntityState.Added; + + foreach ((string value, string valueType) in spec.Inputs) + { + TestCaseInput input = testCase.AddInput(value, valueType); + context.Entry(input).State = EntityState.Added; + } + + TestCaseExpectedOutput output = testCase.AddExpectedOutput( + spec.ExpectedOutputValue, + spec.ExpectedOutputValueType + ); + context.Entry(output).State = EntityState.Added; + + index++; + } + + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Tracks/TrackReadRepository.cs b/StackDuel.Infrastructure/Repositories/Tracks/TrackReadRepository.cs new file mode 100644 index 0000000..b8876bc --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Tracks/TrackReadRepository.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Tracks; +using StackDuel.Domain.Tracks.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Tracks; + +internal sealed class TrackReadRepository(StackDuelDbContext context) : ITrackReadRepository +{ + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + await context.Tracks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + + public async Task FindByKeyAsync(string key, CancellationToken cancellationToken = default) => + await context.Tracks.AsNoTracking().FirstOrDefaultAsync(t => t.Key == key && t.IsActive, cancellationToken); + + public async Task> FindByKeysAsync( + IEnumerable keys, + CancellationToken cancellationToken = default + ) + { + List keyList = keys.ToList(); + + return await context + .Tracks.AsNoTracking() + .Where(t => keyList.Contains(t.Key) && t.IsActive) + .ToListAsync(cancellationToken); + } + + public async Task> FindByIdsAsync( + IEnumerable ids, + CancellationToken cancellationToken = default + ) + { + List idList = ids.ToList(); + + return await context.Tracks.AsNoTracking().Where(t => idList.Contains(t.Id)).ToListAsync(cancellationToken); + } + + public async Task> GetActiveTracksAsync(CancellationToken cancellationToken = default) => + await context.Tracks.AsNoTracking().Where(t => t.IsActive).OrderBy(t => t.Name).ToListAsync(cancellationToken); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Users/UserAvatarWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Users/UserAvatarWriteRepository.cs new file mode 100644 index 0000000..fcbb939 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Users/UserAvatarWriteRepository.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Users; + +internal sealed class UserAvatarWriteRepository(StackDuelDbContext context) : IUserAvatarWriteRepository +{ + public async Task AddAsync(UserAvatar avatar, CancellationToken cancellationToken = default) + { + await context.UserAvatars.AddAsync(avatar, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdForUserAsync( + Guid id, + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await context.UserAvatars.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId, cancellationToken); + } + + public async Task> GetAllByUserIdNewestFirstAsync( + Guid userId, + CancellationToken cancellationToken = default + ) + { + return await context + .UserAvatars.Where(a => a.UserId == userId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task DeleteAsync(UserAvatar avatar, CancellationToken cancellationToken = default) + { + context.UserAvatars.Remove(avatar); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Users/UserReadRepository.cs b/StackDuel.Infrastructure/Repositories/Users/UserReadRepository.cs new file mode 100644 index 0000000..db22c52 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Users/UserReadRepository.cs @@ -0,0 +1,241 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.Groups.Dtos; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users; +using StackDuel.Application.Users.Dtos; +using StackDuel.Application.Users.Dtos.Admin; +using StackDuel.Infrastructure.Persistence.Read; + +namespace StackDuel.Infrastructure.Repositories.Users; + +internal sealed class UserReadRepository(StackDuelReadDbContext context) : IUserReadRepository +{ + public async Task FindBySubAsync(string sub, CancellationToken cancellationToken) + { + var user = await context + .Users.AsNoTracking() + .Where(u => u.Sub == sub) + .Select(u => new UserDto( + u.Id, + u.Sub, + u.Username, + u.ImageUrl, + u.Bio, + u.IsPrivate, + UsernameLastChangedAt: u.UsernameLastChangedAt, + CreatedAt: u.CreatedAt, + SetupCompletedAt: u.SetupCompletedAt, + LanguagePreferenceIds: Array.Empty() + )) + .FirstOrDefaultAsync(cancellationToken); + + if (user is null) + return null; + + IReadOnlyList languagePreferenceIds = await GetLanguagePreferenceIdsAsync(user.Id, cancellationToken); + + return user with + { + LanguagePreferenceIds = languagePreferenceIds, + }; + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await context + .Users.AsNoTracking() + .Where(u => u.Id == id) + .Select(u => new UserDto( + u.Id, + u.Sub, + u.Username, + u.ImageUrl, + u.Bio, + u.IsPrivate, + UsernameLastChangedAt: u.UsernameLastChangedAt, + CreatedAt: u.CreatedAt, + SetupCompletedAt: u.SetupCompletedAt, + LanguagePreferenceIds: Array.Empty() + )) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> FindByIdsAsync( + IEnumerable ids, + CancellationToken cancellationToken + ) + { + Guid[] idArray = [.. ids]; + return await context + .Users.AsNoTracking() + .Where(u => idArray.Contains(u.Id)) + .Select(u => new UserDto( + u.Id, + u.Sub, + u.Username, + u.ImageUrl, + u.Bio, + u.IsPrivate, + UsernameLastChangedAt: u.UsernameLastChangedAt, + CreatedAt: u.CreatedAt, + SetupCompletedAt: u.SetupCompletedAt, + LanguagePreferenceIds: Array.Empty() + )) + .ToDictionaryAsync(u => u.Id, cancellationToken); + } + + private async Task> GetLanguagePreferenceIdsAsync( + Guid userId, + CancellationToken cancellationToken + ) + { + return await context + .UserLanguagePreferences.Where(ulp => ulp.UserId == userId) + .OrderBy(ulp => ulp.Position) + .Select(ulp => ulp.LanguageId) + .ToListAsync(cancellationToken); + } + + public async Task FindProfileByUsernameAsync(string username, CancellationToken cancellationToken) + { + return await context + .Users.AsNoTracking() + .Where(u => u.Username == username) + .Select(u => new UserProfileDto( + u.Id, + u.Username, + u.Bio, + u.ImageUrl, + u.CreatedAt, + u.IsPrivate, + false, + null, + null, + null + )) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task> GetAdminUsersPageableAsync( + PaginationRequest pagination, + string? search, + CancellationToken cancellationToken = default + ) + { + int offset = (pagination.Page - 1) * pagination.Size; + + var query = context.Users.AsNoTracking().AsQueryable(); + + string? term = search?.Trim(); + if (!string.IsNullOrEmpty(term)) + { + if (Guid.TryParse(term, out Guid id)) + { + query = query.Where(u => u.Id == id); + } + else + { + string usernameTerm = term.ToLowerInvariant(); + query = query.Where(u => u.Username.ToLower().Contains(usernameTerm)); + } + } + + int total = await query.CountAsync(cancellationToken); + + var pageUsers = await query + .OrderBy(u => u.Id) + .Skip(offset) + .Take(pagination.Size) + .Select(u => new + { + u.Id, + u.Username, + u.ImageUrl, + u.UsernameLastChangedAt, + u.CreatedAt, + u.SetupCompletedAt, + }) + .ToListAsync(cancellationToken); + + Guid[] userIds = [.. pageUsers.Select(u => u.Id)]; + + var groupRows = await ( + from userGroup in context.UserGroups + join grp in context.Groups on userGroup.GroupId equals grp.Id + where userIds.Contains(userGroup.UserId) + select new + { + userGroup.UserId, + GroupId = grp.Id, + GroupName = grp.Name, + } + ).ToListAsync(cancellationToken); + + Dictionary> groupsByUser = groupRows + .GroupBy(r => r.UserId) + .ToDictionary(g => g.Key, g => g.Select(r => new GroupDto(r.GroupId, r.GroupName)).ToList()); + + return new PageResult + { + Results = + [ + .. pageUsers.Select(u => new AdminUserDto( + u.Id, + u.Username, + u.ImageUrl, + u.UsernameLastChangedAt, + u.CreatedAt, + u.SetupCompletedAt, + groupsByUser.TryGetValue(u.Id, out var groups) ? groups : [] + )), + ], + Total = total, + Page = pagination.Page, + Size = pagination.Size, + }; + } + + public async Task FindAdminUserDetailByIdAsync( + Guid id, + CancellationToken cancellationToken = default + ) + { + var user = await context + .Users.AsNoTracking() + .Where(u => u.Id == id) + .Select(u => new + { + u.Id, + u.Username, + u.ImageUrl, + u.Bio, + u.IsPrivate, + u.UsernameLastChangedAt, + u.CreatedAt, + u.SetupCompletedAt, + }) + .FirstOrDefaultAsync(cancellationToken); + + if (user is null) + return null; + + var groups = await ( + from userGroup in context.UserGroups + join grp in context.Groups on userGroup.GroupId equals grp.Id + where userGroup.UserId == id + select new GroupDto(grp.Id, grp.Name) + ).ToListAsync(cancellationToken); + + return new AdminUserDetailDto( + user.Id, + user.Username, + user.ImageUrl, + user.Bio, + user.IsPrivate, + user.UsernameLastChangedAt, + user.CreatedAt, + user.SetupCompletedAt, + groups + ); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Repositories/Users/UserWriteRepository.cs b/StackDuel.Infrastructure/Repositories/Users/UserWriteRepository.cs new file mode 100644 index 0000000..d1081b7 --- /dev/null +++ b/StackDuel.Infrastructure/Repositories/Users/UserWriteRepository.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Domain.Authorization.Rbac.Entities; +using StackDuel.Domain.Authorization.Rbac.ValueObjects; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.Entities; +using StackDuel.Domain.Users.ValueObjects; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.Repositories.Users; + +internal sealed class UserWriteRepository(StackDuelDbContext context) : IUserWriteRepository +{ + public async Task AddAsync(User user, CancellationToken cancellationToken) + { + await context.Users.AddAsync(user, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task AddToGroupAsync(Guid userId, string groupName, CancellationToken cancellationToken = default) + { + Group? group = await context.Groups.FirstOrDefaultAsync(g => g.Name == new Name(groupName), cancellationToken); + + if (group is null) + return; + + var joinEntity = context.Set>("user_groups"); + + bool alreadyAssigned = await joinEntity.AnyAsync( + ug => EF.Property(ug, "user_id") == userId && EF.Property(ug, "group_id") == group.Id, + cancellationToken + ); + + if (alreadyAssigned) + return; + + joinEntity.Add(new Dictionary { ["user_id"] = userId, ["group_id"] = group.Id }); + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task> GetGroupIdsAsync(Guid userId, CancellationToken cancellationToken = default) + { + var joinEntity = context.Set>("user_groups"); + + List> rows = await joinEntity + .Where(ug => EF.Property(ug, "user_id") == userId) + .ToListAsync(cancellationToken); + + return [.. rows.Select(row => ((GroupId)row["group_id"]).Value)]; + } + + public async Task SetGroupsAsync( + Guid userId, + IReadOnlyCollection groupIds, + CancellationToken cancellationToken = default + ) + { + var joinEntity = context.Set>("user_groups"); + + List> currentRows = await joinEntity + .Where(ug => EF.Property(ug, "user_id") == userId) + .ToListAsync(cancellationToken); + + HashSet desiredGroupIds = [.. groupIds]; + + foreach (Dictionary row in currentRows) + { + Guid groupId = ((GroupId)row["group_id"]).Value; + + if (desiredGroupIds.Remove(groupId)) + continue; + + joinEntity.Remove(row); + } + + foreach (Guid groupId in desiredGroupIds) + { + joinEntity.Add( + new Dictionary { ["user_id"] = userId, ["group_id"] = new GroupId(groupId) } + ); + } + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task SetLanguagePreferencesAsync( + Guid userId, + IReadOnlyList languageIdsInOrder, + CancellationToken cancellationToken = default + ) + { + var joinEntity = context.Set>("user_language_preferences"); + + List> currentRows = await joinEntity + .Where(ulp => EF.Property(ulp, "user_id") == userId) + .ToListAsync(cancellationToken); + + foreach (Dictionary row in currentRows) + joinEntity.Remove(row); + + await context.SaveChangesAsync(cancellationToken); + + List orderedLanguageIds = [.. languageIdsInOrder.Distinct()]; + + for (int position = 0; position < orderedLanguageIds.Count; position++) + { + joinEntity.Add( + new Dictionary + { + ["user_id"] = userId, + ["language_id"] = orderedLanguageIds[position], + ["position"] = position, + } + ); + } + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await context.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task FindBySubAsync(string sub, CancellationToken cancellationToken) + { + return await context.Users.FirstOrDefaultAsync(u => u.Sub == sub, cancellationToken); + } + + public async Task UpdateAsync(User user, CancellationToken cancellationToken) + { + context.Users.Update(user); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task FindByUsername(Username username, CancellationToken cancellationToken) + { + return await context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/Settings/OptionExtensions.cs b/StackDuel.Infrastructure/Settings/OptionExtensions.cs new file mode 100644 index 0000000..f371ae0 --- /dev/null +++ b/StackDuel.Infrastructure/Settings/OptionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackDuel.Application.Settings; + +namespace StackDuel.Infrastructure.Settings; + +public static class OptionExtensions +{ + public static IServiceCollection AddOption(this IServiceCollection services, IConfiguration configuration) + where T : class, IOption + { + var instance = + configuration.GetSection(T.SectionName).Get() + ?? throw new InvalidOperationException($"Configuration section '{T.SectionName}' is missing or invalid."); + + services.AddSingleton(instance); + return services; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/StackDuel.Infrastructure.csproj b/StackDuel.Infrastructure/StackDuel.Infrastructure.csproj index b760144..9145ff6 100644 --- a/StackDuel.Infrastructure/StackDuel.Infrastructure.csproj +++ b/StackDuel.Infrastructure/StackDuel.Infrastructure.csproj @@ -1,9 +1,97 @@  - net10.0 enable enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/StackDuel.Infrastructure/Storage/AzureAvatarBlobStorage.cs b/StackDuel.Infrastructure/Storage/AzureAvatarBlobStorage.cs new file mode 100644 index 0000000..55a48b0 --- /dev/null +++ b/StackDuel.Infrastructure/Storage/AzureAvatarBlobStorage.cs @@ -0,0 +1,44 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using StackDuel.Application.Configuration; +using StackDuel.Application.Images; +using StackDuel.Application.Services.Users; + +namespace StackDuel.Infrastructure.Storage; + +internal sealed class AzureAvatarBlobStorage(BlobServiceClient blobServiceClient, AzureStorageOptions options) + : IAvatarBlobStorage +{ + public async Task UploadAsync( + Guid userId, + Guid avatarId, + byte[] content, + string contentType, + CancellationToken cancellationToken = default + ) + { + BlobContainerClient container = blobServiceClient.GetBlobContainerClient(options.AvatarContainerName); + + if (options.AutoCreateContainer) + await container.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: cancellationToken); + + string blobName = $"{userId}/{avatarId}{ImageSignature.ExtensionFor(contentType)}"; + BlobClient blob = container.GetBlobClient(blobName); + + using var stream = new MemoryStream(content); + await blob.UploadAsync( + stream, + new BlobHttpHeaders { ContentType = contentType }, + cancellationToken: cancellationToken + ); + + return blob.Uri.ToString(); + } + + public async Task DeleteAsync(string blobUrl, CancellationToken cancellationToken = default) + { + var uriBuilder = new BlobUriBuilder(new Uri(blobUrl)); + BlobContainerClient container = blobServiceClient.GetBlobContainerClient(uriBuilder.BlobContainerName); + await container.GetBlobClient(uriBuilder.BlobName).DeleteIfExistsAsync(cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/TestCaseGeneration/BogusTestCaseGeneratorService.cs b/StackDuel.Infrastructure/TestCaseGeneration/BogusTestCaseGeneratorService.cs new file mode 100644 index 0000000..2e55a84 --- /dev/null +++ b/StackDuel.Infrastructure/TestCaseGeneration/BogusTestCaseGeneratorService.cs @@ -0,0 +1,74 @@ +using Bogus; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.ValueObjects; +using System.Text.Json; + +namespace StackDuel.Infrastructure.TestCaseGeneration; + +internal sealed class BogusTestCaseGeneratorService : ITestCaseGeneratorService +{ + private const string DefaultCharset = "abcdefghijklmnopqrstuvwxyz"; + + public IReadOnlyList GenerateInputs(TestCaseGenerationSpec spec, int caseIndex) + { + var random = new Randomizer(unchecked((spec.Seed * 397) ^ caseIndex)); + + return [.. spec.Parameters.Select(parameter => GenerateValue(parameter, random))]; + } + + private static GeneratedValue GenerateValue(GenerationParameterSpec parameter, Randomizer random) + { + return parameter.ValueType switch + { + "integer" => new GeneratedValue( + random.Int((int)(parameter.Min ?? -1000), (int)(parameter.Max ?? 1000)).ToString(), + parameter.ValueType + ), + + "double" => new GeneratedValue( + random.Double(parameter.Min ?? 0, parameter.Max ?? 1000).ToString("R"), + parameter.ValueType + ), + + "boolean" => new GeneratedValue(random.Bool() ? "true" : "false", parameter.ValueType), + + "string" => new GeneratedValue( + JsonSerializer.Serialize(GenerateString(parameter, random)), + parameter.ValueType + ), + + "integer_array" => new GeneratedValue( + JsonSerializer.Serialize(GenerateIntArray(parameter, random)), + parameter.ValueType + ), + + _ => throw new NotSupportedException($"Unsupported generation value type '{parameter.ValueType}'."), + }; + } + + private static string GenerateString(GenerationParameterSpec parameter, Randomizer random) + { + int minLength = parameter.LengthMin ?? 1; + int maxLength = parameter.LengthMax ?? Math.Max(minLength, 10); + string charset = string.IsNullOrEmpty(parameter.Charset) ? DefaultCharset : parameter.Charset; + + return random.String2(minLength, maxLength, charset); + } + + private static int[] GenerateIntArray(GenerationParameterSpec parameter, Randomizer random) + { + int minLength = parameter.LengthMin ?? 1; + int maxLength = parameter.LengthMax ?? Math.Max(minLength, 10); + int length = random.Int(minLength, maxLength); + + int min = (int)(parameter.Min ?? -1000); + int max = (int)(parameter.Max ?? 1000); + + int[] values = new int[length]; + for (int i = 0; i < length; i++) + values[i] = random.Int(min, max); + + return values; + } +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/TestCaseGeneration/TestCaseGenerationService.cs b/StackDuel.Infrastructure/TestCaseGeneration/TestCaseGenerationService.cs new file mode 100644 index 0000000..9828283 --- /dev/null +++ b/StackDuel.Infrastructure/TestCaseGeneration/TestCaseGenerationService.cs @@ -0,0 +1,236 @@ +using Microsoft.EntityFrameworkCore; +using StackDuel.Application.ExecutionEngine; +using StackDuel.Application.Languages; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Domain.Problems; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestSuites; +using StackDuel.Domain.TestSuites.Entities; +using StackDuel.Domain.TestSuites.Enums; +using StackDuel.Infrastructure.ExecutionEngine; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.TestCaseGeneration; + +internal sealed class TestCaseGenerationService( + IProblemRepository problemRepository, + ITestSuiteWriteRepository testSuiteRepository, + ILanguageReadRepository languageReadRepository, + ICodeTemplateStrategyResolver templateResolver, + ITestCaseGeneratorService generator, + Judge0ReferenceSolutionRunner runner, + StackDuelDbContext db +) : ITestCaseGenerationService +{ + public async Task GenerateAsync( + GenerateTestCasesRequest request, + CancellationToken cancellationToken = default + ) + { + var problem = await problemRepository.FindBySetupIdAsync(request.ProblemSetupId, cancellationToken); + if (problem is null) + return Failed(request, $"Problem for setup {request.ProblemSetupId} not found."); + + var setup = problem.Setups.FirstOrDefault(s => s.Id == request.ProblemSetupId); + if (setup is null) + return Failed(request, $"Setup {request.ProblemSetupId} not found on problem."); + + var languages = await languageReadRepository.FindLanguagesByVersionId( + [setup.LanguageVersionId], + cancellationToken + ); + var language = languages.FirstOrDefault(); + if (language is null) + return Failed(request, $"Language for version {setup.LanguageVersionId} not found."); + + var versionEntry = language.Versions.FirstOrDefault(v => v.Id == setup.LanguageVersionId); + if (versionEntry is null) + return Failed(request, $"Language version entry {setup.LanguageVersionId} not found."); + + var templateStrategy = templateResolver.Resolve(language.Name.Value); + int judge0LanguageId = versionEntry.Judge0Id.Value; + + // 1. A reference solution that can't pass the curated cases must not be trusted to + // generate ground truth for random ones. + IReadOnlyList curatedTestCaseIds = await testSuiteRepository.FindTestCaseIdsByProblemSetupIdAsync( + request.ProblemSetupId, + cancellationToken + ); + + List curatedTestCases = await db.Set() + .AsNoTracking() + .Include(tc => tc.Inputs.OrderBy(i => i.Position)) + .Include(tc => tc.ExpectedOutputs) + .Where(tc => curatedTestCaseIds.Contains(tc.Id) && tc.Source == TestCaseSource.Authored) + .AsSplitQuery() + .ToListAsync(cancellationToken); + + if (curatedTestCases.Count > 0) + { + List sanityWarnings = await runner.RunAgainstCasesAsync( + request.ReferenceSolutionCode, + setup.FunctionName, + templateStrategy, + judge0LanguageId, + curatedTestCases, + cancellationToken + ); + + if (sanityWarnings.Count > 0) + return new GenerateTestCasesResult(request.TargetCaseCount, 0, 0, sanityWarnings); + } + + // 2. Reference solution is trusted — persist the spec and attach it to the setup. + // Everything from here on runs in one transaction: a failure partway through (a + // transient Judge0 error, say) must not leave the setup pointing at a spec with no + // generated cases behind it — that would poison it, since the idempotency check above + // only looks at whether a spec is attached, not whether generation actually finished. + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + + int previousVersion = await db + .Set() + .Where(s => s.ProblemSetupId == request.ProblemSetupId) + .Select(s => s.Version) + .DefaultIfEmpty(0) + .MaxAsync(cancellationToken); + + var spec = new TestCaseGenerationSpec( + request.ProblemSetupId, + request.Parameters, + request.OutputValueType, + request.TargetCaseCount, + request.Seed, + version: previousVersion + 1 + ); + + // Stage the spec and the problem's scalar changes without saving yet — problem is + // already tracked (loaded via FindBySetupIdAsync, not AsNoTracking), so calling + // IProblemRepository.UpdateAsync here would re-run context.Problems.Update(), forcing + // the whole graph (including the m2m TestSuites join) to Modified and misfiring + // against rows that either don't exist yet or don't need touching. Everything gets + // saved together in one SaveChangesAsync call once the generated cases are built below. + db.Set().Add(spec); + + problem.SetReferenceSolution(request.ProblemSetupId, request.ReferenceSolutionCode); + problem.SetGenerationSpecId(request.ProblemSetupId, spec.Id); + + // 3. Generate inputs and run the reference solution against them in rounds, until + // TargetCaseCount inputs have actually succeeded (not just been attempted) — some + // specs (Two Sum's, notably) can produce unsolvable random instances, so "attempt N" + // and "succeed N times" are different things, and callers want the latter. + // MaxAttemptMultiplier bounds runaway looping if a spec can basically never succeed. + const int MaxAttemptMultiplier = 15; + int maxAttempts = request.TargetCaseCount * MaxAttemptMultiplier; + int batchSize = Math.Max(request.TargetCaseCount, 1); + + List<(int Index, string Output)> successfulCases = []; + List warnings = []; + int nextIndex = 0; + int attempted = 0; + + while (successfulCases.Count < request.TargetCaseCount && attempted < maxAttempts) + { + int roundSize = Math.Min(batchSize, maxAttempts - attempted); + List roundIndices = [.. Enumerable.Range(nextIndex, roundSize)]; + nextIndex += roundSize; + attempted += roundSize; + + var roundCases = roundIndices + .Select(index => + ( + Index: index, + Inputs: generator + .GenerateInputs(spec, index) + .Select(v => new CodeTemplateInput(v.Value, v.ValueType)) + .ToList() + ) + ) + .ToList(); + + List submissions = + [ + .. roundCases.Select(c => new ExecutionEngineSubmission( + SourceCode: templateStrategy.Render( + new CodeTemplateContext(request.ReferenceSolutionCode, setup.FunctionName, c.Inputs) + ), + LanguageId: judge0LanguageId, + Stdin: templateStrategy.BuildStdin(c.Inputs), + TimeLimitMs: null, + MemoryLimitKb: null + )), + ]; + + List results = await runner.SubmitAndPollAsync(submissions, cancellationToken); + + for (int i = 0; i < roundCases.Count && successfulCases.Count < request.TargetCaseCount; i++) + { + ExecutionEngineResult? result = results.Count > i ? results[i] : null; + + if (result is null || result.Status != ExecutionEngineResultStatus.Accepted) + { + warnings.Add( + $"Case {roundCases[i].Index}: reference solution {result?.Status.ToString() ?? "did not return a result"}." + ); + continue; + } + + string? actualOutput = Judge0ReferenceSolutionRunner.ParseActualOutput(result.Stdout); + if (actualOutput is null) + { + warnings.Add($"Case {roundCases[i].Index}: reference solution produced no output."); + continue; + } + + // Normalize through JSON so the persisted value matches the compact convention + // curated cases use (e.g. Python's "[0, 1]" -> "[0,1]") regardless of which + // language the reference solution happens to be written in. + actualOutput = Judge0ReferenceSolutionRunner.TryNormalizeJson(actualOutput) ?? actualOutput; + + successfulCases.Add((roundCases[i].Index, actualOutput)); + } + } + + // 4. Retire the problem's previous generated pool and add the newly verified cases — + // retired rather than deleted, since a past submission may already reference one of them. + string suiteName = $"{problem.Slug.Value} - Generated Cases"; + Guid suiteId = await testSuiteRepository.FindOrCreateGeneratedSuiteAsync( + suiteName, + [.. problem.Setups.Select(s => s.Id)], + cancellationToken + ); + + await testSuiteRepository.RetireGeneratedTestCasesAsync(suiteId, cancellationToken); + + TestSuite suite = + await testSuiteRepository.FindByIdAsync(suiteId, cancellationToken) + ?? throw new InvalidOperationException($"Generated suite {suiteId} could not be reloaded."); + + foreach (var (index, output) in successfulCases) + { + // TestSuite.TestCases is a PropertyAccessMode.Field-backed collection — EF's + // standard change detection doesn't reliably pick up additions to it via graph + // traversal alone (the same limitation the seeders work around with raw SQL), so + // each newly created child is marked Added explicitly, mirroring the approach + // EvaluateStepHandler already uses for this codebase's field-backed collections. + TestCase testCase = suite.AddGeneratedTestCase($"Generated {index}", spec.Id, index); + db.Entry(testCase).State = EntityState.Added; + + TestCaseExpectedOutput expectedOutput = testCase.AddExpectedOutput(output, request.OutputValueType); + db.Entry(expectedOutput).State = EntityState.Added; + } + + await db.SaveChangesAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + return new GenerateTestCasesResult( + request.TargetCaseCount, + successfulCases.Count, + attempted - successfulCases.Count, + warnings + ); + } + + private static GenerateTestCasesResult Failed(GenerateTestCasesRequest request, string error) => + new(request.TargetCaseCount, 0, 0, [error]); +} \ No newline at end of file diff --git a/StackDuel.Infrastructure/TestCaseGeneration/TestCaseRefreshService.cs b/StackDuel.Infrastructure/TestCaseGeneration/TestCaseRefreshService.cs new file mode 100644 index 0000000..4c16287 --- /dev/null +++ b/StackDuel.Infrastructure/TestCaseGeneration/TestCaseRefreshService.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StackDuel.Application.TestCaseGeneration; +using StackDuel.Domain.Problems.Entities; +using StackDuel.Domain.Problems.Enums; +using StackDuel.Domain.TestCaseGeneration.Entities; +using StackDuel.Domain.TestCaseGeneration.Enums; +using StackDuel.Infrastructure.Persistence; + +namespace StackDuel.Infrastructure.TestCaseGeneration; + +internal sealed partial class TestCaseRefreshService(StackDuelDbContext context, ILogger logger) + : ITestCaseRefreshService +{ + private sealed record SetupCandidate(Guid SetupId, string ReferenceSolutionCode); + + public async Task EnqueueRefreshesAsync(CancellationToken cancellationToken = default) + { + // Only published problems that already declare a generation recipe and have been + // generated at least once (GenerationSpecId set) are eligible — a setup with no + // reference solution or no prior spec has nothing for a refresh to re-roll. + var candidates = await context + .Set() + .Where(p => p.Status == ProblemStatus.Published && p.GenerationSpec != null) + .Select(p => new + { + p.GenerationSpec!.Parameters, + p.GenerationSpec.OutputValueType, + p.GenerationSpec.TargetCaseCount, + Setups = p + .Setups.Where(s => s.GenerationSpecId != null && s.ReferenceSolutionCode != null) + .Select(s => new SetupCandidate(s.Id, s.ReferenceSolutionCode!)) + .ToList(), + }) + .Where(p => p.Setups.Count > 0) + .ToListAsync(cancellationToken); + + if (candidates.Count == 0) + { + LogNoCandidates(); + return 0; + } + + List candidateSetupIds = [.. candidates.SelectMany(c => c.Setups).Select(s => s.SetupId)]; + + HashSet busySetupIds = + [ + .. await context + .Set() + .Where(j => + candidateSetupIds.Contains(j.ProblemSetupId) + && (j.Status == TestCaseGenerationJobStatus.Pending || j.Status == TestCaseGenerationJobStatus.Processing) + ) + .Select(j => j.ProblemSetupId) + .ToListAsync(cancellationToken), + ]; + + List jobs = []; + foreach (var candidate in candidates) + { + foreach (SetupCandidate setup in candidate.Setups) + { + if (busySetupIds.Contains(setup.SetupId)) + { + LogSkippedBusy(setup.SetupId); + continue; + } + + jobs.Add( + new TestCaseGenerationJob( + setup.SetupId, + setup.ReferenceSolutionCode, + candidate.Parameters, + candidate.OutputValueType, + candidate.TargetCaseCount, + seed: Random.Shared.Next() + ) + ); + } + } + + if (jobs.Count == 0) + { + LogNoCandidates(); + return 0; + } + + await context.TestCaseGenerationJobs.AddRangeAsync(jobs, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + LogEnqueued(jobs.Count); + return jobs.Count; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "No eligible problem setups found for weekly test case refresh")] + private partial void LogNoCandidates(); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Skipping weekly refresh for setup {SetupId}: a generation job is already pending or processing" + )] + private partial void LogSkippedBusy(Guid setupId); + + [LoggerMessage(Level = LogLevel.Information, Message = "Enqueued {Count} test case generation job(s) for weekly refresh")] + private partial void LogEnqueued(int count); +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/ApiIntegrationTestBase.cs b/StackDuel.IntegrationTests/ApiIntegrationTestBase.cs new file mode 100644 index 0000000..40f783a --- /dev/null +++ b/StackDuel.IntegrationTests/ApiIntegrationTestBase.cs @@ -0,0 +1,56 @@ +using Npgsql; +using StackDuel.IntegrationTests.Infrastructure; + +namespace StackDuel.IntegrationTests; + +public abstract class ApiIntegrationTestBase +{ + private HttpClient? _client; + + protected HttpClient Client => _client ?? throw new InvalidOperationException("SetUp has not run yet."); + + protected TestDataSeeder Seeder + { + get => field ?? throw new InvalidOperationException("SetUp has not run yet."); + private set; + } + + [SetUp] + public void SetUpClient() + { + _client = IntegrationTestEnvironment.Factory.CreateClient(); + Seeder = new TestDataSeeder(IntegrationTestEnvironment.ConnectionString); + } + + [TearDown] + public async Task TearDownAsync() + { + _client?.Dispose(); + _client = null; + + await using NpgsqlConnection connection = new(IntegrationTestEnvironment.ConnectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = """ + TRUNCATE TABLE + user_language_preferences, + user_groups, + group_roles, + role_permissions, + security_restrictions, + users, + groups, + roles, + permissions + RESTART IDENTITY CASCADE + """; + await command.ExecuteNonQueryAsync(); + } + + protected static HttpRequestMessage AuthenticatedRequest(HttpMethod method, string requestUri, string sub) + { + HttpRequestMessage request = new(method, requestUri); + request.Headers.Add(TestAuthHandler.SubHeaderName, sub); + return request; + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/Authorization/UserAccessApiTests.cs b/StackDuel.IntegrationTests/Authorization/UserAccessApiTests.cs new file mode 100644 index 0000000..3e919a2 --- /dev/null +++ b/StackDuel.IntegrationTests/Authorization/UserAccessApiTests.cs @@ -0,0 +1,55 @@ +using StackDuel.Api.Requests.User; +using StackDuel.Api.Responses.User; +using StackDuel.Domain.Authorization.Rbac.Enums; +using System.Net; +using System.Net.Http.Json; + +namespace StackDuel.IntegrationTests.Authorization; + +public sealed class UserAccessApiTests : ApiIntegrationTestBase +{ + [Test] + public async Task GetAccount_returns_permissions_and_roles_derived_from_the_users_groups() + { + const string sub = "sub-alice-1"; + await CreateAccountAsync(sub, "alice123"); + + Guid userId = await Seeder.GetUserIdBySubAsync(sub); + Guid readPermissionId = await Seeder.CreatePermissionAsync("problems.read"); + Guid writePermissionId = await Seeder.CreatePermissionAsync("problems.write"); + Guid roleId = await Seeder.CreateRoleAsync("Member"); + await Seeder.GrantRolePermissionAsync(roleId, readPermissionId, DecisionEffect.Allow); + await Seeder.GrantRolePermissionAsync(roleId, writePermissionId, DecisionEffect.Deny); + Guid groupId = await Seeder.CreateGroupAsync("Beta Testers"); + await Seeder.GrantGroupRoleAsync(groupId, roleId); + await Seeder.AddUserToGroupAsync(userId, groupId); + + HttpResponseMessage response = await Client.SendAsync( + AuthenticatedRequest(HttpMethod.Get, "/api/v1/user", sub) + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + UserResponse? body = await response.Content.ReadFromJsonAsync(); + Assert.That(body, Is.Not.Null); + Assert.That(body!.Username, Is.EqualTo("alice123")); + Assert.That(body.Permissions, Is.EquivalentTo(new[] { "problems.read" })); + Assert.That(body.Roles, Is.EquivalentTo(new[] { "Member" })); + } + + [Test] + public async Task GetAccount_returns_not_found_when_unauthenticated() + { + HttpResponseMessage response = await Client.GetAsync("/api/v1/user"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + private async Task CreateAccountAsync(string sub, string username) + { + HttpRequestMessage request = AuthenticatedRequest(HttpMethod.Put, "/api/v1/user", sub); + request.Content = JsonContent.Create(new UpsertUserRequest(username, null, null)); + + HttpResponseMessage response = await Client.SendAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/Infrastructure/StackDuelApiFactory.cs b/StackDuel.IntegrationTests/Infrastructure/StackDuelApiFactory.cs new file mode 100644 index 0000000..76e7faf --- /dev/null +++ b/StackDuel.IntegrationTests/Infrastructure/StackDuelApiFactory.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace StackDuel.IntegrationTests.Infrastructure; + +public sealed class StackDuelApiFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services + .AddAuthentication(TestAuthHandler.SchemeName) + .AddScheme(TestAuthHandler.SchemeName, _ => { }); + + RemoveHostedService( + services, + "StackDuel.Infrastructure.Messaging.Consumers.RabbitMqConsumerService, StackDuel.Infrastructure" + ); + RemoveHostedService( + services, + "StackDuel.Infrastructure.Messaging.Consumers.AzureServiceBusConsumerService, StackDuel.Infrastructure" + ); + }); + } + + private static void RemoveHostedService(IServiceCollection services, string assemblyQualifiedTypeName) + { + Type? implementationType = Type.GetType(assemblyQualifiedTypeName); + if (implementationType is null) + return; + + ServiceDescriptor? descriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IHostedService) && d.ImplementationType == implementationType + ); + + if (descriptor is not null) + services.Remove(descriptor); + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/Infrastructure/TestAuthHandler.cs b/StackDuel.IntegrationTests/Infrastructure/TestAuthHandler.cs new file mode 100644 index 0000000..491fee5 --- /dev/null +++ b/StackDuel.IntegrationTests/Infrastructure/TestAuthHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace StackDuel.IntegrationTests.Infrastructure; + +public sealed class TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder +) : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "Test"; + + public const string SubHeaderName = "X-Test-Sub"; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(SubHeaderName, out var subValues) || string.IsNullOrWhiteSpace(subValues)) + return Task.FromResult(AuthenticateResult.NoResult()); + + Claim[] claims = [new Claim(ClaimTypes.NameIdentifier, subValues.ToString())]; + ClaimsIdentity identity = new(claims, SchemeName); + ClaimsPrincipal principal = new(identity); + AuthenticationTicket ticket = new(principal, SchemeName); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/Infrastructure/TestDataSeeder.cs b/StackDuel.IntegrationTests/Infrastructure/TestDataSeeder.cs new file mode 100644 index 0000000..341dfbb --- /dev/null +++ b/StackDuel.IntegrationTests/Infrastructure/TestDataSeeder.cs @@ -0,0 +1,112 @@ +using Npgsql; +using StackDuel.Domain.Authorization.Rbac.Enums; + +namespace StackDuel.IntegrationTests.Infrastructure; + +public sealed class TestDataSeeder(string connectionString) +{ + public async Task GetUserIdBySubAsync(string sub, CancellationToken cancellationToken = default) + { + await using NpgsqlConnection connection = await OpenConnectionAsync(cancellationToken); + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = "SELECT id FROM users WHERE sub = @sub"; + command.Parameters.AddWithValue("sub", sub); + + object? result = await command.ExecuteScalarAsync(cancellationToken); + return result is Guid id ? id : throw new InvalidOperationException($"No user found with sub '{sub}'."); + } + + public async Task CreatePermissionAsync( + string code, + string description = "test permission", + CancellationToken cancellationToken = default + ) + { + Guid id = Guid.NewGuid(); + await ExecuteAsync( + "INSERT INTO permissions (id, code, description) VALUES (@id, @code, @description)", + cancellationToken, + ("id", id), + ("code", code), + ("description", description) + ); + return id; + } + + public async Task CreateRoleAsync(string name, CancellationToken cancellationToken = default) + { + Guid id = Guid.NewGuid(); + await ExecuteAsync( + "INSERT INTO roles (id, name) VALUES (@id, @name)", + cancellationToken, + ("id", id), + ("name", name) + ); + return id; + } + + public Task GrantRolePermissionAsync( + Guid roleId, + Guid permissionId, + DecisionEffect effect, + CancellationToken cancellationToken = default + ) => + ExecuteAsync( + "INSERT INTO role_permissions (role_id, permission_id, effect) VALUES (@roleId, @permissionId, @effect)", + cancellationToken, + ("roleId", roleId), + ("permissionId", permissionId), + ("effect", effect.ToString()) + ); + + public async Task CreateGroupAsync(string name, CancellationToken cancellationToken = default) + { + Guid id = Guid.NewGuid(); + await ExecuteAsync( + "INSERT INTO groups (id, name) VALUES (@id, @name)", + cancellationToken, + ("id", id), + ("name", name) + ); + return id; + } + + public Task GrantGroupRoleAsync(Guid groupId, Guid roleId, CancellationToken cancellationToken = default) => + ExecuteAsync( + "INSERT INTO group_roles (group_id, role_id) VALUES (@groupId, @roleId)", + cancellationToken, + ("groupId", groupId), + ("roleId", roleId) + ); + + public Task AddUserToGroupAsync(Guid userId, Guid groupId, CancellationToken cancellationToken = default) => + ExecuteAsync( + "INSERT INTO user_groups (user_id, group_id) VALUES (@userId, @groupId)", + cancellationToken, + ("userId", userId), + ("groupId", groupId) + ); + + private async Task ExecuteAsync( + string commandText, + CancellationToken cancellationToken, + params (string Name, object Value)[] parameters + ) + { + await using NpgsqlConnection connection = await OpenConnectionAsync(cancellationToken); + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = commandText; + + foreach ((string name, object value) in parameters) + command.Parameters.AddWithValue(name, value); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/IntegrationTestEnvironment.cs b/StackDuel.IntegrationTests/IntegrationTestEnvironment.cs new file mode 100644 index 0000000..9f3c819 --- /dev/null +++ b/StackDuel.IntegrationTests/IntegrationTestEnvironment.cs @@ -0,0 +1,44 @@ +using StackDuel.IntegrationTests.Infrastructure; +using Testcontainers.PostgreSql; + +namespace StackDuel.IntegrationTests; + +[SetUpFixture] +public sealed class IntegrationTestEnvironment +{ + private static PostgreSqlContainer? _container; + + private static string? _connectionString; + + private static StackDuelApiFactory? _factory; + + public static string ConnectionString => + _connectionString + ?? throw new InvalidOperationException("The Postgres test container has not been started yet."); + + public static StackDuelApiFactory Factory => + _factory ?? throw new InvalidOperationException("The API test factory has not been started yet."); + + [OneTimeSetUp] + public async Task StartEnvironmentAsync() + { + _container = new PostgreSqlBuilder("postgres:17").Build(); + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + + Environment.SetEnvironmentVariable("ConnectionStrings__DefaultConnection", _connectionString); + + _factory = new StackDuelApiFactory(); + _ = _factory.Services; + } + + [OneTimeTearDown] + public async Task StopEnvironmentAsync() + { + if (_factory is not null) + await _factory.DisposeAsync(); + + if (_container is not null) + await _container.DisposeAsync(); + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/StackDuel.IntegrationTests.csproj b/StackDuel.IntegrationTests/StackDuel.IntegrationTests.csproj index edb299f..8bc2f36 100644 --- a/StackDuel.IntegrationTests/StackDuel.IntegrationTests.csproj +++ b/StackDuel.IntegrationTests/StackDuel.IntegrationTests.csproj @@ -1,7 +1,7 @@ - - + net10.0 + latest enable enable false @@ -10,12 +10,20 @@ - - + + + + + + + - + - \ No newline at end of file + + + + diff --git a/StackDuel.IntegrationTests/UnitTest1.cs b/StackDuel.IntegrationTests/UnitTest1.cs deleted file mode 100644 index d3048ba..0000000 --- a/StackDuel.IntegrationTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StackDuel.IntegrationTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/Users/AdminUsersApiTests.cs b/StackDuel.IntegrationTests/Users/AdminUsersApiTests.cs new file mode 100644 index 0000000..bb592d2 --- /dev/null +++ b/StackDuel.IntegrationTests/Users/AdminUsersApiTests.cs @@ -0,0 +1,153 @@ +using StackDuel.Api.Requests.User; +using StackDuel.Application.Pagination; +using StackDuel.Application.Users.Dtos.Admin; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Authorization.Rbac.Enums; +using System.Net; +using System.Net.Http.Json; + +namespace StackDuel.IntegrationTests.Users; + +public sealed class AdminUsersApiTests : ApiIntegrationTestBase +{ + [Test] + public async Task GetAdminUsers_returns_users_with_group_memberships_for_a_permitted_caller() + { + const string adminSub = "sub-admin-1"; + await CreateAccountAsync(adminSub, "admin1"); + await GrantPermissionToNewGroupAsync(adminSub, WellKnownAuthorization.ReadAdminUsersPermission); + + const string memberSub = "sub-iris-1"; + await CreateAccountAsync(memberSub, "iris22"); + Guid memberUserId = await Seeder.GetUserIdBySubAsync(memberSub); + Guid testersGroupId = await Seeder.CreateGroupAsync("Testers"); + await Seeder.AddUserToGroupAsync(memberUserId, testersGroupId); + + HttpResponseMessage response = await Client.SendAsync( + AuthenticatedRequest( + HttpMethod.Get, + "/api/v1/user/admin?Page=1&Size=10&Timestamp=2026-01-01T00:00:00Z", + adminSub + ) + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + PageResult? page = await response.Content.ReadFromJsonAsync>(); + Assert.That(page, Is.Not.Null); + AdminUserDto member = page!.Results.Single(u => u.Id == memberUserId); + Assert.That(member.Groups.Select(g => g.Name), Is.EquivalentTo(new[] { "Testers" })); + } + + [Test] + public async Task GetAdminUsers_filters_by_username_search() + { + const string adminSub = "sub-admin-2"; + await CreateAccountAsync(adminSub, "admin2"); + await GrantPermissionToNewGroupAsync(adminSub, WellKnownAuthorization.ReadAdminUsersPermission); + + const string matchingSub = "sub-match-1"; + await CreateAccountAsync(matchingSub, "searchable_user"); + Guid matchingUserId = await Seeder.GetUserIdBySubAsync(matchingSub); + + const string otherSub = "sub-other-1"; + await CreateAccountAsync(otherSub, "unrelated_user"); + + HttpResponseMessage response = await Client.SendAsync( + AuthenticatedRequest( + HttpMethod.Get, + "/api/v1/user/admin?Page=1&Size=10&Timestamp=2026-01-01T00:00:00Z&Search=searchable", + adminSub + ) + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + PageResult? page = await response.Content.ReadFromJsonAsync>(); + Assert.That(page, Is.Not.Null); + Assert.That(page!.Results.Select(u => u.Id), Is.EquivalentTo(new[] { matchingUserId })); + } + + [Test] + public async Task GetAdminUserDetail_returns_the_user_for_a_permitted_caller() + { + const string adminSub = "sub-admin-3"; + await CreateAccountAsync(adminSub, "admin3"); + await GrantPermissionToNewGroupAsync(adminSub, WellKnownAuthorization.ReadAdminUsersPermission); + + const string memberSub = "sub-iris-2"; + await CreateAccountAsync(memberSub, "iris23"); + Guid memberUserId = await Seeder.GetUserIdBySubAsync(memberSub); + Guid testersGroupId = await Seeder.CreateGroupAsync("Testers2"); + await Seeder.AddUserToGroupAsync(memberUserId, testersGroupId); + + HttpResponseMessage response = await Client.SendAsync( + AuthenticatedRequest(HttpMethod.Get, $"/api/v1/user/admin/{memberUserId}", adminSub) + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + AdminUserDetailDto? detail = await response.Content.ReadFromJsonAsync(); + Assert.That(detail, Is.Not.Null); + Assert.That(detail!.Username, Is.EqualTo("iris23")); + Assert.That(detail.Groups.Select(g => g.Name), Is.EquivalentTo(new[] { "Testers2" })); + } + + [Test] + public async Task GetAdminUserDetail_returns_not_found_for_an_unknown_user() + { + const string adminSub = "sub-admin-4"; + await CreateAccountAsync(adminSub, "admin4"); + await GrantPermissionToNewGroupAsync(adminSub, WellKnownAuthorization.ReadAdminUsersPermission); + + HttpResponseMessage response = await Client.SendAsync( + AuthenticatedRequest(HttpMethod.Get, $"/api/v1/user/admin/{Guid.NewGuid()}", adminSub) + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task GetAdminUsers_returns_forbidden_for_a_caller_without_the_permission() + { + const string sub = "sub-plain-1"; + await CreateAccountAsync(sub, "plainuser1"); + + HttpResponseMessage response = await Client.SendAsync( + AuthenticatedRequest( + HttpMethod.Get, + "/api/v1/user/admin?Page=1&Size=10&Timestamp=2026-01-01T00:00:00Z", + sub + ) + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task GetAdminUsers_returns_unauthorized_when_unauthenticated() + { + HttpResponseMessage response = await Client.GetAsync( + "/api/v1/user/admin?Page=1&Size=10&Timestamp=2026-01-01T00:00:00Z" + ); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + private async Task CreateAccountAsync(string sub, string username) + { + HttpRequestMessage request = AuthenticatedRequest(HttpMethod.Put, "/api/v1/user", sub); + request.Content = JsonContent.Create(new UpsertUserRequest(username, null, null)); + + HttpResponseMessage response = await Client.SendAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); + } + + private async Task GrantPermissionToNewGroupAsync(string sub, string permissionCode) + { + Guid userId = await Seeder.GetUserIdBySubAsync(sub); + Guid permissionId = await Seeder.CreatePermissionAsync(permissionCode); + Guid roleId = await Seeder.CreateRoleAsync($"role-for-{permissionCode}"); + await Seeder.GrantRolePermissionAsync(roleId, permissionId, DecisionEffect.Allow); + Guid groupId = await Seeder.CreateGroupAsync($"group-for-{permissionCode}"); + await Seeder.GrantGroupRoleAsync(groupId, roleId); + await Seeder.AddUserToGroupAsync(userId, groupId); + } +} \ No newline at end of file diff --git a/StackDuel.IntegrationTests/Users/UserProfileApiTests.cs b/StackDuel.IntegrationTests/Users/UserProfileApiTests.cs new file mode 100644 index 0000000..45a3375 --- /dev/null +++ b/StackDuel.IntegrationTests/Users/UserProfileApiTests.cs @@ -0,0 +1,35 @@ +using StackDuel.Api.Requests.User; +using StackDuel.Application.Users.Dtos; +using System.Net; +using System.Net.Http.Json; + +namespace StackDuel.IntegrationTests.Users; + +public sealed class UserProfileApiTests : ApiIntegrationTestBase +{ + [Test] + public async Task GetProfile_returns_profile_for_an_existing_username() + { + const string sub = "sub-henry-1"; + HttpRequestMessage createRequest = AuthenticatedRequest(HttpMethod.Put, "/api/v1/user", sub); + createRequest.Content = JsonContent.Create(new UpsertUserRequest("henry9", null, "hello there")); + HttpResponseMessage createResponse = await Client.SendAsync(createRequest); + Assert.That(createResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + HttpResponseMessage response = await Client.GetAsync("/api/v1/user/profile/henry9"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + UserProfileDto? profile = await response.Content.ReadFromJsonAsync(); + Assert.That(profile, Is.Not.Null); + Assert.That(profile!.Username, Is.EqualTo("henry9")); + Assert.That(profile.Bio, Is.EqualTo("hello there")); + } + + [Test] + public async Task GetProfile_returns_not_found_for_an_unknown_username() + { + HttpResponseMessage response = await Client.GetAsync("/api/v1/user/profile/no-such-user"); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } +} \ No newline at end of file diff --git a/StackDuel.Seeder/Class1.cs b/StackDuel.Seeder/Class1.cs deleted file mode 100644 index c6c3bde..0000000 --- a/StackDuel.Seeder/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StackDuel.Seeder; - -public class Class1 -{ - -} \ No newline at end of file diff --git a/StackDuel.Seeder/Program.cs b/StackDuel.Seeder/Program.cs new file mode 100644 index 0000000..ada2229 --- /dev/null +++ b/StackDuel.Seeder/Program.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackDuel.Application.Audit; +using StackDuel.Domain.Audit.Entities; +using StackDuel.Domain.Authorization.Rbac; +using StackDuel.Domain.Users; +using StackDuel.Domain.Users.ValueObjects; +using StackDuel.Infrastructure; +using StackDuel.Infrastructure.Persistence.Seeders; + +bool seedStatic = args.Contains("--static") || args.Contains("--all"); +bool seedDemo = args.Contains("--demo") || args.Contains("--all"); +string? grantAdminUsername = args.SkipWhile(a => a != "--grant-admin").Skip(1).FirstOrDefault(); + +if (!seedStatic && !seedDemo && grantAdminUsername is null) +{ + Console.WriteLine( + "Usage: dotnet run --project StackDuel.Seeder -- [--static] [--demo] [--all] [--grant-admin ]" + ); + Console.WriteLine(); + Console.WriteLine( + " --static Seed reference data (languages, versions, problems) — idempotent, safe every deploy" + ); + Console.WriteLine(" --demo Seed demo data (example problems and test suites)"); + Console.WriteLine(" --all Seed everything"); + Console.WriteLine(" --grant-admin Add an existing user to the admin group, by username"); + return 1; +} + +IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddUserSecrets(optional: true) + .AddEnvironmentVariables() + .Build(); + +ServiceCollection services = new(); +services.AddInfrastructureForSeeder(configuration); + +await using ServiceProvider provider = services.BuildServiceProvider(); +IServiceProvider sp = provider; + +await sp.MigrateAsync(); + +if (seedStatic || seedDemo) +{ + await sp.SeedAsync(new SeederOptions { SeedStaticData = seedStatic, SeedDemoData = seedDemo }); +} + +if (grantAdminUsername is not null) +{ + using IServiceScope scope = sp.CreateScope(); + IUserWriteRepository userWriteRepository = scope.ServiceProvider.GetRequiredService(); + + var user = await userWriteRepository.FindByUsername(new Username(grantAdminUsername)); + if (user is null) + { + Console.WriteLine($"No user found with username '{grantAdminUsername}'."); + return 1; + } + + await userWriteRepository.AddToGroupAsync(user.Id, WellKnownAuthorization.AdminGroup); + + IAuditLogWriteRepository auditLogWriteRepository = + scope.ServiceProvider.GetRequiredService(); + await auditLogWriteRepository.AddAsync( + AuditLogEntry.Create( + actorUserId: null, + actorUsername: "cli:seeder", + action: "user.groups.admin-granted", + targetType: "user", + targetId: user.Id.ToString(), + detailsJson: null + ) + ); + + Console.WriteLine($"Granted the admin group to '{grantAdminUsername}'."); +} + +Console.WriteLine("Done."); +return 0; \ No newline at end of file diff --git a/StackDuel.Seeder/StackDuel.Seeder.csproj b/StackDuel.Seeder/StackDuel.Seeder.csproj index b760144..7817d0d 100644 --- a/StackDuel.Seeder/StackDuel.Seeder.csproj +++ b/StackDuel.Seeder/StackDuel.Seeder.csproj @@ -1,9 +1,24 @@ - - + + Exe net10.0 - enable enable + enable + f1204ecb-b2c7-4ab2-a772-a27e6871af93 + + + + + + + + + + + + + + diff --git a/StackDuel.Seeder/appsettings.json b/StackDuel.Seeder/appsettings.json new file mode 100644 index 0000000..7f07c90 --- /dev/null +++ b/StackDuel.Seeder/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "" + } +} diff --git a/StackDuel.Specs/Features/Users/CreateUser.feature b/StackDuel.Specs/Features/Users/CreateUser.feature new file mode 100644 index 0000000..c1ff1f2 --- /dev/null +++ b/StackDuel.Specs/Features/Users/CreateUser.feature @@ -0,0 +1,25 @@ +# Issue: # +Feature: Create user + As a new player + I want to create an account + So that I can start playing Stack Duel + + Scenario: Successful account creation + Given no user exists with sub "auth0|new-player" + When I create a user with username "player_one" and sub "auth0|new-player" + Then the user is created successfully + + Scenario: Username is required + Given no user exists with sub "auth0|new-player" + When I create a user with username "" and sub "auth0|new-player" + Then the request fails with an error containing "Username" + + Scenario: Username exceeds the maximum length + Given no user exists with sub "auth0|new-player" + When I create a user with username "this-username-is-way-too-long-to-be-valid" and sub "auth0|new-player" + Then the request fails with an error containing "Username" + + Scenario: Sub must be unique + Given a user already exists with sub "auth0|existing-player" + When I create a user with username "player_two" and sub "auth0|existing-player" + Then the request fails with an error containing "already exists" diff --git a/StackDuel.Specs/Features/Users/CreateUser.feature.cs b/StackDuel.Specs/Features/Users/CreateUser.feature.cs new file mode 100644 index 0000000..342d6b0 --- /dev/null +++ b/StackDuel.Specs/Features/Users/CreateUser.feature.cs @@ -0,0 +1,292 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace StackDuel.Specs.Features.Users +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public partial class CreateUserFeature : object, global::Xunit.IClassFixture, global::Xunit.IAsyncLifetime + { + + private global::Reqnroll.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features/Users", "Create user", " As a new player\r\n I want to create an account\r\n So that I can start pla" + + "ying Stack Duel", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + + private global::Xunit.Abstractions.ITestOutputHelper _testOutputHelper; + +#line 1 "CreateUser.feature" +#line hidden + + public CreateUserFeature(CreateUserFeature.FixtureData fixtureData, global::Xunit.Abstractions.ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + } + + public static async global::System.Threading.Tasks.Task FeatureSetupAsync() + { + } + + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/Users/CreateUser.feature.ndjson", 6); + } + + async global::System.Threading.Tasks.Task global::Xunit.IAsyncLifetime.InitializeAsync() + { + try + { + await this.TestInitializeAsync(); + } + catch (System.Exception e1) + { + try + { + ((global::Xunit.IAsyncLifetime)(this)).DisposeAsync(); + } + catch (System.Exception e2) + { + throw new System.AggregateException("Test initialization failed", e1, e2); + } + throw; + } + } + + async global::System.Threading.Tasks.Task global::Xunit.IAsyncLifetime.DisposeAsync() + { + await this.TestTearDownAsync(); + } + + [global::Xunit.SkippableFactAttribute(DisplayName="Successful account creation")] + [global::Xunit.TraitAttribute("FeatureTitle", "Create user")] + [global::Xunit.TraitAttribute("Description", "Successful account creation")] + public async global::System.Threading.Tasks.Task SuccessfulAccountCreation() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Successful account creation", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync("no user exists with sub \"auth0|new-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.WhenAsync("I create a user with username \"player_one\" and sub \"auth0|new-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 10 + await testRunner.ThenAsync("the user is created successfully", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.SkippableFactAttribute(DisplayName="Username is required")] + [global::Xunit.TraitAttribute("FeatureTitle", "Create user")] + [global::Xunit.TraitAttribute("Description", "Username is required")] + public async global::System.Threading.Tasks.Task UsernameIsRequired() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Username is required", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 12 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 13 + await testRunner.GivenAsync("no user exists with sub \"auth0|new-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 14 + await testRunner.WhenAsync("I create a user with username \"\" and sub \"auth0|new-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 15 + await testRunner.ThenAsync("the request fails with an error containing \"Username\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.SkippableFactAttribute(DisplayName="Username exceeds the maximum length")] + [global::Xunit.TraitAttribute("FeatureTitle", "Create user")] + [global::Xunit.TraitAttribute("Description", "Username exceeds the maximum length")] + public async global::System.Threading.Tasks.Task UsernameExceedsTheMaximumLength() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Username exceeds the maximum length", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 17 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 18 + await testRunner.GivenAsync("no user exists with sub \"auth0|new-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 19 + await testRunner.WhenAsync("I create a user with username \"this-username-is-way-too-long-to-be-valid\" and sub" + + " \"auth0|new-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 20 + await testRunner.ThenAsync("the request fails with an error containing \"Username\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.SkippableFactAttribute(DisplayName="Sub must be unique")] + [global::Xunit.TraitAttribute("FeatureTitle", "Create user")] + [global::Xunit.TraitAttribute("Description", "Sub must be unique")] + public async global::System.Threading.Tasks.Task SubMustBeUnique() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "3"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Sub must be unique", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 22 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 23 + await testRunner.GivenAsync("a user already exists with sub \"auth0|existing-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 24 + await testRunner.WhenAsync("I create a user with username \"player_two\" and sub \"auth0|existing-player\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 25 + await testRunner.ThenAsync("the request fails with an error containing \"already exists\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FixtureData : object, global::Xunit.IAsyncLifetime + { + + async global::System.Threading.Tasks.Task global::Xunit.IAsyncLifetime.InitializeAsync() + { + await CreateUserFeature.FeatureSetupAsync(); + } + + async global::System.Threading.Tasks.Task global::Xunit.IAsyncLifetime.DisposeAsync() + { + await CreateUserFeature.FeatureTearDownAsync(); + } + } + } +} +#pragma warning restore +#endregion diff --git a/StackDuel.Specs/README.md b/StackDuel.Specs/README.md new file mode 100644 index 0000000..5f42b1c --- /dev/null +++ b/StackDuel.Specs/README.md @@ -0,0 +1,14 @@ +# StackDuel.Specs + +Acceptance specs for business rules, written as Gherkin `.feature` files and executed with [Reqnroll](https://reqnroll.net/) on xUnit. + +Each scenario exercises a real `Application` command/query handler (constructed directly, against a fake repository in `Support/`) instead of going through HTTP — keeps specs fast and focused on business rules rather than wiring. For full-stack HTTP scenarios, use `StackDuel.IntegrationTests` instead. + +## Workflow + +1. Create the GitHub issue/PBI for the feature on the board first. +2. Write the `.feature` file under `Features//.feature` describing the rules in Given/When/Then, with the issue number in a header comment (`# Issue: #123`). Open this as a PR before writing any implementation code, so the rules can be reviewed on their own. +3. Paste the `.feature` file's repo path into the issue/PBI description, so the board links straight to the rules. +4. Implement the domain/application code and step definitions until the scenarios pass. + +The `.feature` files are the single source of truth for "what are the rules for this feature" — no separate rules doc to keep in sync. diff --git a/StackDuel.Specs/StackDuel.Specs.csproj b/StackDuel.Specs/StackDuel.Specs.csproj new file mode 100644 index 0000000..e9d2234 --- /dev/null +++ b/StackDuel.Specs/StackDuel.Specs.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/StackDuel.Specs/Steps/CreateUserSteps.cs b/StackDuel.Specs/Steps/CreateUserSteps.cs new file mode 100644 index 0000000..75e08a2 --- /dev/null +++ b/StackDuel.Specs/Steps/CreateUserSteps.cs @@ -0,0 +1,54 @@ +using Ardalis.Result; +using FluentValidation; +using Reqnroll; +using StackDuel.Application.Commands.Users.CreateUser; +using StackDuel.Domain.User.Entities; +using StackDuel.Domain.User.ValueObjects; +using StackDuel.Specs.Support; + +namespace StackDuel.Specs.Steps; + +[Binding] +public sealed class CreateUserSteps +{ + private readonly FakeUserRepository _userRepository = new(); + private Result _result = null!; + + [Given(@"no user exists with sub ""(.*)""")] + public void GivenNoUserExistsWithSub(string sub) + { + // Nothing to arrange: the fake repository starts empty. + } + + [Given(@"a user already exists with sub ""(.*)""")] + public void GivenAUserAlreadyExistsWithSub(string sub) + { + var user = new User(new Username("existing_user"), new UserSub(sub)); + _userRepository.Seed(user); + } + + [When(@"I create a user with username ""(.*)"" and sub ""(.*)""")] + public async Task WhenICreateAUserWithUsernameAndSub(string username, string sub) + { + var command = new CreateUserCommand(username, sub); + var validator = new CreateUserValidator(); + var handler = new CreateUserHandler(validator, _userRepository); + + _result = await handler.Handle(command, CancellationToken.None); + } + + [Then("the user is created successfully")] + public void ThenTheUserIsCreatedSuccessfully() + { + Assert.True(_result.IsSuccess, string.Join("; ", _result.Errors)); + Assert.NotEqual(Guid.Empty, _result.Value); + } + + [Then(@"the request fails with an error containing ""(.*)""")] + public void ThenTheRequestFailsWithAnErrorContaining(string expectedSubstring) + { + Assert.False(_result.IsSuccess); + var allErrors = _result.Errors.Concat(_result.ValidationErrors.Select(e => e.ErrorMessage)); + Assert.Contains(allErrors, error => error.Contains(expectedSubstring, StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/StackDuel.Specs/Support/FakeUserRepository.cs b/StackDuel.Specs/Support/FakeUserRepository.cs new file mode 100644 index 0000000..a47918c --- /dev/null +++ b/StackDuel.Specs/Support/FakeUserRepository.cs @@ -0,0 +1,29 @@ +using StackDuel.Domain.User; +using StackDuel.Domain.User.Entities; +using StackDuel.Domain.User.ValueObjects; + +namespace StackDuel.Specs.Support; + +internal sealed class FakeUserRepository : IUserRepository +{ + private readonly List _users = []; + + public Task AddAsync(User entity, CancellationToken cancellationToken = default) + { + _users.Add(entity); + return Task.CompletedTask; + } + + public Task UpdateAsync(User entity, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + Task.FromResult(_users.SingleOrDefault(u => u.Id == id)); + + public Task FindBySubAsync(UserSub sub, CancellationToken cancellationToken = default) => + Task.FromResult(_users.SingleOrDefault(u => u.Sub == sub)); + + public Task FindByUsernameAsync(Username username, CancellationToken cancellationToken = default) => + Task.FromResult(_users.SingleOrDefault(u => u.Username == username)); + + public void Seed(User user) => _users.Add(user); +} \ No newline at end of file diff --git a/StackDuel.slnx b/StackDuel.slnx index 449ff68..e5bd533 100644 --- a/StackDuel.slnx +++ b/StackDuel.slnx @@ -1,12 +1,24 @@ - - - - - - - - - - + + + + + + + + + + + + + diff --git a/bruno/.env b/bruno/.env new file mode 100644 index 0000000..db5b90d --- /dev/null +++ b/bruno/.env @@ -0,0 +1,11 @@ +# Copy this file to .env and fill in real values. .env is gitignored. + +# Clerk OAuth Application (authorization code + PKCE, see collection.bru) +CLERK_AUTHORIZATION_URL=https://driven-skunk-1590.clerk.accounts.dev/oauth/authorize +CLERK_TOKEN_URL=https://driven-skunk-1590.clerk.accounts.dev/oauth/token +CLERK_CLIENT_ID=21YkrUO2PBrK7Bnv +CLERK_SCOPE=email offline_access profile + +# Environments +STAGING_BASE_URL= +PRODUCTION_BASE_URL= diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2446173 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + postgres: + image: postgres:17 + container_name: aw-pg + ports: + - "5432:5432" + environment: + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + POSTGRES_DB: stackduel + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + azurite: + image: mcr.microsoft.com/azure-storage/azurite:3.35.0 + container_name: aw-azurite + ports: + - "10000:10000" + volumes: + - azuritedata:/data + command: "azurite-blob --blobHost 0.0.0.0 --location /data --skipApiVersionCheck" + + rabbitmq: + image: heidiks/rabbitmq-delayed-message-exchange:4.0.7-management + container_name: aw-rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + volumes: + - rabbitmqdata:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + pgdata: + rabbitmqdata: + azuritedata: \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..e35310b --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..52753f0 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Deploys PUBLISH_DIR to the Azure App Service named by AZURE_WEBAPP_NAME. +# Mirrors what the `azure/webapps-deploy` action does for the Scrum environment: +# a Kudu zip deploy authenticated with the Web Deploy publish profile credentials. +set -euo pipefail + +: "${AZURE_WEBAPP_NAME:?Missing AZURE_WEBAPP_NAME}" +: "${AZURE_WEBAPP_PUBLISH_PROFILE:?Missing AZURE_WEBAPP_PUBLISH_PROFILE}" +: "${PUBLISH_DIR:?Missing PUBLISH_DIR}" + +msdeploy_profile=$(grep -o ']*publishMethod="MSDeploy"[^>]*/>' <<<"${AZURE_WEBAPP_PUBLISH_PROFILE}") + +if [ -z "${msdeploy_profile}" ]; then + echo "Could not find an MSDeploy publish profile in AZURE_WEBAPP_PUBLISH_PROFILE" + exit 1 +fi + +publish_url=$(grep -oP 'publishUrl="\K[^"]+' <<<"${msdeploy_profile}") +user_name=$(grep -oP 'userName="\K[^"]+' <<<"${msdeploy_profile}") +user_pwd=$(grep -oP 'userPWD="\K[^"]+' <<<"${msdeploy_profile}") +scm_host="${publish_url%%:*}" + +work_dir=$(mktemp -d) +zip_path="${work_dir}/publish.zip" + +(cd "${PUBLISH_DIR}" && zip -rq "${zip_path}" .) + +echo "Deploying ${AZURE_WEBAPP_NAME} from ${PUBLISH_DIR} via ${scm_host}..." + +curl -fsS -X POST \ + -u "${user_name}:${user_pwd}" \ + -H "Content-Type: application/zip" \ + --data-binary "@${zip_path}" \ + "https://${scm_host}/api/zipdeploy" + +rm -rf "${work_dir}" + +echo "Deployment to ${AZURE_WEBAPP_NAME} completed." diff --git a/scripts/download-jdtls.ps1 b/scripts/download-jdtls.ps1 new file mode 100644 index 0000000..db02825 --- /dev/null +++ b/scripts/download-jdtls.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent $ScriptDir +$TargetDir = Join-Path $RepoRoot ".tools\jdtls" +$DownloadUrl = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz" + +$existingLauncher = Get-ChildItem -Path (Join-Path $TargetDir "plugins") -Filter "org.eclipse.equinox.launcher_*.jar" -ErrorAction SilentlyContinue + +if ($existingLauncher) { + Write-Host "jdtls already present at $TargetDir, skipping download." +} else { + Write-Host "Downloading jdtls to $TargetDir..." + New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null + $Archive = Join-Path $env:TEMP "jdtls-$(New-Guid).tar.gz" + Invoke-WebRequest -Uri $DownloadUrl -OutFile $Archive + tar -xzf $Archive -C $TargetDir + Remove-Item $Archive -Force + Write-Host "jdtls extracted." +} + +$LauncherJar = (Get-ChildItem -Path (Join-Path $TargetDir "plugins") -Filter "org.eclipse.equinox.launcher_*.jar" | Select-Object -First 1).FullName +$ConfigDir = Join-Path $TargetDir "config_win" +$ApiProject = Join-Path $RepoRoot "StackDuel.Api" + +Write-Host "" +Write-Host "jdtls requires Java 21+ just to run itself -- this is separate from the JDK used to" +Write-Host "analyze the code users write (which should match the Judge0 execution JDK, e.g. 17)." +Write-Host "" +Write-Host "Configure StackDuel.Api user secrets:" +Write-Host " dotnet user-secrets set --project `"$ApiProject`" LanguageServer:Enabled true" +Write-Host " dotnet user-secrets set --project `"$ApiProject`" LanguageServer:Java:JdtlsLauncherJarPath `"$LauncherJar`"" +Write-Host " dotnet user-secrets set --project `"$ApiProject`" LanguageServer:Java:JdtlsConfigDirectory `"$ConfigDir`"" +Write-Host " dotnet user-secrets set --project `"$ApiProject`" LanguageServer:Java:JdtlsRuntimeJavaHome `"`"" +Write-Host " dotnet user-secrets set --project `"$ApiProject`" LanguageServer:Java:JavaHome `"`"" diff --git a/scripts/download-jdtls.sh b/scripts/download-jdtls.sh new file mode 100644 index 0000000..5fe9f1c --- /dev/null +++ b/scripts/download-jdtls.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +TARGET_DIR="$REPO_ROOT/.tools/jdtls" +DOWNLOAD_URL="https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz" + +if compgen -G "$TARGET_DIR/plugins/org.eclipse.equinox.launcher_*.jar" >/dev/null; then + echo "jdtls already present at $TARGET_DIR, skipping download." +else + echo "Downloading jdtls to $TARGET_DIR..." + mkdir -p "$TARGET_DIR" + ARCHIVE="$(mktemp)" + curl -sL --proto '=https' --tlsv1.2 -o "$ARCHIVE" "$DOWNLOAD_URL" + tar -xzf "$ARCHIVE" -C "$TARGET_DIR" + rm -f "$ARCHIVE" + echo "jdtls extracted." +fi + +LAUNCHER_JAR="$(compgen -G "$TARGET_DIR/plugins/org.eclipse.equinox.launcher_*.jar" | head -n 1)" + +case "$(uname -s)" in + Linux*) CONFIG_SUBDIR="config_linux" ;; + Darwin*) CONFIG_SUBDIR="config_mac" ;; + MINGW*|MSYS*|CYGWIN*) CONFIG_SUBDIR="config_win" ;; + *) CONFIG_SUBDIR="config_linux" ;; +esac + +echo +echo "jdtls requires Java 21+ just to run itself — this is separate from the JDK used to" +echo "analyze the code users write (which should match the Judge0 execution JDK, e.g. 17)." +echo +echo "Configure StackDuel.Api user secrets:" +echo " dotnet user-secrets set --project $REPO_ROOT/StackDuel.Api LanguageServer:Enabled true" +echo " dotnet user-secrets set --project $REPO_ROOT/StackDuel.Api LanguageServer:Java:JdtlsLauncherJarPath \"$LAUNCHER_JAR\"" +echo " dotnet user-secrets set --project $REPO_ROOT/StackDuel.Api LanguageServer:Java:JdtlsConfigDirectory \"$TARGET_DIR/$CONFIG_SUBDIR\"" +echo " dotnet user-secrets set --project $REPO_ROOT/StackDuel.Api LanguageServer:Java:JdtlsRuntimeJavaHome \"\"" +echo " dotnet user-secrets set --project $REPO_ROOT/StackDuel.Api LanguageServer:Java:JavaHome \"\"" diff --git a/scripts/setup-user-secrets.ps1 b/scripts/setup-user-secrets.ps1 new file mode 100644 index 0000000..745b09e --- /dev/null +++ b/scripts/setup-user-secrets.ps1 @@ -0,0 +1,129 @@ +$ErrorActionPreference = 'Stop' + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptRoot +$projectPath = Join-Path $repoRoot 'StackDuel.Api/StackDuel.Api.csproj' +$skipToken = '__SKIP__' + +if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + Write-Error 'dotnet CLI was not found in PATH.' + exit 1 +} + +if (-not (Test-Path $projectPath)) { + Write-Error "Could not find project at $projectPath" + exit 1 +} + +function Read-SettingValue { + param( + [Parameter(Mandatory = $true)] + [string]$Prompt, + [string]$Default = '', + [switch]$Secret + ) + + $suffix = if ($Default -ne '') { " [$Default]" } else { '' } + + if ($Secret) { + $secureInput = Read-Host -Prompt ($Prompt + $suffix) -AsSecureString + $plain = [System.Net.NetworkCredential]::new('', $secureInput).Password + + if ($plain -ieq 'skip') { + return $skipToken + } + + if ([string]::IsNullOrWhiteSpace($plain)) { + return $Default + } + + return $plain + } + + $value = Read-Host -Prompt ($Prompt + $suffix) + + if ($value -ieq 'skip') { + return $skipToken + } + + if ([string]::IsNullOrWhiteSpace($value)) { + return $Default + } + + return $value +} + +function Set-UserSecret { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [Parameter(Mandatory = $true)] + [string]$Value + ) + + if ($Value -eq $skipToken -or [string]::IsNullOrEmpty($Value)) { + Write-Host "Skipping $Key" -ForegroundColor Yellow + return + } + + dotnet user-secrets set --project "$projectPath" "$Key" "$Value" | Out-Null + Write-Host "Set $Key" -ForegroundColor Green +} + +Write-Host 'Configure StackDuel.Api user secrets from appsettings values.' -ForegroundColor Cyan +Write-Host "Press Enter to accept a default value when shown, or type 'skip' to leave a key unchanged." -ForegroundColor Cyan +Write-Host '' + +$commonSettings = @( + @{ Key = 'Auth0:Audience'; Prompt = "Auth0 Audience (your API's identifier in Auth0)"; Default = '' }, + @{ Key = 'Auth0:Domain'; Prompt = 'Auth0 Domain (e.g. your-tenant.us.auth0.com)'; Default = '' }, + @{ Key = 'ConnectionStrings:DefaultConnection'; Prompt = 'Connection string'; Default = 'Host=localhost;Port=5432;Database=stackduel;Username=myuser;Password=mypassword' }, + @{ Key = 'AzureStorage:ConnectionString'; Prompt = 'Azure Storage ConnectionString (Azurite emulator or a real storage account)'; Default = 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;'; Secret = $true }, + @{ Key = 'AzureStorage:AvatarContainerName'; Prompt = 'Azure Storage avatar container name'; Default = 'avatars' }, + @{ Key = 'ExecutionEngines:Judge0:Enabled'; Prompt = 'Judge0 Enabled (true/false)'; Default = 'true' }, + @{ Key = 'ExecutionEngines:Judge0:RunWorker'; Prompt = 'Judge0 RunWorker (true/false)'; Default = 'true' }, + @{ Key = 'ExecutionEngines:Judge0:BaseUrl'; Prompt = 'Judge0 BaseUrl'; Default = 'https://judge0-ce.p.rapidapi.com' }, + @{ Key = 'ExecutionEngines:Judge0:ApiKey'; Prompt = 'Judge0 ApiKey (from your RapidAPI Judge0 CE subscription)'; Default = ''; Secret = $true }, + @{ Key = 'ExecutionEngines:Judge0:Host'; Prompt = 'Judge0 Host'; Default = 'judge0-ce.p.rapidapi.com' }, + @{ Key = 'ExecutionEngines:Judge0:ShouldWait'; Prompt = 'Judge0 ShouldWait (true/false)'; Default = 'false' }, + @{ Key = 'ExecutionEngines:Judge0:IsEncoded'; Prompt = 'Judge0 IsEncoded (true/false)'; Default = 'true' }, + @{ Key = 'ExecutionEngines:Judge0:DefaultTimeoutInSeconds'; Prompt = 'Judge0 timeout in seconds'; Default = '10' }, + @{ Key = 'ExecutionEngines:Judge0:UseCallback'; Prompt = "Judge0 UseCallback (true/false, off unless you've confirmed RapidAPI delivers callbacks)"; Default = 'false' }, + @{ Key = 'ExecutionEngines:Judge0:CallbackSecret'; Prompt = 'Judge0 CallbackSecret (shared secret embedded in the callback_url query string)'; Default = ''; Secret = $true }, + @{ Key = 'ExecutionEngines:Judge0:PublicBaseUrl'; Prompt = 'Judge0 PublicBaseUrl (publicly reachable base URL Judge0 can call back to)'; Default = '' } +) + +$rabbitMqSettings = @( + @{ Key = 'MessageBus:RabbitMQ:Host'; Prompt = 'RabbitMQ Host'; Default = 'localhost' }, + @{ Key = 'MessageBus:RabbitMQ:VirtualHost'; Prompt = 'RabbitMQ VirtualHost'; Default = '/' }, + @{ Key = 'MessageBus:RabbitMQ:Username'; Prompt = 'RabbitMQ Username'; Default = 'guest' }, + @{ Key = 'MessageBus:RabbitMQ:Password'; Prompt = 'RabbitMQ Password'; Default = 'guest'; Secret = $true } +) + +$azureServiceBusSettings = @( + @{ Key = 'MessageBus:AzureServiceBus:ConnectionString'; Prompt = 'Azure Service Bus ConnectionString'; Default = ''; Secret = $true } +) + +foreach ($setting in $commonSettings) { + $value = Read-SettingValue -Prompt $setting.Prompt -Default $setting.Default -Secret:([bool]($setting.Secret)) + Set-UserSecret -Key $setting.Key -Value $value +} + +$transport = Read-SettingValue -Prompt 'MessageBus Transport (RabbitMQ/AzureServiceBus)' -Default 'RabbitMQ' +Set-UserSecret -Key 'MessageBus:Transport' -Value $transport + +if ($transport -ieq 'AzureServiceBus') { + foreach ($setting in $azureServiceBusSettings) { + $value = Read-SettingValue -Prompt $setting.Prompt -Default $setting.Default -Secret:([bool]($setting.Secret)) + Set-UserSecret -Key $setting.Key -Value $value + } +} +else { + foreach ($setting in $rabbitMqSettings) { + $value = Read-SettingValue -Prompt $setting.Prompt -Default $setting.Default -Secret:([bool]($setting.Secret)) + Set-UserSecret -Key $setting.Key -Value $value + } +} + +Write-Host '' +Write-Host 'Done. User secrets have been applied to StackDuel.Api.' -ForegroundColor Cyan \ No newline at end of file diff --git a/scripts/setup-user-secrets.sh b/scripts/setup-user-secrets.sh new file mode 100644 index 0000000..f0dee4a --- /dev/null +++ b/scripts/setup-user-secrets.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +PROJECT_PATH="$REPO_ROOT/StackDuel.Api/StackDuel.Api.csproj" +SKIP_TOKEN="__SKIP__" + +if ! command -v dotnet >/dev/null 2>&1; then + echo "dotnet CLI was not found in PATH." >&2 + exit 1 +fi + +if [ ! -f "$PROJECT_PATH" ]; then + echo "Could not find project at $PROJECT_PATH" >&2 + exit 1 +fi + +read_value() { + local prompt="$1" + local default_value="${2-}" + local secret="${3-false}" + local value="" + + if [ -n "$default_value" ]; then + prompt="$prompt [$default_value]" + fi + + if [ "$secret" = "true" ]; then + read -r -s -p "$prompt: " value + echo >&2 + else + read -r -p "$prompt: " value + fi + + if [ "$value" = "skip" ] || [ "$value" = "SKIP" ]; then + printf '%s' "$SKIP_TOKEN" + return + fi + + if [ -z "$value" ]; then + value="$default_value" + fi + + printf '%s' "$value" +} + +set_secret() { + local key="$1" + local value="$2" + + if [ "$value" = "$SKIP_TOKEN" ] || [ -z "$value" ]; then + echo "Skipping $key" + return + fi + + dotnet user-secrets set --project "$PROJECT_PATH" "$key" "$value" >/dev/null + echo "Set $key" +} + +echo "Configure StackDuel.Api user secrets from appsettings values." +echo "Press Enter to accept a default value when shown, or type 'skip' to leave a key unchanged." +echo + +KEYS=( + "Auth0:Audience" + "Auth0:Domain" + "ConnectionStrings:DefaultConnection" + "AzureStorage:ConnectionString" + "AzureStorage:AvatarContainerName" + "ExecutionEngines:Judge0:Enabled" + "ExecutionEngines:Judge0:RunWorker" + "ExecutionEngines:Judge0:BaseUrl" + "ExecutionEngines:Judge0:ApiKey" + "ExecutionEngines:Judge0:Host" + "ExecutionEngines:Judge0:ShouldWait" + "ExecutionEngines:Judge0:IsEncoded" + "ExecutionEngines:Judge0:DefaultTimeoutInSeconds" + "ExecutionEngines:Judge0:UseCallback" + "ExecutionEngines:Judge0:CallbackSecret" + "ExecutionEngines:Judge0:PublicBaseUrl" +) + +PROMPTS=( + "Auth0 Audience (your API's identifier in Auth0)" + "Auth0 Domain (e.g. your-tenant.us.auth0.com)" + "Connection string" + "Azure Storage ConnectionString (Azurite emulator or a real storage account)" + "Azure Storage avatar container name" + "Judge0 Enabled (true/false)" + "Judge0 RunWorker (true/false)" + "Judge0 BaseUrl" + "Judge0 ApiKey (from your RapidAPI Judge0 CE subscription)" + "Judge0 Host" + "Judge0 ShouldWait (true/false)" + "Judge0 IsEncoded (true/false)" + "Judge0 timeout in seconds" + "Judge0 UseCallback (true/false, off unless you've confirmed RapidAPI delivers callbacks)" + "Judge0 CallbackSecret (shared secret embedded in the callback_url query string)" + "Judge0 PublicBaseUrl (publicly reachable base URL Judge0 can call back to)" +) + +DEFAULTS=( + "" + "" + "Host=localhost;Port=5432;Database=stackduel;Username=myuser;Password=mypassword" + "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" + "avatars" + "true" + "true" + "https://judge0-ce.p.rapidapi.com" + "" + "judge0-ce.p.rapidapi.com" + "false" + "true" + "10" + "false" + "" + "" +) + +SECRETS=( + "false" + "false" + "false" + "true" + "false" + "false" + "false" + "false" + "true" + "false" + "false" + "false" + "false" + "false" + "true" + "false" +) + +for i in "${!KEYS[@]}"; do + value="$(read_value "${PROMPTS[$i]}" "${DEFAULTS[$i]}" "${SECRETS[$i]}")" + set_secret "${KEYS[$i]}" "$value" +done + +transport="$(read_value "MessageBus Transport (RabbitMQ/AzureServiceBus)" "RabbitMQ")" +set_secret "MessageBus:Transport" "$transport" + +if [ "$transport" = "AzureServiceBus" ]; then + value="$(read_value "Azure Service Bus ConnectionString" "" "true")" + set_secret "MessageBus:AzureServiceBus:ConnectionString" "$value" +else + value="$(read_value "RabbitMQ Host" "localhost")" + set_secret "MessageBus:RabbitMQ:Host" "$value" + + value="$(read_value "RabbitMQ VirtualHost" "/")" + set_secret "MessageBus:RabbitMQ:VirtualHost" "$value" + + value="$(read_value "RabbitMQ Username" "guest")" + set_secret "MessageBus:RabbitMQ:Username" "$value" + + value="$(read_value "RabbitMQ Password" "guest" "true")" + set_secret "MessageBus:RabbitMQ:Password" "$value" +fi + +echo +echo "Done. User secrets have been applied to StackDuel.Api." \ No newline at end of file diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..932012c --- /dev/null +++ b/templates/README.md @@ -0,0 +1,88 @@ +# StackDuel CQRS templates + +`dotnet new` templates that scaffold this repo's CQRS pieces (`StackDuel.Application.Commands.ICommand`, +`Mediator`, `Ardalis.Result`, `FluentValidation`) so adding a feature doesn't mean hand-writing the same +boilerplate every time. Mirrors the shape of the real `CreateUser`/`GetUserBySub` slice in +`StackDuel.Application`. + +## Install / update + +From the `server/` repo root: + +```sh +dotnet new install ./templates --force +``` + +Re-run with `--force` any time you edit a template file — installs are a snapshot, not a live link. + +## Templates + +### `stackduel-command` — Command + Handler + Validator + +```sh +cd StackDuel.Application/Commands/Orders # wherever the feature's commands live +dotnet new stackduel-command -n CreateOrder --response Guid \ + --namespace StackDuel.Application.Commands.Orders.CreateOrder +``` + +Generates `CreateOrder/{CreateOrderCommand,CreateOrderHandler,CreateOrderValidator}.cs` with empty +TODO bodies. `--response` is required (the `TResult` in `ICommand`); `--namespace` defaults +to `StackDuel.Application.Commands`. + +### `stackduel-query` — Query + Handler + +```sh +dotnet new stackduel-query -n GetOrderById --response OrderDto \ + --namespace StackDuel.Application.Queries.Orders.GetOrderById +``` + +Same idea, for the read side. `--response` is required (usually a Dto type). + +### `stackduel-repository` — Repository interface (Domain layer) + +```sh +dotnet new stackduel-repository -n Order --aggregate Order \ + --aggregateNs StackDuel.Domain.Order.Entities --namespace StackDuel.Domain.Order +``` + +Generates `IOrderRepository : IRepository`. `--aggregate` and `--aggregateNs` are both +required and always fully-qualify the aggregate type in the generated file — do the same in any +code you add by hand in a namespace that isn't `StackDuel.Domain.` itself. Folders named +the same as their entity (e.g. `StackDuel.Domain/User/Entities/User.cs`) make the bare type name +`User`/`Order`/etc. unresolvable from sibling or ancestor namespaces (C# finds the *namespace* +segment first) — see the note in `StackDuel.Domain.Tests` for a worked example. + +### `stackduel-service` — Application service (wraps `ISender`) + +```sh +dotnet new stackduel-service -n Order --namespace StackDuel.Application.Order +``` + +Generates `IOrderService`/`OrderService(ISender sender)` with empty method bodies you fill in by +calling `sender.Send(new SomeCommand(...), cancellationToken)`. + +### `stackduel-feature-slice` — all of the above at once + +The composite generator: given an aggregate name and its existing entity's namespace, scaffolds a +full `Create` + `GetById` vertical slice — repository interface, command, validator, handler, query, +handler, DTO, and service — in one shot, with everything wired together and referencing the right +types. Run from the **repo root** (it writes into both `StackDuel.Domain` and +`StackDuel.Application`): + +```sh +dotnet new stackduel-feature-slice -n Order --aggregateNs StackDuel.Domain.Order.Entities +``` + +Produces: + +``` +StackDuel.Domain/Order/IOrderRepository.cs +StackDuel.Application/Commands/Order/CreateOrder/{CreateOrderCommand,CreateOrderHandler,CreateOrderValidator}.cs +StackDuel.Application/Queries/Order/GetOrderById/{GetOrderByIdQuery,GetOrderByIdHandler}.cs +StackDuel.Application/Order/{IOrderService,OrderService,Dtos/OrderDto}.cs +``` + +This assumes the `Order` aggregate root **already exists** (hand-write the entity first, the way +`User` was built) — the slice scaffolds the CQRS plumbing around it, not the domain model itself. +After generating, you still need to: register `IOrderService`/`IOrderRepository` in DI, implement +the handler bodies (marked `throw new NotImplementedException()`), and fill in the DTO's fields. diff --git a/templates/command/.template.config/template.json b/templates/command/.template.config/template.json new file mode 100644 index 0000000..c89c78a --- /dev/null +++ b/templates/command/.template.config/template.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Stack Duel", + "classifications": ["StackDuel", "CQRS", "Command"], + "identity": "StackDuel.Templates.Command", + "name": "StackDuel CQRS Command", + "shortName": "stackduel-command", + "tags": { "language": "C#", "type": "item" }, + "sourceName": "Sample", + "symbols": { + "response": { + "type": "parameter", + "datatype": "string", + "replaces": "__RESPONSE__", + "isRequired": true, + "description": "The TResult type the command resolves to, i.e. ICommand (e.g. Guid)." + }, + "namespace": { + "type": "parameter", + "datatype": "string", + "replaces": "__NAMESPACE__", + "defaultValue": "StackDuel.Application.Commands", + "description": "Namespace for the generated command/handler/validator." + } + } +} diff --git a/templates/command/Sample/SampleCommand.cs b/templates/command/Sample/SampleCommand.cs new file mode 100644 index 0000000..0572947 --- /dev/null +++ b/templates/command/Sample/SampleCommand.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Commands; + +namespace __NAMESPACE__; + +public sealed record SampleCommand(/* TODO: add properties */) : ICommand<__RESPONSE__>; diff --git a/templates/command/Sample/SampleHandler.cs b/templates/command/Sample/SampleHandler.cs new file mode 100644 index 0000000..e90380f --- /dev/null +++ b/templates/command/Sample/SampleHandler.cs @@ -0,0 +1,18 @@ +using Ardalis.Result; +using FluentValidation; +using StackDuel.Application.Commands; + +namespace __NAMESPACE__; + +internal sealed class SampleHandler(IValidator validator /* TODO: inject repository/services */) + : AbstractCommandHandler(validator) +{ + protected override async ValueTask> HandleValidated( + SampleCommand command, + CancellationToken cancellationToken + ) + { + // TODO: implement + throw new NotImplementedException(); + } +} diff --git a/templates/command/Sample/SampleValidator.cs b/templates/command/Sample/SampleValidator.cs new file mode 100644 index 0000000..0169fd3 --- /dev/null +++ b/templates/command/Sample/SampleValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace __NAMESPACE__; + +internal sealed class SampleValidator : AbstractValidator +{ + public SampleValidator() + { + // TODO: add rules, e.g. RuleFor(x => x.SomeProperty).NotEmpty(); + } +} diff --git a/templates/feature-slice/.template.config/template.json b/templates/feature-slice/.template.config/template.json new file mode 100644 index 0000000..958b8e9 --- /dev/null +++ b/templates/feature-slice/.template.config/template.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Stack Duel", + "classifications": ["StackDuel", "CQRS", "Feature Slice"], + "identity": "StackDuel.Templates.FeatureSlice", + "name": "StackDuel CQRS Feature Slice (Repository + Command + Query + Service)", + "shortName": "stackduel-feature-slice", + "tags": { "language": "C#", "type": "item" }, + "sourceName": "Sample", + "symbols": { + "aggregateNs": { + "type": "parameter", + "datatype": "string", + "replaces": "__AGGREGATE_NS__", + "isRequired": true, + "description": "Fully qualified namespace of the existing aggregate root entity (e.g. StackDuel.Domain.Order.Entities). The entity itself must already exist; this scaffolds the CQRS slice around it." + }, + "rootNamespace": { + "type": "parameter", + "datatype": "string", + "replaces": "__ROOT_NS__", + "defaultValue": "StackDuel", + "description": "Root namespace prefix for every generated namespace." + } + }, + "primaryOutputs": [ + { "path": "StackDuel.Domain/Sample/ISampleRepository.cs" }, + { "path": "StackDuel.Application/Commands/Sample/CreateSample/CreateSampleCommand.cs" }, + { "path": "StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdQuery.cs" }, + { "path": "StackDuel.Application/Sample/ISampleService.cs" } + ] +} diff --git a/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleCommand.cs b/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleCommand.cs new file mode 100644 index 0000000..1d43f8c --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleCommand.cs @@ -0,0 +1,5 @@ +using __ROOT_NS__.Application.Commands; + +namespace __ROOT_NS__.Application.Commands.Sample.CreateSample; + +public sealed record CreateSampleCommand(/* TODO: add properties */) : ICommand; diff --git a/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleHandler.cs b/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleHandler.cs new file mode 100644 index 0000000..f10b742 --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleHandler.cs @@ -0,0 +1,22 @@ +using Ardalis.Result; +using FluentValidation; +using __ROOT_NS__.Application.Commands; +using __ROOT_NS__.Domain.Sample; + +namespace __ROOT_NS__.Application.Commands.Sample.CreateSample; + +internal sealed class CreateSampleHandler(IValidator validator, ISampleRepository repository) + : AbstractCommandHandler(validator) +{ + protected override async ValueTask> HandleValidated( + CreateSampleCommand command, + CancellationToken cancellationToken + ) + { + // TODO: construct the __AGGREGATE_NS__.Sample aggregate and persist it, e.g.: + // var entity = new __AGGREGATE_NS__.Sample(...); + // await repository.AddAsync(entity, cancellationToken); + // return Result.Success(entity.Id); + throw new NotImplementedException(); + } +} diff --git a/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleValidator.cs b/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleValidator.cs new file mode 100644 index 0000000..e2bf9fc --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Commands/Sample/CreateSample/CreateSampleValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace __ROOT_NS__.Application.Commands.Sample.CreateSample; + +internal sealed class CreateSampleValidator : AbstractValidator +{ + public CreateSampleValidator() + { + // TODO: add rules. + } +} diff --git a/templates/feature-slice/StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdHandler.cs b/templates/feature-slice/StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdHandler.cs new file mode 100644 index 0000000..e4f95e7 --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdHandler.cs @@ -0,0 +1,20 @@ +using Ardalis.Result; +using __ROOT_NS__.Application.Queries; +using __ROOT_NS__.Application.Sample.Dtos; +using __ROOT_NS__.Domain.Sample; + +namespace __ROOT_NS__.Application.Queries.Sample.GetSampleById; + +internal sealed class GetSampleByIdHandler(ISampleRepository repository) : IQueryHandler +{ + public async ValueTask> Handle(GetSampleByIdQuery query, CancellationToken cancellationToken) + { + var entity = await repository.FindByIdAsync(query.Id, cancellationToken); + + if (entity is null) + return Result.NotFound(); + + // TODO: map `entity` (__AGGREGATE_NS__.Sample) onto SampleDto. + throw new NotImplementedException(); + } +} diff --git a/templates/feature-slice/StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdQuery.cs b/templates/feature-slice/StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdQuery.cs new file mode 100644 index 0000000..e180308 --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Queries/Sample/GetSampleById/GetSampleByIdQuery.cs @@ -0,0 +1,6 @@ +using __ROOT_NS__.Application.Queries; +using __ROOT_NS__.Application.Sample.Dtos; + +namespace __ROOT_NS__.Application.Queries.Sample.GetSampleById; + +public sealed record GetSampleByIdQuery(Guid Id) : IQuery; diff --git a/templates/feature-slice/StackDuel.Application/Sample/Dtos/SampleDto.cs b/templates/feature-slice/StackDuel.Application/Sample/Dtos/SampleDto.cs new file mode 100644 index 0000000..c058a94 --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Sample/Dtos/SampleDto.cs @@ -0,0 +1,4 @@ +namespace __ROOT_NS__.Application.Sample.Dtos; + +// TODO: add the fields callers of this feature actually need. +public sealed record SampleDto(Guid Id); diff --git a/templates/feature-slice/StackDuel.Application/Sample/ISampleService.cs b/templates/feature-slice/StackDuel.Application/Sample/ISampleService.cs new file mode 100644 index 0000000..72e1301 --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Sample/ISampleService.cs @@ -0,0 +1,11 @@ +using Ardalis.Result; +using __ROOT_NS__.Application.Sample.Dtos; + +namespace __ROOT_NS__.Application.Sample; + +public interface ISampleService +{ + Task> CreateAsync(/* TODO: params */ CancellationToken cancellationToken); + + Task> GetByIdAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/templates/feature-slice/StackDuel.Application/Sample/SampleService.cs b/templates/feature-slice/StackDuel.Application/Sample/SampleService.cs new file mode 100644 index 0000000..b519964 --- /dev/null +++ b/templates/feature-slice/StackDuel.Application/Sample/SampleService.cs @@ -0,0 +1,16 @@ +using Ardalis.Result; +using Mediator; +using __ROOT_NS__.Application.Commands.Sample.CreateSample; +using __ROOT_NS__.Application.Queries.Sample.GetSampleById; +using __ROOT_NS__.Application.Sample.Dtos; + +namespace __ROOT_NS__.Application.Sample; + +internal sealed class SampleService(ISender sender) : ISampleService +{ + public async Task> CreateAsync(/* TODO: params */ CancellationToken cancellationToken) => + await sender.Send(new CreateSampleCommand(/* TODO: map params */), cancellationToken); + + public async Task> GetByIdAsync(Guid id, CancellationToken cancellationToken) => + await sender.Send(new GetSampleByIdQuery(id), cancellationToken); +} diff --git a/templates/feature-slice/StackDuel.Domain/Sample/ISampleRepository.cs b/templates/feature-slice/StackDuel.Domain/Sample/ISampleRepository.cs new file mode 100644 index 0000000..ccb549f --- /dev/null +++ b/templates/feature-slice/StackDuel.Domain/Sample/ISampleRepository.cs @@ -0,0 +1,8 @@ +using __ROOT_NS__.Domain.SeedWork; + +namespace __ROOT_NS__.Domain.Sample; + +public interface ISampleRepository : IRepository<__AGGREGATE_NS__.Sample> +{ + // TODO: add aggregate-specific query methods beyond FindByIdAsync (inherited from IRepository). +} diff --git a/templates/query/.template.config/template.json b/templates/query/.template.config/template.json new file mode 100644 index 0000000..01d5575 --- /dev/null +++ b/templates/query/.template.config/template.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Stack Duel", + "classifications": ["StackDuel", "CQRS", "Query"], + "identity": "StackDuel.Templates.Query", + "name": "StackDuel CQRS Query", + "shortName": "stackduel-query", + "tags": { "language": "C#", "type": "item" }, + "sourceName": "Sample", + "symbols": { + "response": { + "type": "parameter", + "datatype": "string", + "replaces": "__RESPONSE__", + "isRequired": true, + "description": "The TResult type the query resolves to, i.e. IQuery (e.g. a Dto type)." + }, + "namespace": { + "type": "parameter", + "datatype": "string", + "replaces": "__NAMESPACE__", + "defaultValue": "StackDuel.Application.Queries", + "description": "Namespace for the generated query/handler." + } + } +} diff --git a/templates/query/Sample/SampleHandler.cs b/templates/query/Sample/SampleHandler.cs new file mode 100644 index 0000000..318dd8e --- /dev/null +++ b/templates/query/Sample/SampleHandler.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using StackDuel.Application.Queries; + +namespace __NAMESPACE__; + +internal sealed class SampleHandler(/* TODO: inject repository */) : IQueryHandler +{ + public async ValueTask> Handle(SampleQuery query, CancellationToken cancellationToken) + { + // TODO: implement + throw new NotImplementedException(); + } +} diff --git a/templates/query/Sample/SampleQuery.cs b/templates/query/Sample/SampleQuery.cs new file mode 100644 index 0000000..c9cc2bd --- /dev/null +++ b/templates/query/Sample/SampleQuery.cs @@ -0,0 +1,5 @@ +using StackDuel.Application.Queries; + +namespace __NAMESPACE__; + +public sealed record SampleQuery(/* TODO: add properties */) : IQuery<__RESPONSE__>; diff --git a/templates/repository/.template.config/template.json b/templates/repository/.template.config/template.json new file mode 100644 index 0000000..21a544f --- /dev/null +++ b/templates/repository/.template.config/template.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Stack Duel", + "classifications": ["StackDuel", "CQRS", "Repository"], + "identity": "StackDuel.Templates.Repository", + "name": "StackDuel Repository Interface", + "shortName": "stackduel-repository", + "tags": { "language": "C#", "type": "item" }, + "sourceName": "Sample", + "symbols": { + "aggregate": { + "type": "parameter", + "datatype": "string", + "replaces": "__AGGREGATE__", + "isRequired": true, + "description": "The aggregate root type this repository persists (e.g. Order)." + }, + "aggregateNs": { + "type": "parameter", + "datatype": "string", + "replaces": "__AGGREGATE_NS__", + "isRequired": true, + "description": "Fully qualified namespace of the aggregate root (e.g. StackDuel.Domain.Order.Entities)." + }, + "namespace": { + "type": "parameter", + "datatype": "string", + "replaces": "__NAMESPACE__", + "defaultValue": "StackDuel.Domain", + "description": "Namespace for the generated repository interface." + } + } +} diff --git a/templates/repository/ISampleRepository.cs b/templates/repository/ISampleRepository.cs new file mode 100644 index 0000000..3a62afb --- /dev/null +++ b/templates/repository/ISampleRepository.cs @@ -0,0 +1,9 @@ +using StackDuel.Domain.SeedWork; + +namespace __NAMESPACE__; + +public interface ISampleRepository : IRepository<__AGGREGATE_NS__.__AGGREGATE__> +{ + // TODO: add aggregate-specific query methods beyond FindByIdAsync (inherited from IRepository), e.g.: + // Task<__AGGREGATE_NS__.__AGGREGATE__?> FindByXAsync(string x, CancellationToken cancellationToken = default); +} diff --git a/templates/service/.template.config/template.json b/templates/service/.template.config/template.json new file mode 100644 index 0000000..9938dfb --- /dev/null +++ b/templates/service/.template.config/template.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Stack Duel", + "classifications": ["StackDuel", "CQRS", "Service"], + "identity": "StackDuel.Templates.Service", + "name": "StackDuel Application Service", + "shortName": "stackduel-service", + "tags": { "language": "C#", "type": "item" }, + "sourceName": "Sample", + "symbols": { + "namespace": { + "type": "parameter", + "datatype": "string", + "replaces": "__NAMESPACE__", + "defaultValue": "StackDuel.Application", + "description": "Namespace for the generated service interface/implementation." + } + } +} diff --git a/templates/service/ISampleService.cs b/templates/service/ISampleService.cs new file mode 100644 index 0000000..1118634 --- /dev/null +++ b/templates/service/ISampleService.cs @@ -0,0 +1,7 @@ +namespace __NAMESPACE__; + +public interface ISampleService +{ + // TODO: define service methods, e.g.: + // Task> CreateAsync(/* params */ CancellationToken cancellationToken); +} diff --git a/templates/service/SampleService.cs b/templates/service/SampleService.cs new file mode 100644 index 0000000..4670296 --- /dev/null +++ b/templates/service/SampleService.cs @@ -0,0 +1,8 @@ +using Mediator; + +namespace __NAMESPACE__; + +internal sealed class SampleService(ISender sender) : ISampleService +{ + // TODO: implement methods by calling sender.Send(new SomeCommand(...), cancellationToken) +}