diff --git a/.codacy.yml b/.codacy.yml index 6995d49..2dd79d5 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -21,3 +21,8 @@ exclude_paths: # payloads like '$MONGO_GT' and '`id`' must NOT expand. SC2034 (BASE_URL) is used # further down in the same script. - ".pentest/**" + + # Monitoring infrastructure templates β€” SMTP environment variable references + # in docker-compose look like credentials to static scanners. alertmanager.yml + # is gitignored (only .example is tracked). + - "docker-compose.monitoring.yml" diff --git a/.env.example b/.env.example index 40e7b5d..03c9c54 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,31 @@ SCRAPER_API_URL=https://scraper.prostaff.gg # Must match SCRAPER_API_KEY configured on the scraper service SCRAPER_API_KEY= +# =========================================== +# prostaff-events Integration (Phoenix event bus) +# =========================================== +# Real-time WebSocket hub and event bus. Rails publishes domain events to Redis +# pub/sub (channel: prostaff:events:), Phoenix subscribes and broadcasts +# to connected frontend clients. +# +# Leave blank to disable event publishing (events are silently dropped). +# When set, Events::EventPublisher will publish to Redis on every domain event. +# +# Internal JWT secret shared with prostaff-events for service-to-service auth. +# Must match INTERNAL_JWT_SECRET configured in prostaff-events. +PHOENIX_EVENTS_ENABLED=false +PHOENIX_EVENTS_URL=http://localhost:4000 +INTERNAL_JWT_SECRET= + +# =========================================== +# Sidekiq Web UI (production access) +# =========================================== +# Credentials for /sidekiq dashboard (HTTP Basic Auth). +# Both must be set β€” UI stays inaccessible if either is blank (safe default). +# Generate password: openssl rand -hex 32 +SIDEKIQ_WEB_USER= +SIDEKIQ_WEB_PASSWORD= + # =========================================== # HashID Configuration (for public URL obfuscation) # =========================================== diff --git a/.github/branch-protection-ruleset.json b/.github/branch-protection-ruleset.json index b924059..e8e1be0 100644 --- a/.github/branch-protection-ruleset.json +++ b/.github/branch-protection-ruleset.json @@ -28,6 +28,10 @@ { "context": "Security Scan", "integration_id": null + }, + { + "context": "codacy/pr-quality-review", + "integration_id": null } ], "strict_required_status_checks_policy": true diff --git a/.github/workflows/README.md b/.github/workflows/README.md index dad571f..628e646 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -250,7 +250,7 @@ new-feature-test: - name: Setup Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Setup Database run: bundle exec rails db:migrate RAILS_ENV=test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5073e4..bcc30e5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,6 +30,7 @@ on: permissions: security-events: write # upload SARIF para o Security tab + pull-requests: write # postar comentario de resumo no PR packages: read actions: read contents: read diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 89951f7..1588ca3 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -92,7 +92,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Install dependencies @@ -236,7 +236,7 @@ jobs: steps: - name: Manual approval checkpoint - run: | + run: | # nosemgrep: yaml.github-actions.security.run-shell-injection.run-shell-injection echo "==================================" echo "🚨 PRODUCTION DEPLOYMENT" echo "==================================" @@ -511,7 +511,7 @@ jobs: fi - name: Display notification - run: | + run: | # nosemgrep: yaml.github-actions.security.run-shell-injection.run-shell-injection echo "==============================================" echo "${{ env.STATUS }}" echo "${{ env.MESSAGE }}" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 420728a..0f88258 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -51,7 +51,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Install dependencies @@ -262,7 +262,7 @@ jobs: fi - name: Display notification - run: | + run: | # nosemgrep: yaml.github-actions.security.run-shell-injection.run-shell-injection echo "======================================" echo "${{ env.STATUS }}" echo "${{ env.MESSAGE }}" diff --git a/.github/workflows/nightly-security.yml b/.github/workflows/nightly-security.yml index 8b1e2d4..616633c 100644 --- a/.github/workflows/nightly-security.yml +++ b/.github/workflows/nightly-security.yml @@ -1,10 +1,9 @@ name: Nightly Security Audit on: - # TODO: Reativar quando em produΓ§Γ£o - # schedule: - # # Run every night at 1am UTC - # - cron: '0 1 * * *' + schedule: + # Run every night at 1am UTC + - cron: '0 1 * * *' workflow_dispatch: permissions: @@ -15,6 +14,8 @@ jobs: full-security-audit: name: Complete Security Audit runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true services: postgres: image: postgres:14 @@ -46,13 +47,16 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Setup Database env: RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432/prostaff_test + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/prostaff_test + REDIS_URL: redis://localhost:6379/0 + SECRET_KEY_BASE: nightly_audit_secret_key_base_not_for_production + JWT_SECRET_KEY: nightly_audit_jwt_secret_not_for_production run: | bundle exec rails db:create bundle exec rails db:migrate @@ -60,24 +64,23 @@ jobs: - name: Start Rails Server env: RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432/prostaff_test + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/prostaff_test REDIS_URL: redis://localhost:6379/0 + SECRET_KEY_BASE: nightly_audit_secret_key_base_not_for_production + JWT_SECRET_KEY: nightly_audit_jwt_secret_not_for_production run: | - bundle exec rails server -p 3333 -e test & - sleep 10 - curl -f http://localhost:3333/up || exit 1 + bundle exec rails server -p 3333 -e test -d + timeout 60 bash -c 'until curl -sf http://localhost:3333/up; do sleep 2; done' - - name: Install Security Tools - run: | - gem install brakeman bundler-audit - docker pull zaproxy/zap-stable + - name: Install ZAP + run: docker pull zaproxy/zap-stable - name: Create Reports Directory run: mkdir -p security_tests/reports/nightly - name: Run Brakeman run: | - brakeman --rails7 \ + bundle exec brakeman --rails7 \ --format json \ --output security_tests/reports/nightly/brakeman.json \ --format html \ @@ -86,13 +89,18 @@ jobs: - name: Run Bundle Audit run: | - bundle-audit update - bundle-audit check > security_tests/reports/nightly/bundle-audit.txt || true + bundle exec bundler-audit update + bundle exec bundler-audit check \ + --format json \ + --output security_tests/reports/nightly/bundle-audit.json \ + || true + # Also write plain text for human readability + bundle exec bundler-audit check > security_tests/reports/nightly/bundle-audit.txt || true - name: Run ZAP Baseline Scan run: | docker run --rm --network="host" \ - -v $(pwd)/security_tests/reports/nightly:/zap/wrk:rw \ + -v "$(pwd)/security_tests/reports/nightly:/zap/wrk:rw" \ zaproxy/zap-stable \ zap-baseline.py \ -t http://localhost:3333 \ @@ -102,10 +110,10 @@ jobs: - name: Run ZAP API Scan run: | docker run --rm --network="host" \ - -v $(pwd)/security_tests/reports/nightly:/zap/wrk:rw \ + -v "$(pwd)/security_tests/reports/nightly:/zap/wrk:rw" \ zaproxy/zap-stable \ zap-api-scan.py \ - -t http://localhost:3333/api-docs/v1/swagger.json \ + -t http://localhost:3333/api-docs/v1/swagger.yaml \ -f openapi \ -r zap-api.html \ -J zap-api.json || true @@ -114,114 +122,116 @@ jobs: id: parse run: | # Brakeman - BRAKEMAN_HIGH=$(jq '[.warnings[] | select(.confidence == "High")] | length' security_tests/reports/nightly/brakeman.json) - BRAKEMAN_TOTAL=$(jq '.warnings | length' security_tests/reports/nightly/brakeman.json) + BRAKEMAN_HIGH=$(jq '[.warnings[] | select(.confidence == "High")] | length' \ + security_tests/reports/nightly/brakeman.json 2>/dev/null || echo "0") + BRAKEMAN_TOTAL=$(jq '.warnings | length' \ + security_tests/reports/nightly/brakeman.json 2>/dev/null || echo "0") # Bundle Audit - if grep -q "Vulnerabilities found" security_tests/reports/nightly/bundle-audit.txt; then + if grep -q "Vulnerabilities found" security_tests/reports/nightly/bundle-audit.txt 2>/dev/null; then VULNERABILITIES="true" else VULNERABILITIES="false" fi # ZAP - ZAP_HIGH=$(jq '[.site[0].alerts[] | select(.riskcode == "3")] | length' security_tests/reports/nightly/zap-baseline.json 2>/dev/null || echo "0") - ZAP_MEDIUM=$(jq '[.site[0].alerts[] | select(.riskcode == "2")] | length' security_tests/reports/nightly/zap-baseline.json 2>/dev/null || echo "0") + ZAP_HIGH=$(jq '[.site[0].alerts[] | select(.riskcode == "3")] | length' \ + security_tests/reports/nightly/zap-baseline.json 2>/dev/null || echo "0") + ZAP_MEDIUM=$(jq '[.site[0].alerts[] | select(.riskcode == "2")] | length' \ + security_tests/reports/nightly/zap-baseline.json 2>/dev/null || echo "0") - echo "brakeman_high=$BRAKEMAN_HIGH" >> $GITHUB_OUTPUT - echo "brakeman_total=$BRAKEMAN_TOTAL" >> $GITHUB_OUTPUT - echo "vulnerabilities=$VULNERABILITIES" >> $GITHUB_OUTPUT - echo "zap_high=$ZAP_HIGH" >> $GITHUB_OUTPUT - echo "zap_medium=$ZAP_MEDIUM" >> $GITHUB_OUTPUT + echo "brakeman_high=$BRAKEMAN_HIGH" >> "$GITHUB_OUTPUT" + echo "brakeman_total=$BRAKEMAN_TOTAL" >> "$GITHUB_OUTPUT" + echo "vulnerabilities=$VULNERABILITIES" >> "$GITHUB_OUTPUT" + echo "zap_high=$ZAP_HIGH" >> "$GITHUB_OUTPUT" + echo "zap_medium=$ZAP_MEDIUM" >> "$GITHUB_OUTPUT" - name: Generate Summary if: always() run: | - cat > security_tests/reports/nightly/SUMMARY.md << EOF - # Nightly Security Audit Summary - - **Date:** $(date) - **Run:** #${{ github.run_number }} + cat >> "$GITHUB_STEP_SUMMARY" << EOF + # Nightly Security Audit β€” $(date -u '+%Y-%m-%d %H:%M UTC') - ## Results + ## Brakeman (SAST) + - Total warnings: ${{ steps.parse.outputs.brakeman_total }} + - High confidence: ${{ steps.parse.outputs.brakeman_high }} - ### Brakeman (Code Security) - - Total Warnings: ${{ steps.parse.outputs.brakeman_total }} - - High Confidence: ${{ steps.parse.outputs.brakeman_high }} - - ### Bundle Audit (Dependencies) + ## Bundle Audit (CVEs) - Vulnerabilities: ${{ steps.parse.outputs.vulnerabilities }} - ### OWASP ZAP (Runtime Security) - - High Risk: ${{ steps.parse.outputs.zap_high }} - - Medium Risk: ${{ steps.parse.outputs.zap_medium }} + ## OWASP ZAP (DAST) + - High risk: ${{ steps.parse.outputs.zap_high }} + - Medium risk: ${{ steps.parse.outputs.zap_medium }} ## Status - - $(if [ "${{ steps.parse.outputs.brakeman_high }}" -gt "0" ] || [ "${{ steps.parse.outputs.vulnerabilities }}" == "true" ] || [ "${{ steps.parse.outputs.zap_high }}" -gt "0" ]; then - echo "⚠️ **ACTION REQUIRED:** Critical security issues detected!" + $(if [ "${{ steps.parse.outputs.brakeman_high }}" -gt "0" ] \ + || [ "${{ steps.parse.outputs.vulnerabilities }}" = "true" ] \ + || [ "${{ steps.parse.outputs.zap_high }}" -gt "0" ]; then + echo "⚠️ **ACTION REQUIRED β€” critical security issues detected!**" else - echo "βœ… No critical security issues found." + echo "βœ… No critical issues found." fi) - - ## Reports - - - [Brakeman HTML Report](brakeman.html) - - [ZAP Baseline Report](zap-baseline.html) - - [ZAP API Report](zap-api.html) - - [Bundle Audit Report](bundle-audit.txt) EOF - - name: Job Summary - if: always() - run: | - cat security_tests/reports/nightly/SUMMARY.md >> $GITHUB_STEP_SUMMARY - - name: Upload Reports if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: nightly-security-reports-${{ github.run_number }} path: security_tests/reports/nightly/ + retention-days: 30 - name: Create GitHub Issue on Failure - if: steps.parse.outputs.brakeman_high > 0 || steps.parse.outputs.vulnerabilities == 'true' || steps.parse.outputs.zap_high > 0 + if: > + steps.parse.outputs.brakeman_high > 0 || + steps.parse.outputs.vulnerabilities == 'true' || + steps.parse.outputs.zap_high > 0 uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 with: script: | - const fs = require('fs'); - const summary = fs.readFileSync('security_tests/reports/nightly/SUMMARY.md', 'utf8'); - - const issues = await github.rest.issues.listForRepo({ + const date = new Date().toISOString().split('T')[0]; + const title = `⚠️ Nightly Security Audit Failed β€” ${date}`; + const body = [ + `## Nightly Security Audit β€” ${date}`, + '', + `- **Brakeman high**: ${{ steps.parse.outputs.brakeman_high }}`, + `- **CVEs found**: ${{ steps.parse.outputs.vulnerabilities }}`, + `- **ZAP high risk**: ${{ steps.parse.outputs.zap_high }}`, + `- **ZAP medium risk**: ${{ steps.parse.outputs.zap_medium }}`, + '', + `[View run artifacts](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`, + ].join('\n'); + + const { data: issues } = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', - labels: 'security,automated' + labels: 'security,automated', }); - const existingIssue = issues.data.find(issue => - issue.title.includes('Nightly Security Audit Failed') - ); - - if (existingIssue) { + const existing = issues.find(i => i.title.includes('Nightly Security Audit Failed')); + if (existing) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: existingIssue.number, - body: `## Update: ${new Date().toISOString()}\n\n${summary}` + issue_number: existing.number, + body: `## Update β€” ${new Date().toISOString()}\n\n${body}`, }); } else { await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: `⚠️ Nightly Security Audit Failed - ${new Date().toISOString().split('T')[0]}`, - body: summary, - labels: ['security', 'automated', 'critical'] + title, + body, + labels: ['security', 'automated', 'critical'], }); } - name: Fail on Critical Issues - if: steps.parse.outputs.brakeman_high > 0 || steps.parse.outputs.vulnerabilities == 'true' || steps.parse.outputs.zap_high > 0 + if: > + steps.parse.outputs.brakeman_high > 0 || + steps.parse.outputs.vulnerabilities == 'true' || + steps.parse.outputs.zap_high > 0 run: | - echo "::error::Critical security issues detected!" + echo "::error::Critical security issues detected β€” check the uploaded reports." exit 1 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 26c26a0..6457ccf 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -5,10 +5,9 @@ on: branches: [ master, develop ] pull_request: branches: [ master, develop ] - # TODO: Reativar quando em produΓ§Γ£o - # schedule: - # # Run weekly on Monday at 9am UTC - # - cron: '0 9 * * 1' + schedule: + # Run weekly on Monday at 9am UTC + - cron: '0 9 * * 1' permissions: contents: read @@ -25,15 +24,12 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - - name: Install Brakeman - run: gem install brakeman - - name: Run Brakeman run: | - brakeman --rails7 \ + bundle exec brakeman --rails7 \ --format json \ --output brakeman-report.json \ --no-exit-on-warn \ @@ -44,10 +40,11 @@ jobs: run: | WARNINGS=$(jq '.warnings | length' brakeman-report.json) HIGH=$(jq '[.warnings[] | select(.confidence == "High")] | length' brakeman-report.json) - echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT - echo "high=$HIGH" >> $GITHUB_OUTPUT + echo "warnings=$WARNINGS" >> "$GITHUB_OUTPUT" + echo "high=$HIGH" >> "$GITHUB_OUTPUT" - name: Upload Report + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: brakeman-report @@ -67,11 +64,11 @@ jobs: ${high > 0 ? '⚠️ High confidence issues found! Please review.' : 'βœ… No high confidence issues found.'} `; - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: body + body, }); - name: Fail on High Confidence Issues @@ -89,20 +86,22 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - - name: Install Bundle Audit - run: gem install bundler-audit - - name: Update Vulnerability Database - run: bundle-audit update + run: bundle exec bundler-audit update - name: Run Bundle Audit id: audit run: | - bundle-audit check --output bundle-audit.txt || echo "vulnerabilities=true" >> $GITHUB_OUTPUT - cat bundle-audit.txt + if ! bundle exec bundler-audit check; then + echo "vulnerabilities=true" >> "$GITHUB_OUTPUT" + bundle exec bundler-audit check > bundle-audit.txt || true + else + echo "vulnerabilities=false" >> "$GITHUB_OUTPUT" + bundle exec bundler-audit check > bundle-audit.txt + fi - name: Upload Report if: always() @@ -112,13 +111,15 @@ jobs: path: bundle-audit.txt - name: Comment PR - if: github.event_name == 'pull_request' && always() + if: github.event_name == 'pull_request' uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 with: script: | const fs = require('fs'); - const report = fs.readFileSync('bundle-audit.txt', 'utf8'); - const hasVulns = report.includes('Vulnerabilities found'); + const report = fs.existsSync('bundle-audit.txt') + ? fs.readFileSync('bundle-audit.txt', 'utf8') + : 'No report generated.'; + const hasVulns = '${{ steps.audit.outputs.vulnerabilities }}' === 'true'; const body = `## πŸ“¦ Dependency Security Check ${hasVulns ? '⚠️ Vulnerabilities found in dependencies!' : 'βœ… No known vulnerabilities found.'} @@ -131,11 +132,11 @@ jobs: \`\`\` `; - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: body + body, }); - name: Fail on Vulnerabilities @@ -165,42 +166,28 @@ jobs: --verbose \ || true - echo "::group::Semgrep Report Preview" - cat semgrep-report.json | head -c 5000 - echo "" - echo "::endgroup::" - - name: Parse Results id: parse run: | - # Count total results TOTAL=$(jq '.results | length' semgrep-report.json) - - # Count actual ERROR severity issues (not warnings) ERRORS=$(jq '.results | map(select(.extra.severity == "ERROR")) | length' semgrep-report.json) WARNINGS=$(jq '.results | map(select(.extra.severity == "WARNING")) | length' semgrep-report.json) - - # Count HIGH confidence security issues (excluding audit rules) CRITICAL=$(jq '.results | map(select(.extra.metadata.confidence == "HIGH" and (.extra.metadata.subcategory // "vuln") != "audit")) | length' semgrep-report.json) - echo "errors=$ERRORS" >> $GITHUB_OUTPUT - echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT + echo "errors=$ERRORS" >> "$GITHUB_OUTPUT" + echo "warnings=$WARNINGS" >> "$GITHUB_OUTPUT" + echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" - echo "::notice::Semgrep Analysis Complete" - echo "::notice:: - Total findings: $TOTAL" - echo "::notice:: - ERROR severity: $ERRORS" - echo "::notice:: - WARNING severity: $WARNINGS" - echo "::notice:: - HIGH confidence (non-audit): $CRITICAL" + echo "::notice::Total findings: $TOTAL β€” errors: $ERRORS, warnings: $WARNINGS, critical: $CRITICAL" - # Show details of ERROR severity issues if any if [ "$ERRORS" -gt 0 ]; then echo "::group::ERROR Severity Issues" - jq -r '.results[] | select(.extra.severity == "ERROR") | " - \(.path):\(.start.line) - \(.check_id)"' semgrep-report.json + jq -r '.results[] | select(.extra.severity == "ERROR") | " - \(.path):\(.start.line) β€” \(.check_id)"' semgrep-report.json echo "::endgroup::" fi - name: Upload Report + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: semgrep-report @@ -211,29 +198,33 @@ jobs: uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 with: script: | - const errors = '${{ steps.parse.outputs.errors }}'; + const errors = '${{ steps.parse.outputs.errors }}'; const warnings = '${{ steps.parse.outputs.warnings }}'; const critical = '${{ steps.parse.outputs.critical }}'; const body = `## πŸ” Semgrep Static Analysis - - **Errors**: ${errors} - - **Critical Issues**: ${critical} - - **Warnings**: ${warnings} + | Severity | Count | + |----------|-------| + | Errors | ${errors} | + | Critical (HIGH confidence) | ${critical} | + | Warnings | ${warnings} | - ${errors > 0 ? '❌ Security errors found! Please fix.' : critical > 0 ? '⚠️ High confidence security issues found. Please review.' : warnings > 0 ? '⚠️ Warnings found (non-blocking).' : 'βœ… No issues found.'} + ${errors > 0 ? '❌ Security errors found! Please fix before merging.' + : critical > 0 ? '⚠️ High confidence issues found. Please review.' + : warnings > 0 ? '⚠️ Warnings found (non-blocking).' + : 'βœ… No issues found.'} `; - github.rest.issues.createComment({ + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: body + body, }); - name: Fail on Critical Errors if: steps.parse.outputs.errors > 0 run: | - echo "::error::Semgrep found ${{ steps.parse.outputs.errors }} security errors with ERROR severity!" - echo "::error::Review the semgrep-report.json artifact for details" + echo "::error::Semgrep found ${{ steps.parse.outputs.errors }} ERROR severity issues." exit 1 secret-scan: @@ -283,7 +274,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Setup Database @@ -291,8 +282,7 @@ jobs: RAILS_ENV: test DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/prostaff_test REDIS_URL: redis://127.0.0.1:6379/0 - run: | - bundle exec rails db:create db:migrate RAILS_ENV=test + run: bundle exec rails db:create db:migrate RAILS_ENV=test - name: Start Rails Server env: @@ -303,10 +293,6 @@ jobs: RIOT_API_KEY: ${{ secrets.RIOT_API_KEY || 'dummy_key' }} run: | bundle exec rails server -p 3333 -d - sleep 10 - - - name: Wait for API - run: | timeout 60 bash -c 'until curl -sf http://localhost:3333/up; do sleep 2; done' - name: Run SSRF Protection Tests @@ -353,7 +339,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Setup Database @@ -361,8 +347,7 @@ jobs: RAILS_ENV: test DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/prostaff_test REDIS_URL: redis://127.0.0.1:6379/0 - run: | - bundle exec rails db:create db:migrate RAILS_ENV=test + run: bundle exec rails db:create db:migrate RAILS_ENV=test - name: Start Rails Server env: @@ -373,10 +358,6 @@ jobs: RIOT_API_KEY: ${{ secrets.RIOT_API_KEY || 'dummy_key' }} run: | bundle exec rails server -p 3333 -d - sleep 10 - - - name: Wait for API - run: | timeout 60 bash -c 'until curl -sf http://localhost:3333/up; do sleep 2; done' - name: Run Authentication Tests @@ -416,7 +397,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: 3.4.5 + ruby-version: 3.4.8 bundler-cache: true - name: Setup Database @@ -424,8 +405,7 @@ jobs: RAILS_ENV: test DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/prostaff_test REDIS_URL: redis://127.0.0.1:6379/0 - run: | - bundle exec rails db:create db:migrate RAILS_ENV=test + run: bundle exec rails db:create db:migrate RAILS_ENV=test - name: Start Rails Server env: @@ -436,10 +416,6 @@ jobs: RIOT_API_KEY: ${{ secrets.RIOT_API_KEY || 'dummy_key' }} run: | bundle exec rails server -p 3333 -d - sleep 10 - - - name: Wait for API - run: | timeout 60 bash -c 'until curl -sf http://localhost:3333/up; do sleep 2; done' - name: Run SQL Injection Tests @@ -463,66 +439,93 @@ jobs: security-summary: name: Security Summary runs-on: ubuntu-latest - needs: [brakeman, dependency-check, semgrep, ssrf-protection, authentication-test, sql-injection-test, secrets-scan-enhanced] + needs: + - brakeman + - dependency-check + - semgrep + - ssrf-protection + - authentication-test + - sql-injection-test + - secrets-scan-enhanced if: always() steps: - name: Check Results run: | - echo "Brakeman: ${{ needs.brakeman.result }}" + echo "Brakeman: ${{ needs.brakeman.result }}" echo "Dependency Check: ${{ needs.dependency-check.result }}" - echo "Semgrep: ${{ needs.semgrep.result }}" - echo "SSRF Protection: ${{ needs.ssrf-protection.result }}" - echo "Authentication: ${{ needs.authentication-test.result }}" - echo "SQL Injection: ${{ needs.sql-injection-test.result }}" - echo "Secrets Scan: ${{ needs.secrets-scan-enhanced.result }}" + echo "Semgrep: ${{ needs.semgrep.result }}" + echo "SSRF Protection: ${{ needs.ssrf-protection.result }}" + echo "Authentication: ${{ needs.authentication-test.result }}" + echo "SQL Injection: ${{ needs.sql-injection-test.result }}" + echo "Secrets Scan: ${{ needs.secrets-scan-enhanced.result }}" - - name: Post Summary + - name: Write Step Summary + run: | + status() { + case "$1" in + success) echo "βœ…" ;; + failure) echo "❌" ;; + *) echo "⚠️" ;; + esac + } + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## πŸ” Security Scan Summary + + ### Static Analysis (SAST) + | Check | Status | + |-------|--------| + | Brakeman | $(status "${{ needs.brakeman.result }}") ${{ needs.brakeman.result }} | + | Dependencies | $(status "${{ needs.dependency-check.result }}") ${{ needs.dependency-check.result }} | + | Semgrep | $(status "${{ needs.semgrep.result }}") ${{ needs.semgrep.result }} | + | Secrets | $(status "${{ needs.secrets-scan-enhanced.result }}") ${{ needs.secrets-scan-enhanced.result }} | + + ### Dynamic Analysis (DAST) + | Check | Status | + |-------|--------| + | SSRF Protection | $(status "${{ needs.ssrf-protection.result }}") ${{ needs.ssrf-protection.result }} | + | Authentication | $(status "${{ needs.authentication-test.result }}") ${{ needs.authentication-test.result }} | + | SQL Injection | $(status "${{ needs.sql-injection-test.result }}") ${{ needs.sql-injection-test.result }} | + EOF + + - name: Comment PR if: github.event_name == 'pull_request' uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 with: script: | + const s = (r) => ({ success: 'βœ…', failure: '❌' }[r] ?? '⚠️'); const brakeman = '${{ needs.brakeman.result }}'; - const deps = '${{ needs.dependency-check.result }}'; - const semgrep = '${{ needs.semgrep.result }}'; - const ssrf = '${{ needs.ssrf-protection.result }}'; - const auth = '${{ needs.authentication-test.result }}'; - const sqli = '${{ needs.sql-injection-test.result }}'; - const secrets = '${{ needs.secrets-scan-enhanced.result }}'; - - const status = (result) => { - switch(result) { - case 'success': return 'βœ…'; - case 'failure': return '❌'; - default: return '⚠️'; - } - }; + const deps = '${{ needs.dependency-check.result }}'; + const semgrep = '${{ needs.semgrep.result }}'; + const ssrf = '${{ needs.ssrf-protection.result }}'; + const auth = '${{ needs.authentication-test.result }}'; + const sqli = '${{ needs.sql-injection-test.result }}'; + const secrets = '${{ needs.secrets-scan-enhanced.result }}'; + + const allPassed = [brakeman, deps, semgrep, ssrf, auth, sqli, secrets] + .every(r => r === 'success'); const body = `## πŸ” Security Scan Summary ### Static Analysis (SAST) | Check | Status | |-------|--------| - | Brakeman | ${status(brakeman)} ${brakeman} | - | Dependencies | ${status(deps)} ${deps} | - | Semgrep | ${status(semgrep)} ${semgrep} | - | Secrets | ${status(secrets)} ${secrets} | + | Brakeman | ${s(brakeman)} ${brakeman} | + | Dependencies | ${s(deps)} ${deps} | + | Semgrep | ${s(semgrep)} ${semgrep} | + | Secrets | ${s(secrets)} ${secrets} | ### Dynamic Analysis (DAST) | Check | Status | |-------|--------| - | SSRF Protection | ${status(ssrf)} ${ssrf} | - | Authentication | ${status(auth)} ${auth} | - | SQL Injection | ${status(sqli)} ${sqli} | - - ${brakeman === 'success' && deps === 'success' && semgrep === 'success' && - ssrf === 'success' && auth === 'success' && sqli === 'success' && secrets === 'success' - ? 'βœ… All security checks passed!' - : '⚠️ Some security checks failed. Please review the details above.'} - `; + | SSRF Protection | ${s(ssrf)} ${ssrf} | + | Authentication | ${s(auth)} ${auth} | + | SQL Injection | ${s(sqli)} ${sqli} | - github.rest.issues.createComment({ + ${allPassed ? 'βœ… All security checks passed!' : '⚠️ Some checks failed β€” review the details above.'} + `; + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: body + body, }); diff --git a/.github/workflows/snyk-container.yml b/.github/workflows/snyk-container.yml new file mode 100644 index 0000000..2eb1952 --- /dev/null +++ b/.github/workflows/snyk-container.yml @@ -0,0 +1,45 @@ +name: Snyk Container Scan + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master ] + schedule: + # Wednesday at 1:30 PM UTC β€” staggers from the other weekly scans (Monday 9am) + - cron: '30 13 * * 3' + +permissions: + contents: read + security-events: write + actions: read + +jobs: + snyk: + name: Snyk Docker Image Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Build Docker image + if: env.SNYK_TOKEN != '' + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: docker build -t prostaff-api:${{ github.sha }} . + + - name: Run Snyk container scan + if: env.SNYK_TOKEN != '' + continue-on-error: true + uses: snyk/actions/docker@14818c4695ecc4045f33c9cee9e795a788711ca4 + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + image: prostaff-api:${{ github.sha }} + args: --file=Dockerfile --severity-threshold=high + + - name: Upload SARIF to GitHub Code Scanning + # Only upload if the sarif file was produced (snyk may not create it on auth failure) + if: always() && hashFiles('snyk.sarif') != '' + uses: github/codeql-action/upload-sarif@b5ebac6f4c00c8ccddb7cdcd45fdb248329f808a # v3 + with: + sarif_file: snyk.sarif diff --git a/.github/workflows/update-architecture-diagram.yml b/.github/workflows/update-architecture-diagram.yml index 06d9e2c..d61c19c 100644 --- a/.github/workflows/update-architecture-diagram.yml +++ b/.github/workflows/update-architecture-diagram.yml @@ -39,7 +39,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 with: - ruby-version: '3.4.5' + ruby-version: '3.4.8' bundler-cache: true - name: Install dependencies diff --git a/.pentest/README.md b/.pentest/README.md index 96cfa67..d39ed6b 100644 --- a/.pentest/README.md +++ b/.pentest/README.md @@ -6,7 +6,7 @@ Lab de testes de seguranΓ§a para a API ProStaff - **API**: http://localhost:3333/api/v1 - **WebSocket**: ws://localhost:3333/cable -- **Stack**: Rails 7.1, PostgreSQL, Redis, JWT, Pundit, Rack::Attack, Meilisearch +- **Stack**: Rails 7.2, PostgreSQL, Redis, JWT, Pundit, Rack::Attack, Meilisearch ## Pre-requisitos @@ -15,7 +15,7 @@ API rodando localmente: ```bash cd /home/bullet/PROJETOS/prostaff-api docker compose up -d -docker exec prostaff-api-api-1 bundle exec rails runner scripts/create_test_user.rb +docker exec prostaff-api bundle exec rails runner scripts/create_test_user.rb ``` Credenciais de teste: `test@prostaff.gg` / `Test123!@#` @@ -28,39 +28,44 @@ Credenciais de teste: `test@prostaff.gg` / `Test123!@#` ``` ## Scripts β€” API (scripts/) - -| Script | Vetor | Destrutivo | -|--------|-------|-----------| -| 01_health_recon.sh | Info disclosure nos endpoints de health | Nao | -| 02_auth_fingerprint.sh | Fingerprint do sistema JWT + timing oracle | Nao | -| 03_jwt_attacks.sh | alg:none, RS256β†’HS256, claims tampering, token replay | Nao | -| 04_org_isolation.sh | IDOR + isolamento multi-tenant | Nao | -| 05_rbac_probe.sh | Privilege escalation + Pundit bypass | Nao | -| 06_rate_limit_probe.sh | Rack::Attack + bypass via X-Forwarded-For | Nao | -| 07_param_fuzzing.sh | SQLi, XSS, SSTI, type confusion, oversized payloads | Nao | -| 08_ssrf_probe.sh | SSRF via integracao Riot API | Nao | -| 09_export_injection.sh | CSV/Formula injection nos exports | Sim (cria player) | -| 10_websocket_probe.sh | Action Cable auth + IDOR de canal | Nao | -| 11_search_injection.sh | Meilisearch operators + cross-org search | Nao | -| 12_info_disclosure.sh | Rails routes expostos, headers, CORS, 500 stack traces | Nao | -| 13_nuclei_scan.sh | Templates customizados + headers/auth/Rails exposures | Nao | -| 14_httpx_recon.sh | Recon completo de paths e headers | Nao | -| 15_full_audit.sh | Roda todos os scripts em sequencia | Opcional | -| 16_security_headers.sh | CarameloScan checkers #1-7, #10, #13-16 (HSTS, CSP, CORS) | Nao | -| 17_cookie_security.sh | Flags Secure/HttpOnly/SameSite, escopo, invalidacao no logout | Nao | -| 18_content_security.sh | Server disclosure, Referrer-Policy, Cache-Control, stack trace | Nao | -| 19_info_disclosure.sh | .env, .git, swagger, rails/info, sidekiq, logs, Gemfile | Nao | -| 20_dns_email_spoof.sh | SPF, DMARC, DKIM, MX, zone transfer AXFR, subdomain takeover | Nao | + +| Script | Vetor | Destrutivo | +|-----------------------------|-------------------------------------------------------|----------------| +| 01_health_recon.sh | Info disclosure nos endpoints de health | Nao | +| 02_auth_fingerprint.sh | Fingerprint do sistema JWT + timing oracle | Nao | +| 03_jwt_attacks.sh | alg:none, RS256β†’HS256, claims tampering, token replay | Nao | +| 04_org_isolation.sh | IDOR + isolamento multi-tenant | Nao | +| 05_rbac_probe.sh | Privilege escalation + Pundit bypass | Nao | +| 06_rate_limit_probe.sh | Rack::Attack + bypass via X-Forwarded-For | Nao | +| 07_param_fuzzing.sh | SQLi, XSS, SSTI, type confusion, oversized payloads | Nao | +| 08_ssrf_probe.sh | SSRF via integracao Riot API | Nao | +| 09_export_injection.sh | CSV/Formula injection nos exports |Sim(cria player)| +| 10_websocket_probe.sh | Action Cable auth + IDOR de canal | Nao | +| 11_search_injection.sh | Meilisearch operators + cross-org search | Nao | +| 12_info_disclosure.sh | Rails routes expostos, headers, CORS, 500 stack traces| Nao | +| 13_nuclei_scan.sh | Templates customizados + headers/auth/Rails exposures | Nao | +| 14_httpx_recon.sh | Recon completo de paths e headers | Nao | +| 15_full_audit.sh | Roda todos os scripts em sequencia | opcional | +| 16_security_headers.sh | Checkers #1-7, #10, #13-16 (HSTS, CSP, CORS) | Nao | +| 17_cookie_security.sh | Flags Secure/HttpOnly/SameSite, escopo, invalidacao | Nao | +| 18_content_security.sh | Server disclosure, Referrer-Policy, stack trace, cache| Nao | +| 19_info_disclosure.sh | .env, .git, swagger, info, sidekiq, logs, Gemfile | Nao | +| 20_dns_email_spoof.sh | SPF, DMARC, DKIM, MX, zone transfer AXFR, subtakeover | Nao | +| 22_race_conditions.sh | TOCTOU em registro, refresh tk cc, rate limit burst | Nao | +| 23_token_rotation.sh | Ciclo de vida do token: single-use, type confusion | Nao | +| 24_host_header.sh | Host header injection em pass reset, config.hosts | Nao | +| 25_mass_assignment.sh | Strong Param: role, org_id, puuid, plan escalation | Nao | +| 27_supabase_direct_bypass.sh| Bypass da camada Rails via Supabase REST API direto | Nao | ## Scripts β€” Frontend (front/) -| Script | Vetor | -|--------|-------| -| check-security-headers.sh | Todos os 22 checkers CarameloScan no prostaff.gg | -| check-cookies.sh | Flags de cookie, SameSite, duracao, CSRF token | -| check-sri.sh | SRI em scripts/CSS externos, source maps, scripts inline | +| Script | Vetor | +|---------------------------|-----------------------------------------------------------------------| +| check-security-headers.sh | Todos os 22 checkers CaramelScan no prostaff.gg | +| check-cookies.sh | Flags de cookie, SameSite, duracao, CSRF token | +| check-sri.sh | SRI em scripts/CSS externos, source maps, scripts inline | | check-content-security.sh | Version disclosure, Referrer-Policy, cache em paginas auth, COOP/CORP | -| check-info-disclosure.sh | .env, .git, __NEXT_DATA__, BUILD_ID, comentarios HTML, robots.txt | +| check-info-disclosure.sh | .env, .git, __NEXT_DATA__, BUILD_ID, comentarios HTML, robots.txt | Todos os scripts de frontend aceitam o target como primeiro argumento: ```bash @@ -73,16 +78,25 @@ Todos os scripts de frontend aceitam o target como primeiro argumento: # Todos os testes API (sem os destrutivos) ./scripts/15_full_audit.sh --skip-destructive +# JWT e token lifecycle (novos) +./scripts/22_race_conditions.sh +./scripts/23_token_rotation.sh + # Auditoria de headers API (producao) ./scripts/16_security_headers.sh ./scripts/16_security_headers.sh http://localhost:3333 # local -# Auditoria completa de seguranca (CarameloScan + extras) +# Auditoria completa de seguranca (CarameloScan + extras + novos) ./scripts/16_security_headers.sh ./scripts/17_cookie_security.sh ./scripts/18_content_security.sh ./scripts/19_info_disclosure.sh ./scripts/20_dns_email_spoof.sh +./scripts/24_host_header.sh +./scripts/25_mass_assignment.sh + +# Supabase layer (anon key do frontend como vetor) +./scripts/27_supabase_direct_bypass.sh # Auditoria completa frontend ./front/check-security-headers.sh @@ -96,12 +110,14 @@ Todos os scripts de frontend aceitam o target como primeiro argumento: 1. `01` β†’ `02` (recon e auth - baseline) 2. `03` β†’ `04` β†’ `05` (atacar auth e autorizacao) -3. `06` β†’ `07` (rate limits e fuzzing) -4. `08` β†’ `09` (integracao externa e exports) -5. `10` β†’ `11` (WebSocket e search) -6. `12` β†’ `13` β†’ `14` (info disclosure e scan automatizado) -7. `16` β†’ `17` β†’ `18` β†’ `19` β†’ `20` (headers, cookies, content, DNS) -8. `front/check-*` (auditoria frontend) +3. `22` β†’ `23` (race conditions e lifecycle do token) +4. `06` β†’ `07` (rate limits e fuzzing) +5. `08` β†’ `09` (integracao externa e exports) +6. `10` β†’ `11` (WebSocket e search) +7. `12` β†’ `13` β†’ `14` (info disclosure e scan automatizado) +8. `16` β†’ `17` β†’ `18` β†’ `19` β†’ `20` β†’ `24` β†’ `25` (headers, cookies, content, DNS, host header, mass assignment) +9. `27` (Supabase layer β€” bypass via anon key do frontend) +10. `front/check-*` (auditoria frontend) ## Relatorios @@ -109,8 +125,20 @@ Salvos em `reports/` com data no nome. Formato: `security-audit-YYYY-MM-DD.md`. Nunca commitar - adicione ao .gitignore. -| Relatorio | Data | Criticos | Status | -|-----------|------|----------|--------| +| Relatorio | Data | Criticos | Status | +|--------------------------------------|----------------|----------|-----------| +| JWT Race Condition + Token Confusion | 2026-04-11 | 3 | Corrigido | + +### Historico de vulnerabilidades corrigidas + +| ID | Script | Severidade | Descricao & Correcao | +|--------|--------|------------|---------------------------------------------------------------------------------------| +| JWT-01 | 23 | Medium | Refresh token aceito como access token (`type` claim nao validado em `authenticate_request!`) +| | Adicionado `valid_access_token_type?` no concern `Authenticatable` +| JWT-02 | 23 | Medium | Refresh token sobrevive ao logout (logout nao blacklistava o refresh token) +| | `logout` agora blacklista `params[:refresh_token]` se presente +| JWT-03 | 22 | Medium | TOCTOU no `refresh_access_token` (decode + blacklist nao atomicos ----- 2 sessoes paralelas possiveis) +| | `TokenBlacklist.claim_for_rotation` com Redis SET NX EX antes de gerar novos tokens | ## Vetores principais (Rails/JWT) @@ -120,6 +148,9 @@ Salvos em `reports/` com data no nome. Formato: `security-audit-YYYY-MM-DD.md`. - Modificacao de claims (role, org_id) - Timing oracle para enumeracao de usuarios - Token replay apos logout +- **[CORRIGIDO 2026-04-11]** Refresh token TOCTOU race condition β€” 2x HTTP 200 em requests paralelas com o mesmo token +- **[CORRIGIDO 2026-04-11]** Refresh token sobrevive ao logout β€” cliente deve enviar refresh_token no body do logout +- **[CORRIGIDO 2026-04-11]** Refresh token aceito como access token em todos os endpoints autenticados ### Autorizacao - Multi-tenant IDOR (organization_id scope) @@ -160,5 +191,9 @@ Salvos em `reports/` com data no nome. Formato: `security-audit-YYYY-MM-DD.md`. ## Resultados -Salvos em `snapshots/` com timestamp. Nunca commitar - adicione ao .gitignore. +Salvos em `snapshots/` com timestamp. + +- Nunca commitados + +- adicionados ao .gitignore. diff --git a/.pentest/scripts/00_bundle_audit.sh b/.pentest/scripts/00_bundle_audit.sh new file mode 100644 index 0000000..103c2e4 --- /dev/null +++ b/.pentest/scripts/00_bundle_audit.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# ============================================================================= +# 00_bundle_audit.sh - Dependency CVE audit via bundler-audit +# +# Purpose: Check Gemfile.lock against the Ruby Advisory Database. +# Catches gem-level CVEs that static analysis tools (rubocop, +# brakeman, semgrep) cannot detect. +# +# Usage: +# bash 00_bundle_audit.sh # run from any directory +# bash 00_bundle_audit.sh --no-update # skip advisory DB update (offline) +# +# Output: ../snapshots/bundle_audit_TIMESTAMP.txt +# exits 1 if any vulnerability is found +# ============================================================================= + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="${REPO_ROOT}/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/bundle_audit_${TIMESTAMP}.txt" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*" | tee -a "$OUTPUT_FILE"; } +finding() { echo -e "${RED}[!!]${RESET} $*" | tee -a "$OUTPUT_FILE"; } +info() { echo -e "${CYAN}[*]${RESET} $*" | tee -a "$OUTPUT_FILE"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*" | tee -a "$OUTPUT_FILE"; } + +mkdir -p "$SNAPSHOT_DIR" +echo "# Bundle Audit β€” $TIMESTAMP" > "$OUTPUT_FILE" +echo "=================================================================" | tee -a "$OUTPUT_FILE" + +cd "$REPO_ROOT" + +# Ensure bundler-audit is available +if ! bundle exec bundler-audit version &>/dev/null; then + finding "bundler-audit not found. Run: bundle install" + exit 1 +fi + +# Update advisory DB (can be skipped with --no-update) +if [[ "${1:-}" != "--no-update" ]]; then + info "Updating Ruby Advisory Database..." + bundle exec bundler-audit update 2>&1 | tee -a "$OUTPUT_FILE" +else + warn "Skipping advisory DB update (--no-update passed)" +fi + +echo "" | tee -a "$OUTPUT_FILE" +info "Running audit against Gemfile.lock..." +echo "" | tee -a "$OUTPUT_FILE" + +AUDIT_EXIT=0 +bundle exec bundler-audit check 2>&1 | tee -a "$OUTPUT_FILE" || AUDIT_EXIT=$? + +echo "" | tee -a "$OUTPUT_FILE" +echo "=================================================================" | tee -a "$OUTPUT_FILE" + +if [ "$AUDIT_EXIT" -eq 0 ]; then + ok "No known vulnerabilities found." +else + finding "Vulnerabilities detected β€” see output above." + finding "Fix: bundle update --conservative" +fi + +echo -e "${BOLD}Full output saved to: ${OUTPUT_FILE}${RESET}" +exit "$AUDIT_EXIT" diff --git a/.pentest/scripts/15_full_audit.sh b/.pentest/scripts/15_full_audit.sh index 89c9669..35e64fc 100644 --- a/.pentest/scripts/15_full_audit.sh +++ b/.pentest/scripts/15_full_audit.sh @@ -92,6 +92,7 @@ SCRIPTS=( "13:13_nuclei_scan.sh:false:true" "14:14_httpx_recon.sh:false:false" "21:21_activestorage_dos_cve_2026_33658.sh:false:false" + "27:27_supabase_direct_bypass.sh:false:false" ) # --------------------------------------------------------------------------- diff --git a/.pentest/scripts/22_race_conditions.sh b/.pentest/scripts/22_race_conditions.sh new file mode 100644 index 0000000..72f2f09 --- /dev/null +++ b/.pentest/scripts/22_race_conditions.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# ============================================================================= +# 22_race_conditions.sh - Race condition / TOCTOU attack suite +# +# Purpose: Test concurrent requests for time-of-check / time-of-use gaps: +# 1. Duplicate email registration race (concurrent POSTs before DB constraint) +# 2. Refresh token concurrent reuse (token blacklist TOCTOU) +# 3. Rate limit burst bypass (Rack::Attack counter race) +# 4. Player creation concurrent (org membership limit bypass) +# +# Safe: creates test accounts that are cleaned up, no destructive side effects +# +# Usage: +# bash 22_race_conditions.sh +# +# Output: ../snapshots/race_conditions_TIMESTAMP.txt +# ============================================================================= + +set -euo pipefail +set +m # suppress job control notifications + +API="http://localhost:3333/api/v1" +TEST_EMAIL="test@prostaff.gg" +TEST_PASSWORD="Test123!@#" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="/home/bullet/PROJETOS/prostaff-api/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/race_conditions_${TIMESTAMP}.txt" +CONCURRENCY=8 # parallel requests per test + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +finding() { echo -e "${RED}[!!]${RESET} $*"; } +info() { echo -e "${CYAN}[*]${RESET} $*"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*"; } +header() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}\n"; } +sep() { echo "--------------------------------------------------------------------------------"; } + +mkdir -p "${SNAPSHOT_DIR}" +TEE_PID="" +exec > >(tee -a "${OUTPUT_FILE}"; ) 2>&1 +TEE_PID=$! + +VULN_COUNT=0 +TOKEN="" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +get_token() { + local tmp + tmp="$(mktemp)" + curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}" \ + 2>/dev/null > /dev/null || true + TOKEN=$(python3 -c " +import sys, json +try: + d = json.load(open('${tmp}')) + t = d.get('access_token') or d.get('data', {}).get('access_token', '') + print(t) +except: + print('') +" 2>/dev/null) + rm -f "${tmp}" +} + +# Fire N concurrent requests, return count of HTTP 200/201 responses +# Display output goes to stderr; only the numeric count goes to stdout +concurrent_post() { + local label="$1" + local url="$2" + local body="$3" + local n="${4:-${CONCURRENCY}}" + local tmp_dir + tmp_dir="$(mktemp -d)" + + info "Firing ${n} concurrent POSTs: ${label}" >&2 + + for i in $(seq 1 "${n}"); do + curl -s -o "${tmp_dir}/r${i}" -w "%{http_code}" --max-time 10 \ + -X POST "${url}" \ + -H "Content-Type: application/json" \ + -d "${body}" \ + 2>/dev/null > "${tmp_dir}/c${i}" & + done + wait + + local success=0 + local codes="" + for i in $(seq 1 "${n}"); do + local code + code=$(cat "${tmp_dir}/c${i}" 2>/dev/null || echo "ERR") + codes="${codes} ${code}" + if [[ "${code}" == "200" || "${code}" == "201" ]]; then + success=$((success + 1)) + fi + done + + echo " Status codes:${codes}" >&2 + echo " Successful (2xx): ${success} / ${n}" >&2 + + rm -rf "${tmp_dir}" + echo "${success}" +} + +# --------------------------------------------------------------------------- +# STEP 0: Authenticate +# --------------------------------------------------------------------------- +header "STEP 0: Obtaining Valid Token" +get_token +if [ -z "${TOKEN}" ]; then + finding "Could not obtain token - is the API running?" + exit 1 +fi +ok "Token acquired: ${TOKEN:0:40}..." + +# --------------------------------------------------------------------------- +# TEST 1: Duplicate email registration race +# --------------------------------------------------------------------------- +header "TEST 1: Duplicate Email Registration Race" +info "Description: Send ${CONCURRENCY} concurrent registration requests with the same email." +info "Expected: only 1 should succeed (201), rest should be 422 DUPLICATE_EMAIL" +info "Vulnerability: if >1 return 201, the duplicate check has a TOCTOU gap (app-level check, not DB constraint)" +sep + +RACE_EMAIL="race_test_${TIMESTAMP}@pentest.invalid" +RACE_ORG="PentestRaceOrg${TIMESTAMP}" + +BODY="{ + \"user\": {\"email\": \"${RACE_EMAIL}\", \"password\": \"PentestPass1!\", \"full_name\": \"Race Tester\"}, + \"organization\": {\"name\": \"${RACE_ORG}\", \"region\": \"BR\"} +}" + +SUCCESS=$(concurrent_post "duplicate email registration" "${API}/auth/register" "${BODY}" "${CONCURRENCY}") + +if [ "${SUCCESS}" -gt 1 ]; then + finding "RACE CONDITION: ${SUCCESS} registrations succeeded with the same email -> TOCTOU in duplicate check" + VULN_COUNT=$((VULN_COUNT + 1)) +elif [ "${SUCCESS}" -eq 1 ]; then + ok "Only 1 registration succeeded - duplicate check held under concurrency" +else + warn "No registrations succeeded - check if API is healthy" +fi + +# --------------------------------------------------------------------------- +# TEST 2: Duplicate organization name race +# --------------------------------------------------------------------------- +header "TEST 2: Duplicate Organization Name Race" +info "Description: Different emails, same organization name β€” concurrent registration" +info "Expected: only 1 org created, rest fail with DUPLICATE_ORGANIZATION" +sep + +RACE_ORG2="PentestRaceOrg2_${TIMESTAMP}" +RACE_ORG2_TMPDIR="${SNAPSHOT_DIR}/tmp_race2_${TIMESTAMP}" +mkdir -p "${RACE_ORG2_TMPDIR}" + +# Build all request bodies as separate tmp files (avoids variable expansion issues in background jobs) +for i in $(seq 1 "${CONCURRENCY}"); do + cat > "${RACE_ORG2_TMPDIR}/body${i}.json" </dev/null > "${RACE_ORG2_TMPDIR}/c${i}" & +done +wait + +SUCCESS2=0 +CODES2="" +for i in $(seq 1 "${CONCURRENCY}"); do + CODE=$(cat "${RACE_ORG2_TMPDIR}/c${i}" 2>/dev/null || echo "ERR") + CODES2="${CODES2} ${CODE}" + if [[ "${CODE}" == "200" || "${CODE}" == "201" ]]; then + SUCCESS2=$((SUCCESS2 + 1)) + fi +done + +echo " Status codes:${CODES2}" +echo " Successful (2xx): ${SUCCESS2} / ${CONCURRENCY}" +rm -rf "${RACE_ORG2_TMPDIR}" + +if [ "${SUCCESS2}" -gt 1 ]; then + finding "RACE CONDITION: ${SUCCESS2} orgs with the same name created -> TOCTOU in org name check" + VULN_COUNT=$((VULN_COUNT + 1)) +else + ok "Org name uniqueness held under concurrency (${SUCCESS2} succeeded)" +fi + +# --------------------------------------------------------------------------- +# TEST 3: Concurrent refresh token reuse (blacklist TOCTOU) +# --------------------------------------------------------------------------- +header "TEST 3: Concurrent Refresh Token Reuse" +info "Description: Login once, then send ${CONCURRENCY} concurrent refresh requests with the SAME refresh token." +info "Expected: only 1 should succeed. If >1 succeed, two valid sessions exist from one token." +info "Vulnerability: decode() and blacklist_token() are not atomic - window exists for parallel use" +sep + +TMP_LOGIN="$(mktemp)" +REFRESH_TOKEN="" +LOGIN_CODE=$(curl -s -o "${TMP_LOGIN}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}" \ + 2>/dev/null) || LOGIN_CODE="ERR" + +if [ "${LOGIN_CODE}" == "200" ]; then + REFRESH_TOKEN=$(python3 -c " +import sys, json +try: + d = json.load(open('${TMP_LOGIN}')) + t = d.get('refresh_token') or d.get('data', {}).get('refresh_token', '') + print(t) +except: + print('') +" 2>/dev/null) +fi +rm -f "${TMP_LOGIN}" + +if [ -z "${REFRESH_TOKEN}" ]; then + warn "Could not obtain refresh token - skipping test 3" +else + info "Refresh token obtained: ${REFRESH_TOKEN:0:40}..." + + TMP_REFRESH_DIR="${SNAPSHOT_DIR}/tmp_refresh_${TIMESTAMP}" + mkdir -p "${TMP_REFRESH_DIR}" + + for i in $(seq 1 "${CONCURRENCY}"); do + curl -s -o "${TMP_REFRESH_DIR}/r${i}" -w "%{http_code}" --max-time 10 \ + -X POST "${API}/auth/refresh" \ + -H "Content-Type: application/json" \ + -d "{\"refresh_token\":\"${REFRESH_TOKEN}\"}" \ + 2>/dev/null > "${TMP_REFRESH_DIR}/c${i}" & + done + wait + + REFRESH_SUCCESS=0 + REFRESH_CODES="" + for i in $(seq 1 "${CONCURRENCY}"); do + CODE=$(cat "${TMP_REFRESH_DIR}/c${i}" 2>/dev/null || echo "ERR") + REFRESH_CODES="${REFRESH_CODES} ${CODE}" + if [ "${CODE}" == "200" ]; then + REFRESH_SUCCESS=$((REFRESH_SUCCESS + 1)) + fi + done + + echo " Status codes:${REFRESH_CODES}" + echo " Successful refreshes: ${REFRESH_SUCCESS} / ${CONCURRENCY}" + rm -rf "${TMP_REFRESH_DIR}" + + if [ "${REFRESH_SUCCESS}" -gt 1 ]; then + finding "RACE CONDITION: ${REFRESH_SUCCESS} concurrent refreshes succeeded with the same token" + finding "Multiple valid sessions can be minted from a single refresh token -> session cloning" + VULN_COUNT=$((VULN_COUNT + 1)) + elif [ "${REFRESH_SUCCESS}" -eq 1 ]; then + ok "Only 1 refresh succeeded - token blacklist is race-safe (or Redis serializes)" + else + warn "No refreshes succeeded - all rejected (possibly first request won and blacklisted it)" + ok "Effective protection: at most one new session from token (0 success = all rejected correctly too)" + fi +fi + +# --------------------------------------------------------------------------- +# TEST 4: Rate limit burst bypass via concurrent requests +# --------------------------------------------------------------------------- +header "TEST 4: Rate Limit Burst via Concurrent Requests" +info "Description: Fire ${CONCURRENCY} simultaneous login requests." +info "Rack::Attack uses counters; concurrent requests may all read 'below limit' before any increments." +info "Expected: all should succeed if under limit, but demonstrates the window" +sep + +TMP_BURST_DIR="${SNAPSHOT_DIR}/tmp_burst_${TIMESTAMP}" +mkdir -p "${TMP_BURST_DIR}" + +# Use invalid password to avoid blacklisting; we're testing the rate counter, not auth +BAD_BODY="{\"email\":\"${TEST_EMAIL}\",\"password\":\"WRONG_PASSWORD_PENTEST\"}" + +for i in $(seq 1 20); do + curl -s -o "${TMP_BURST_DIR}/r${i}" -w "%{http_code}" --max-time 10 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "${BAD_BODY}" \ + 2>/dev/null > "${TMP_BURST_DIR}/c${i}" & +done +wait + +RATE_429=0 +RATE_401=0 +RATE_OTHER=0 +for i in $(seq 1 20); do + CODE=$(cat "${TMP_BURST_DIR}/c${i}" 2>/dev/null || echo "ERR") + case "${CODE}" in + 429) RATE_429=$((RATE_429 + 1)) ;; + 401) RATE_401=$((RATE_401 + 1)) ;; + *) RATE_OTHER=$((RATE_OTHER + 1)) ;; + esac +done + +echo " 429 (rate limited): ${RATE_429}" +echo " 401 (bad creds): ${RATE_401}" +echo " Other: ${RATE_OTHER}" +rm -rf "${TMP_BURST_DIR}" + +info "If limit is e.g. 10/min and all 20 got 401, Rack::Attack may not be counting concurrent hits" +if [ "${RATE_429}" -gt 0 ]; then + ok "Rack::Attack triggered ${RATE_429} rate limit responses under concurrent burst" +else + warn "No 429s seen in 20 concurrent requests - verify Rack::Attack config for login endpoint" +fi + +# --------------------------------------------------------------------------- +# SUMMARY +# --------------------------------------------------------------------------- +header "SUMMARY" +sep +echo "Vulnerabilities found: ${VULN_COUNT}" +sep +if [ "${VULN_COUNT}" -gt 0 ]; then + finding "RACE CONDITIONS DETECTED - review concurrency control in affected endpoints" +else + ok "No exploitable race conditions found in this test run" + info "Note: race windows are probabilistic - run multiple times or increase CONCURRENCY to raise detection confidence" +fi +echo "" +echo "Output saved to: ${OUTPUT_FILE}" + +# Flush stdout so tee finishes writing before the process exits +exec 1>&- +exec 2>&- +[ -n "${TEE_PID}" ] && wait "${TEE_PID}" 2>/dev/null || true diff --git a/.pentest/scripts/23_token_rotation.sh b/.pentest/scripts/23_token_rotation.sh new file mode 100644 index 0000000..9b73665 --- /dev/null +++ b/.pentest/scripts/23_token_rotation.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash +# ============================================================================= +# 23_token_rotation.sh - Refresh token lifecycle and rotation security +# +# Purpose: Verify refresh token security properties: +# 1. Refresh token is single-use (invalidated after first use) +# 2. Old access token still works after refresh (expected - not blacklisted) +# 3. Refresh token rejected after logout (logout only blacklists access token) +# 4. Access token rejected after logout (blacklist enforced) +# 5. Type confusion: access token used as refresh token +# 6. Type confusion: refresh token used as access token +# 7. Refresh token replay after rotation (old refresh token reuse) +# +# Usage: +# bash 23_token_rotation.sh +# +# Output: ../snapshots/token_rotation_TIMESTAMP.txt +# ============================================================================= + +set -euo pipefail + +API="http://localhost:3333/api/v1" +TEST_EMAIL="test@prostaff.gg" +TEST_PASSWORD="Test123!@#" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="/home/bullet/PROJETOS/prostaff-api/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/token_rotation_${TIMESTAMP}.txt" +TARGET="${API}/dashboard" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +finding() { echo -e "${RED}[!!]${RESET} $*"; } +info() { echo -e "${CYAN}[*]${RESET} $*"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*"; } +header() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}\n"; } +sep() { echo "--------------------------------------------------------------------------------"; } + +mkdir -p "${SNAPSHOT_DIR}" +exec > >(tee -a "${OUTPUT_FILE}") 2>&1 + +VULN_COUNT=0 + +# --------------------------------------------------------------------------- +# Helper: login and extract tokens +# --------------------------------------------------------------------------- +do_login() { + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}" \ + 2>/dev/null) || code="ERR" + + if [ "${code}" != "200" ]; then + echo "" + echo "" + rm -f "${tmp}" + return 1 + fi + + python3 -c " +import sys, json +try: + d = json.load(open('${tmp}')) + at = d.get('access_token') or d.get('data', {}).get('access_token', '') + rt = d.get('refresh_token') or d.get('data', {}).get('refresh_token', '') + print(at) + print(rt) +except: + print('') + print('') +" 2>/dev/null + rm -f "${tmp}" +} + +# Helper: do a refresh and return new tokens (access\nrefresh) + http code +do_refresh() { + local refresh_token="$1" + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/refresh" \ + -H "Content-Type: application/json" \ + -d "{\"refresh_token\":\"${refresh_token}\"}" \ + 2>/dev/null) || code="ERR" + + local at="" + local rt="" + if [ "${code}" == "200" ]; then + at=$(python3 -c " +import json +try: + d = json.load(open('${tmp}')) + print(d.get('access_token') or d.get('data', {}).get('access_token', '')) +except: print('') +" 2>/dev/null) + rt=$(python3 -c " +import json +try: + d = json.load(open('${tmp}')) + print(d.get('refresh_token') or d.get('data', {}).get('refresh_token', '')) +except: print('') +" 2>/dev/null) + fi + + rm -f "${tmp}" + echo "${code}" + echo "${at}" + echo "${rt}" +} + +# Helper: test an access token against TARGET +test_access() { + local token="$1" + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 10 \ + -H "Authorization: Bearer ${token}" \ + "${TARGET}" \ + 2>/dev/null) || code="ERR" + rm -f "${tmp}" + echo "${code}" +} + +# Helper: logout with a given access token +do_logout() { + local access_token="$1" + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 10 \ + -X POST "${API}/auth/logout" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${access_token}" \ + 2>/dev/null) || code="ERR" + rm -f "${tmp}" + echo "${code}" +} + +# --------------------------------------------------------------------------- +# STEP 0: Initial login +# --------------------------------------------------------------------------- +header "STEP 0: Initial Login" + +TOKENS=$(do_login) || { finding "Login failed - is the API running?"; exit 1; } +ACCESS_TOKEN=$(echo "${TOKENS}" | sed -n '1p') +REFRESH_TOKEN=$(echo "${TOKENS}" | sed -n '2p') + +if [ -z "${ACCESS_TOKEN}" ] || [ -z "${REFRESH_TOKEN}" ]; then + finding "Failed to parse tokens from login response" + exit 1 +fi + +ok "Access token: ${ACCESS_TOKEN:0:50}..." +ok "Refresh token: ${REFRESH_TOKEN:0:50}..." + +# Verify initial access +INITIAL_STATUS=$(test_access "${ACCESS_TOKEN}") +if [ "${INITIAL_STATUS}" == "200" ]; then + ok "Initial access token works (HTTP 200)" +else + warn "Initial access token returned ${INITIAL_STATUS} - unexpected" +fi + +# --------------------------------------------------------------------------- +# TEST 1: Refresh token is single-use +# --------------------------------------------------------------------------- +header "TEST 1: Refresh Token is Single-Use" +info "Use refresh token once, then try to use it again" +sep + +REFRESH_RESULT=$(do_refresh "${REFRESH_TOKEN}") +REFRESH1_CODE=$(echo "${REFRESH_RESULT}" | sed -n '1p') +NEW_ACCESS=$(echo "${REFRESH_RESULT}" | sed -n '2p') +NEW_REFRESH=$(echo "${REFRESH_RESULT}" | sed -n '3p') + +echo "First refresh: HTTP ${REFRESH1_CODE}" + +if [ "${REFRESH1_CODE}" == "200" ]; then + ok "First refresh succeeded (HTTP 200)" + ok "New access token: ${NEW_ACCESS:0:50}..." + ok "New refresh token: ${NEW_REFRESH:0:50}..." + + # Replay the old refresh token + sleep 0.5 + REFRESH2_RESULT=$(do_refresh "${REFRESH_TOKEN}") + REFRESH2_CODE=$(echo "${REFRESH2_RESULT}" | sed -n '1p') + + echo "Replay old refresh token: HTTP ${REFRESH2_CODE}" + + if [ "${REFRESH2_CODE}" == "200" ]; then + finding "VULNERABILITY: Old refresh token accepted after rotation -> token is NOT single-use" + finding "Attacker who captured the original refresh token can now generate sessions indefinitely" + VULN_COUNT=$((VULN_COUNT + 1)) + elif [ "${REFRESH2_CODE}" == "401" ]; then + ok "Old refresh token rejected (HTTP 401) - rotation is enforced correctly" + else + warn "Unexpected status ${REFRESH2_CODE} on replay - investigate" + fi +else + warn "First refresh returned ${REFRESH1_CODE} - expected 200, skipping replay test" +fi + +# --------------------------------------------------------------------------- +# TEST 2: Old access token valid after refresh (expected behavior) +# --------------------------------------------------------------------------- +header "TEST 2: Old Access Token Validity After Refresh" +info "Access tokens are NOT blacklisted on refresh - they expire naturally" +info "This is expected behavior (by design in JwtService) - documenting for awareness" +sep + +OLD_ACCESS_STATUS=$(test_access "${ACCESS_TOKEN}") +echo "Old access token after refresh: HTTP ${OLD_ACCESS_STATUS}" + +if [ "${OLD_ACCESS_STATUS}" == "200" ]; then + warn "Old access token still valid after refresh (expected - access tokens not blacklisted on rotation)" + warn "Implication: if access token is captured, it remains valid until expiry even after rotation" + info "Mitigation: short access token TTL (current: EXPIRATION_HOURS from env)" +else + ok "Old access token rejected after refresh (HTTP ${OLD_ACCESS_STATUS})" +fi + +# --------------------------------------------------------------------------- +# TEST 3: Refresh token rejected after logout +# --------------------------------------------------------------------------- +header "TEST 3: Refresh Token Rejected After Logout" +info "Logout only blacklists the access token - test if refresh token also becomes invalid" +sep + +# Get a fresh session for this test +TOKENS3=$(do_login) || { warn "Could not get fresh tokens for test 3"; TOKENS3=""; } + +if [ -n "${TOKENS3}" ]; then + ACCESS3=$(echo "${TOKENS3}" | sed -n '1p') + REFRESH3=$(echo "${TOKENS3}" | sed -n '2p') + + LOGOUT_CODE=$(do_logout "${ACCESS3}") + echo "Logout: HTTP ${LOGOUT_CODE}" + + if [ "${LOGOUT_CODE}" == "200" ]; then + ok "Logout succeeded" + + # Test: can we still use the refresh token after logout? + REFRESH3_RESULT=$(do_refresh "${REFRESH3}") + REFRESH3_CODE=$(echo "${REFRESH3_RESULT}" | sed -n '1p') + echo "Refresh after logout: HTTP ${REFRESH3_CODE}" + + if [ "${REFRESH3_CODE}" == "200" ]; then + finding "VULNERABILITY: Refresh token still valid after logout" + finding "Attacker who captured the refresh token can re-establish session after victim logs out" + finding "Root cause: logout only blacklists the access token JTI, not the refresh token JTI" + VULN_COUNT=$((VULN_COUNT + 1)) + elif [ "${REFRESH3_CODE}" == "401" ]; then + ok "Refresh token correctly rejected after logout (HTTP 401)" + else + warn "Unexpected status ${REFRESH3_CODE} on post-logout refresh" + fi + else + warn "Logout failed with HTTP ${LOGOUT_CODE} - skipping post-logout refresh test" + fi +fi + +# --------------------------------------------------------------------------- +# TEST 4: Access token rejected after logout +# --------------------------------------------------------------------------- +header "TEST 4: Access Token Blacklisted After Logout" +info "Core logout guarantee: the access token used to log out must be invalidated" +sep + +TOKENS4=$(do_login) || { warn "Could not get fresh tokens for test 4"; TOKENS4=""; } + +if [ -n "${TOKENS4}" ]; then + ACCESS4=$(echo "${TOKENS4}" | sed -n '1p') + + # Confirm it works before logout + PRE_STATUS=$(test_access "${ACCESS4}") + echo "Pre-logout access: HTTP ${PRE_STATUS}" + + LOGOUT4_CODE=$(do_logout "${ACCESS4}") + echo "Logout: HTTP ${LOGOUT4_CODE}" + + # Confirm it is rejected after logout + POST_STATUS=$(test_access "${ACCESS4}") + echo "Post-logout access: HTTP ${POST_STATUS}" + + if [ "${POST_STATUS}" == "200" ]; then + finding "VULNERABILITY: Access token still valid after logout (HTTP 200)" + finding "Token blacklist not working - logout provides no security guarantee" + VULN_COUNT=$((VULN_COUNT + 1)) + elif [ "${POST_STATUS}" == "401" ]; then + ok "Access token correctly rejected after logout (HTTP 401)" + else + warn "Unexpected status ${POST_STATUS} after logout" + fi +fi + +# --------------------------------------------------------------------------- +# TEST 5: Type confusion - access token used as refresh token +# --------------------------------------------------------------------------- +header "TEST 5: Type Confusion - Access Token as Refresh Token" +info "Access token has type:'access' - should be rejected by /auth/refresh" +sep + +TOKENS5=$(do_login) || { warn "Could not get tokens for test 5"; TOKENS5=""; } + +if [ -n "${TOKENS5}" ]; then + ACCESS5=$(echo "${TOKENS5}" | sed -n '1p') + + REFRESH5_RESULT=$(do_refresh "${ACCESS5}") + REFRESH5_CODE=$(echo "${REFRESH5_RESULT}" | sed -n '1p') + echo "Refresh with access token: HTTP ${REFRESH5_CODE}" + + if [ "${REFRESH5_CODE}" == "200" ]; then + finding "VULNERABILITY: Access token accepted as refresh token" + finding "Type field in JWT payload is not enforced - any valid token can be used as refresh" + VULN_COUNT=$((VULN_COUNT + 1)) + else + ok "Access token correctly rejected as refresh token (HTTP ${REFRESH5_CODE})" + fi +fi + +# --------------------------------------------------------------------------- +# TEST 6: Type confusion - refresh token used as access token +# --------------------------------------------------------------------------- +header "TEST 6: Type Confusion - Refresh Token as Access Token" +info "Refresh token has type:'refresh' - should be rejected by authenticated endpoints" +sep + +TOKENS6=$(do_login) || { warn "Could not get tokens for test 6"; TOKENS6=""; } + +if [ -n "${TOKENS6}" ]; then + REFRESH6=$(echo "${TOKENS6}" | sed -n '2p') + + STATUS6=$(test_access "${REFRESH6}") + echo "Dashboard with refresh token: HTTP ${STATUS6}" + + if [ "${STATUS6}" == "200" ]; then + finding "VULNERABILITY: Refresh token accepted as access token" + finding "The authenticate_request! filter does not check the 'type' claim in the JWT payload" + finding "Attacker who intercepts a refresh token gains full API access" + VULN_COUNT=$((VULN_COUNT + 1)) + else + ok "Refresh token correctly rejected on authenticated endpoint (HTTP ${STATUS6})" + fi +fi + +# --------------------------------------------------------------------------- +# TEST 7: Refresh chain replay - use new refresh token, then replay the first one +# --------------------------------------------------------------------------- +header "TEST 7: Refresh Chain - Multi-Rotation Replay" +info "Rotate the token twice. Try to replay the generation-1 refresh token after gen-2 exists." +sep + +TOKENS7=$(do_login) || { warn "Could not get tokens for test 7"; TOKENS7=""; } + +if [ -n "${TOKENS7}" ]; then + REFRESH7=$(echo "${TOKENS7}" | sed -n '2p') + + # First rotation + RES7A=$(do_refresh "${REFRESH7}") + CODE7A=$(echo "${RES7A}" | sed -n '1p') + REFRESH7B=$(echo "${RES7A}" | sed -n '3p') + echo "First rotation: HTTP ${CODE7A}" + + if [ "${CODE7A}" == "200" ] && [ -n "${REFRESH7B}" ]; then + # Second rotation + RES7B=$(do_refresh "${REFRESH7B}") + CODE7B=$(echo "${RES7B}" | sed -n '1p') + echo "Second rotation: HTTP ${CODE7B}" + + # Replay the original generation-1 refresh token + RES7C=$(do_refresh "${REFRESH7}") + CODE7C=$(echo "${RES7C}" | sed -n '1p') + echo "Replay gen-1 refresh token: HTTP ${CODE7C}" + + if [ "${CODE7C}" == "200" ]; then + finding "VULNERABILITY: Generation-1 refresh token valid after two rotations" + finding "Token family invalidation not implemented - old refresh tokens survive chain" + VULN_COUNT=$((VULN_COUNT + 1)) + else + ok "Generation-1 refresh token correctly rejected (HTTP ${CODE7C})" + fi + else + warn "Could not complete token chain for test 7" + fi +fi + +# --------------------------------------------------------------------------- +# SUMMARY +# --------------------------------------------------------------------------- +header "SUMMARY" +sep +echo "Vulnerabilities found: ${VULN_COUNT}" +sep +if [ "${VULN_COUNT}" -gt 0 ]; then + finding "TOKEN ROTATION ISSUES DETECTED" +else + ok "Token rotation security checks passed" +fi +echo "" +echo "Output saved to: ${OUTPUT_FILE}" diff --git a/.pentest/scripts/24_host_header.sh b/.pentest/scripts/24_host_header.sh new file mode 100644 index 0000000..5b0f1bc --- /dev/null +++ b/.pentest/scripts/24_host_header.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# ============================================================================= +# 24_host_header.sh - Host header injection +# +# Purpose: Test if the API uses the HTTP Host header in any way that an +# attacker can manipulate β€” specifically in password reset link generation. +# +# 1. Baseline: confirm /auth/forgot-password works +# 2. X-Forwarded-Host injection (most common vector) +# 3. X-Forwarded-For injection (rate limit bypass) +# 4. Duplicate Host headers (parsing confusion) +# 5. Rails config.hosts enforcement (non-whitelisted host) +# 6. Absolute URI in request line (proxy behavior) +# 7. Password reset link generation with spoofed host +# +# Note: Tests 1-6 are non-destructive. Test 7 only calls forgot-password with +# a non-existent email - no real reset token is generated. +# +# Usage: +# bash 24_host_header.sh +# +# Output: ../snapshots/host_header_TIMESTAMP.txt +# ============================================================================= + +set -euo pipefail + +API="http://localhost:3333/api/v1" +BASE_URL="http://localhost:3333" +TEST_EMAIL="test@prostaff.gg" +TEST_PASSWORD="Test123!@#" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="/home/bullet/PROJETOS/prostaff-api/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/host_header_${TIMESTAMP}.txt" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +finding() { echo -e "${RED}[!!]${RESET} $*"; } +info() { echo -e "${CYAN}[*]${RESET} $*"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*"; } +header() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}\n"; } +sep() { echo "--------------------------------------------------------------------------------"; } + +mkdir -p "${SNAPSHOT_DIR}" +exec > >(tee -a "${OUTPUT_FILE}") 2>&1 + +VULN_COUNT=0 +TOKEN="" + +# --------------------------------------------------------------------------- +# Helper: get token +# --------------------------------------------------------------------------- +get_token() { + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}" \ + 2>/dev/null) || code="ERR" + if [ "${code}" == "200" ]; then + TOKEN=$(python3 -c " +import json +try: + d = json.load(open('${tmp}')) + print(d.get('access_token') or d.get('data', {}).get('access_token', '')) +except: print('') +" 2>/dev/null) + fi + rm -f "${tmp}" +} + +# Helper: send request and show full response headers + body +# Display goes to stderr; only the HTTP code goes to stdout +probe() { + local label="$1" + shift + { + echo "" + sep + echo "TEST: ${label}" + sep + } >&2 + + local tmp_body tmp_headers + tmp_body="$(mktemp)" + tmp_headers="$(mktemp)" + + local http_code + http_code=$(curl -s \ + -D "${tmp_headers}" \ + -o "${tmp_body}" \ + -w "%{http_code}" \ + --max-time 10 \ + "$@" \ + 2>/dev/null) || http_code="CURL_ERR" + + { + echo "HTTP Status: ${http_code}" + echo "--- Response Headers ---" + cat "${tmp_headers}" + echo "--- Response Body ---" + python3 -m json.tool 2>/dev/null < "${tmp_body}" || cat "${tmp_body}" + echo "" + } >&2 + + rm -f "${tmp_body}" "${tmp_headers}" + echo "${http_code}" +} + +# --------------------------------------------------------------------------- +# STEP 0: Authenticate +# --------------------------------------------------------------------------- +header "STEP 0: Obtaining Token" +get_token +if [ -z "${TOKEN}" ]; then + finding "Could not authenticate - is the API running?" + exit 1 +fi +ok "Token acquired: ${TOKEN:0:40}..." + +# --------------------------------------------------------------------------- +# TEST 1: Baseline - normal forgot-password request +# --------------------------------------------------------------------------- +header "TEST 1: Baseline - Normal Forgot-Password" +info "Confirm the endpoint works with a non-existent email (always returns 200 by design)" + +BASELINE_CODE=$(probe "baseline forgot-password" \ + -X POST "${API}/auth/forgot-password" \ + -H "Content-Type: application/json" \ + -d '{"email":"nonexistent_pentest@invalid.test"}') + +if [ "${BASELINE_CODE}" == "200" ]; then + ok "Endpoint returns 200 (prevents email enumeration - correct behavior)" +else + warn "Unexpected baseline status: ${BASELINE_CODE}" +fi + +# --------------------------------------------------------------------------- +# TEST 2: X-Forwarded-Host injection +# --------------------------------------------------------------------------- +header "TEST 2: X-Forwarded-Host Injection" +info "If the app uses request.host to build the password reset URL, this header can redirect the link" +info "Attack: victim clicks reset link, token goes to attacker's server" +sep + +ATTACKER_HOST="evil.attacker.com" + +XFH_CODE=$(probe "X-Forwarded-Host injection" \ + -X POST "${API}/auth/forgot-password" \ + -H "Content-Type: application/json" \ + -H "X-Forwarded-Host: ${ATTACKER_HOST}" \ + -d "{\"email\":\"${TEST_EMAIL}\"}") + +echo "X-Forwarded-Host: ${ATTACKER_HOST} -> HTTP ${XFH_CODE}" + +if [ "${XFH_CODE}" == "200" ]; then + info "Request accepted with X-Forwarded-Host: ${ATTACKER_HOST}" + warn "Check application logs/emails to verify if ${ATTACKER_HOST} appears in the reset link" + warn "If reset email URL contains ${ATTACKER_HOST}, this is a critical vulnerability" +else + ok "Request rejected (HTTP ${XFH_CODE}) with spoofed X-Forwarded-Host" +fi + +# --------------------------------------------------------------------------- +# TEST 3: Host header override +# --------------------------------------------------------------------------- +header "TEST 3: Host Header Override" +info "Overriding the Host header directly (works when curl bypasses OS resolver)" +sep + +HOST_CODE=$(probe "Host header override to attacker domain" \ + -X POST "${API}/auth/forgot-password" \ + -H "Content-Type: application/json" \ + -H "Host: evil.attacker.com" \ + -d '{"email":"nonexistent_pentest@invalid.test"}') + +echo "Host: evil.attacker.com -> HTTP ${HOST_CODE}" + +if [ "${HOST_CODE}" == "200" ]; then + info "Request accepted with overridden Host header" + warn "If Rails config.hosts is not set, this request is processed without validation" +elif [ "${HOST_CODE}" == "403" ] || [ "${HOST_CODE}" == "400" ]; then + ok "Request blocked (HTTP ${HOST_CODE}) - Rails config.hosts is enforcing allowed hosts" +else + warn "Unexpected status ${HOST_CODE}" +fi + +# --------------------------------------------------------------------------- +# TEST 4: X-Original-Host / X-Real-Host headers +# --------------------------------------------------------------------------- +header "TEST 4: Alternative Forwarding Headers" +info "Some reverse proxies pass X-Original-Host or X-Real-Host; check if Rails trusts them" +sep + +XOH_CODE=$(probe "X-Original-Host injection" \ + -X POST "${API}/auth/forgot-password" \ + -H "Content-Type: application/json" \ + -H "X-Original-Host: evil.attacker.com" \ + -d '{"email":"nonexistent_pentest@invalid.test"}') + +echo "X-Original-Host: evil.attacker.com -> HTTP ${XOH_CODE}" + +XRH_CODE=$(probe "X-Real-Host injection" \ + -X POST "${API}/auth/forgot-password" \ + -H "Content-Type: application/json" \ + -H "X-Real-Host: evil.attacker.com" \ + -d '{"email":"nonexistent_pentest@invalid.test"}') + +echo "X-Real-Host: evil.attacker.com -> HTTP ${XRH_CODE}" + +# --------------------------------------------------------------------------- +# TEST 5: Rails config.hosts enforcement +# --------------------------------------------------------------------------- +header "TEST 5: Rails config.hosts Enforcement" +info "Rails 6+ has config.hosts - check if it's configured to reject unknown hosts" +info "Test: make a legitimate request with a completely unknown host" +sep + +CONFIG_HOSTS_CODE=$(probe "Unknown host in Host header" \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -H "Host: unknown-random-host-pentest.xyz" \ + -d '{"email":"nobody@test.com","password":"anything"}') + +echo "Unknown Host header -> HTTP ${CONFIG_HOSTS_CODE}" + +if [ "${CONFIG_HOSTS_CODE}" == "403" ] || [ "${CONFIG_HOSTS_CODE}" == "400" ]; then + ok "Rails config.hosts is active - unknown host blocked (HTTP ${CONFIG_HOSTS_CODE})" +elif [ "${CONFIG_HOSTS_CODE}" == "401" ] || [ "${CONFIG_HOSTS_CODE}" == "200" ]; then + warn "Request processed with unknown Host header (HTTP ${CONFIG_HOSTS_CODE})" + warn "Rails config.hosts may not be configured - Host header injection possible in password reset" + VULN_COUNT=$((VULN_COUNT + 1)) +else + info "Response: HTTP ${CONFIG_HOSTS_CODE} - verify manually" +fi + +# --------------------------------------------------------------------------- +# TEST 6: X-Forwarded-For rate limit bypass +# --------------------------------------------------------------------------- +header "TEST 6: X-Forwarded-For Rate Limit Bypass" +info "Rack::Attack uses REMOTE_ADDR by default" +info "If configured to trust X-Forwarded-For (common in proxied setups), attacker can spoof IP per request" +sep + +RATE_CODES="" +for i in $(seq 1 12); do + FAKE_IP="10.100.${i}.${i}" + TMP="$(mktemp)" + CODE=$(curl -s -o "${TMP}" -w "%{http_code}" --max-time 5 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -H "X-Forwarded-For: ${FAKE_IP}" \ + -H "X-Real-IP: ${FAKE_IP}" \ + -d '{"email":"nobody_pentest@invalid.com","password":"WRONG"}' \ + 2>/dev/null) || CODE="ERR" + RATE_CODES="${RATE_CODES} ${CODE}" + rm -f "${TMP}" +done + +echo " Status codes (12 requests with different X-Forwarded-For IPs):${RATE_CODES}" + +RATE_429_COUNT=0 +for code in ${RATE_CODES}; do + [ "${code}" == "429" ] && RATE_429_COUNT=$((RATE_429_COUNT + 1)) +done + +if [ "${RATE_429_COUNT}" -gt 0 ]; then + ok "Rate limiting triggered ${RATE_429_COUNT}/12 times - not bypassed by X-Forwarded-For spoofing" +else + info "No 429s seen with spoofed X-Forwarded-For IPs" + info "Either: (a) under the per-IP rate limit threshold, or (b) Rack::Attack trusts X-Forwarded-For" + info "Test further: run the full rate limit script (06_rate_limit_probe.sh) with X-Forwarded-For" +fi + +# --------------------------------------------------------------------------- +# TEST 7: Authenticated endpoints with spoofed Host header +# --------------------------------------------------------------------------- +header "TEST 7: Authenticated Endpoints with Spoofed Host" +info "If CORS or host validation happens at the app level (not Rails config.hosts), test auth endpoints" +sep + +AUTH_HOST_CODE=$(probe "Authenticated endpoint with spoofed host" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Host: evil.attacker.com" \ + "${API}/dashboard") + +echo "Dashboard with Host: evil.attacker.com -> HTTP ${AUTH_HOST_CODE}" + +if [ "${AUTH_HOST_CODE}" == "200" ]; then + warn "Authenticated request processed with spoofed Host header" + warn "Not a direct vuln here, but confirms config.hosts is not blocking all spoofed hosts" +elif [ "${AUTH_HOST_CODE}" == "403" ] || [ "${AUTH_HOST_CODE}" == "400" ]; then + ok "Spoofed Host blocked on authenticated endpoint too (HTTP ${AUTH_HOST_CODE})" +fi + +# --------------------------------------------------------------------------- +# SUMMARY +# --------------------------------------------------------------------------- +header "SUMMARY" +sep +echo "Vulnerabilities found: ${VULN_COUNT}" +sep + +info "Key findings to verify manually:" +info " 1. Check application logs after test 2 - does X-Forwarded-Host appear in generated URLs?" +info " 2. If using a reverse proxy in production, verify proxy strips X-Forwarded-Host before forwarding" +info " 3. Confirm ActionMailer.default_url_options uses a hardcoded host, not request.host" + +if [ "${VULN_COUNT}" -gt 0 ]; then + finding "HOST HEADER ISSUES DETECTED - Rails config.hosts may not be enforcing allowed hosts" +else + ok "No clear host header injection vectors found (verify manually for email link generation)" +fi +echo "" +echo "Output saved to: ${OUTPUT_FILE}" diff --git a/.pentest/scripts/25_mass_assignment.sh b/.pentest/scripts/25_mass_assignment.sh new file mode 100644 index 0000000..bfa4fed --- /dev/null +++ b/.pentest/scripts/25_mass_assignment.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# ============================================================================= +# 25_mass_assignment.sh - Mass assignment / field injection +# +# Purpose: Test whether sensitive fields can be written by sending them +# in request bodies despite not being in the Strong Parameters whitelist. +# +# 1. Register with elevated role (role: 'admin'/'owner') +# 2. Register with organization_id injected in user params +# 3. Register with plan/tier escalation +# 4. Profile update with role escalation (PATCH /profile) +# 5. Profile update with organization_id override +# 6. Player create with riot_puuid / riot_summoner_id injection +# 7. Player create with organization_id from another org +# 8. Player update with organization_id override +# 9. Organization update with plan/tier escalation +# 10. Nested attribute injection (notification_preferences with extra keys) +# +# Usage: +# bash 25_mass_assignment.sh +# +# Output: ../snapshots/mass_assignment_TIMESTAMP.txt +# ============================================================================= + +set -euo pipefail + +API="http://localhost:3333/api/v1" +TEST_EMAIL="test@prostaff.gg" +TEST_PASSWORD="Test123!@#" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="/home/bullet/PROJETOS/prostaff-api/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/mass_assignment_${TIMESTAMP}.txt" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +finding() { echo -e "${RED}[!!]${RESET} $*"; } +info() { echo -e "${CYAN}[*]${RESET} $*"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*"; } +header() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}\n"; } +sep() { echo "--------------------------------------------------------------------------------"; } + +mkdir -p "${SNAPSHOT_DIR}" +exec > >(tee -a "${OUTPUT_FILE}") 2>&1 + +VULN_COUNT=0 +TOKEN="" +ORG_ID="" +USER_ID="" +PLAYER_ID="" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +get_token() { + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}" \ + 2>/dev/null) || code="ERR" + + if [ "${code}" == "200" ]; then + TOKEN=$(python3 -c " +import json +try: + d = json.load(open('${tmp}')) + print(d.get('access_token') or d.get('data',{}).get('access_token','')) +except: print('') +" 2>/dev/null) + ORG_ID=$(python3 -c " +import json +try: + d = json.load(open('${tmp}')) + u = d.get('user') or d.get('data',{}).get('user',{}) + print(u.get('organization_id','')) +except: print('') +" 2>/dev/null) + USER_ID=$(python3 -c " +import json +try: + d = json.load(open('${tmp}')) + u = d.get('user') or d.get('data',{}).get('user',{}) + print(u.get('id','')) +except: print('') +" 2>/dev/null) + fi + rm -f "${tmp}" +} + +# Send POST/PATCH and check if a given field appears in the response with the injected value +probe_field() { + local method="$1" + local url="$2" + local body="$3" + local check_field="$4" + local check_value="$5" + local label="$6" + local auth="${7:-}" + + echo "" + sep + echo "TEST: ${label}" + echo "Method: ${method} ${url}" + echo "Injected field: ${check_field} = ${check_value}" + sep + + local tmp + tmp="$(mktemp)" + local http_code + local curl_args=("-s" "-o" "${tmp}" "-w" "%{http_code}" "--max-time" "15" + "-X" "${method}" "${url}" + "-H" "Content-Type: application/json" + "-d" "${body}") + + if [ -n "${auth}" ]; then + curl_args+=("-H" "Authorization: Bearer ${auth}") + fi + + http_code=$(curl "${curl_args[@]}" 2>/dev/null) || http_code="ERR" + + echo "HTTP Status: ${http_code}" + local response + response=$(cat "${tmp}") + python3 -m json.tool 2>/dev/null <<< "${response}" || echo "${response}" + rm -f "${tmp}" + + # Check if the injected value appears in the response + if echo "${response}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + + def find_val(obj, key, val): + if isinstance(obj, dict): + for k, v in obj.items(): + if str(k).lower() == key.lower() and str(v).lower() == str(val).lower(): + return True + if find_val(v, key, val): + return True + elif isinstance(obj, list): + for item in obj: + if find_val(item, key, val): + return True + return False + + sys.exit(0 if find_val(d, '${check_field}', '${check_value}') else 1) +except: + sys.exit(1) +" 2>/dev/null; then + finding "MASS ASSIGNMENT: '${check_field}' = '${check_value}' reflected in response" + finding "Field was accepted and stored despite not being in the expected permit list" + VULN_COUNT=$((VULN_COUNT + 1)) + else + if [ "${http_code}" == "200" ] || [ "${http_code}" == "201" ]; then + ok "Request succeeded but injected field NOT reflected - Strong Parameters filtered it" + else + ok "Request rejected (HTTP ${http_code}) - field not accepted" + fi + fi +} + +# --------------------------------------------------------------------------- +# STEP 0: Authenticate +# --------------------------------------------------------------------------- +header "STEP 0: Authenticating" +get_token +if [ -z "${TOKEN}" ]; then + finding "Could not authenticate - is the API running?" + exit 1 +fi +ok "Token: ${TOKEN:0:40}..." +ok "Org ID: ${ORG_ID}" +ok "User ID: ${USER_ID}" + +# Get the first player ID +TMP_PLAYERS="$(mktemp)" +curl -s -o "${TMP_PLAYERS}" --max-time 10 \ + -H "Authorization: Bearer ${TOKEN}" \ + "${API}/players?per_page=1" 2>/dev/null || true + +PLAYER_ID=$(python3 -c " +import json +try: + d = json.load(open('${TMP_PLAYERS}')) + players = (d.get('data') or {}).get('players') or d.get('players') or [] + if players: print(players[0].get('id','')) + else: print('') +except: print('') +" 2>/dev/null) +rm -f "${TMP_PLAYERS}" +info "Player ID for tests: ${PLAYER_ID:-none found}" + +# --------------------------------------------------------------------------- +# TEST 1: Registration with elevated role +# --------------------------------------------------------------------------- +header "TEST 1: Registration with Role Escalation" +info "Inject role:'admin' and role:'owner' in user params at registration" +info "user_params permits: email, password, full_name, timezone, language" + +for INJECTED_ROLE in "admin" "owner" "superadmin"; do + BODY="{ + \"user\": { + \"email\": \"mass_assign_role_${INJECTED_ROLE}_${TIMESTAMP}@pentest.invalid\", + \"password\": \"PentestPass1!\", + \"full_name\": \"Role Inject\", + \"role\": \"${INJECTED_ROLE}\" + }, + \"organization\": {\"name\": \"RoleOrg_${INJECTED_ROLE}_${TIMESTAMP}\", \"region\": \"BR\"} + }" + probe_field "POST" "${API}/auth/register" "${BODY}" "role" "${INJECTED_ROLE}" \ + "Register with role=${INJECTED_ROLE}" +done + +# --------------------------------------------------------------------------- +# TEST 2: Registration with organization_id injection +# --------------------------------------------------------------------------- +header "TEST 2: Registration with organization_id Injection" +info "Inject organization_id in user params to be placed in an existing org" +info "This would bypass org creation flow and allow account hijacking" + +FAKE_ORG_ID=1 +BODY="{ + \"user\": { + \"email\": \"mass_assign_org_${TIMESTAMP}@pentest.invalid\", + \"password\": \"PentestPass1!\", + \"full_name\": \"Org Inject\", + \"organization_id\": ${FAKE_ORG_ID} + }, + \"organization\": {\"name\": \"OrgInjectTest_${TIMESTAMP}\", \"region\": \"BR\"} +}" +probe_field "POST" "${API}/auth/register" "${BODY}" "organization_id" "${FAKE_ORG_ID}" \ + "Register with organization_id=${FAKE_ORG_ID}" + +# --------------------------------------------------------------------------- +# TEST 3: Registration with plan/tier escalation +# --------------------------------------------------------------------------- +header "TEST 3: Registration with Plan/Tier Escalation" +info "organization_params permits: name, region, tier" +info "Inject: plan:'enterprise', subscription_status:'active', trial_ends_at:'2099-01-01'" + +BODY="{ + \"user\": {\"email\": \"mass_plan_${TIMESTAMP}@pentest.invalid\", \"password\": \"PentestPass1!\", \"full_name\": \"Plan Inject\"}, + \"organization\": { + \"name\": \"PlanOrg_${TIMESTAMP}\", + \"region\": \"BR\", + \"plan\": \"enterprise\", + \"subscription_status\": \"active\", + \"trial_ends_at\": \"2099-12-31\" + } +}" + +sep +echo "TEST: Register with plan escalation fields" +TMP="$(mktemp)" +CODE=$(curl -s -o "${TMP}" -w "%{http_code}" --max-time 15 \ + -X POST "${API}/auth/register" \ + -H "Content-Type: application/json" \ + -d "${BODY}" \ + 2>/dev/null) || CODE="ERR" +echo "HTTP Status: ${CODE}" +python3 -m json.tool 2>/dev/null < "${TMP}" || cat "${TMP}" +rm -f "${TMP}" + +# --------------------------------------------------------------------------- +# TEST 4: Profile update with role escalation +# --------------------------------------------------------------------------- +header "TEST 4: Profile Update - Role Escalation" +info "PATCH /profile - profile_params permits: full_name, email, avatar_url, timezone, language, discord_user_id" +info "Inject: role:'admin', role:'owner'" + +for INJECTED_ROLE in "admin" "owner"; do + BODY="{\"user\": {\"full_name\": \"Normal Update\", \"role\": \"${INJECTED_ROLE}\"}}" + probe_field "PATCH" "${API}/profile" "${BODY}" "role" "${INJECTED_ROLE}" \ + "Profile PATCH with role=${INJECTED_ROLE}" "${TOKEN}" +done + +# --------------------------------------------------------------------------- +# TEST 5: Profile update with organization_id override +# --------------------------------------------------------------------------- +header "TEST 5: Profile Update - Organization Override" +info "Inject organization_id in profile update to reassign user to another org" + +BODY="{\"user\": {\"full_name\": \"Normal Update\", \"organization_id\": 1}}" +probe_field "PATCH" "${API}/profile" "${BODY}" "organization_id" "1" \ + "Profile PATCH with organization_id=1" "${TOKEN}" + +# --------------------------------------------------------------------------- +# TEST 6: Player create with riot_puuid injection +# --------------------------------------------------------------------------- +header "TEST 6: Player Create - riot_puuid / riot_summoner_id Injection" +info "player_params EXPLICITLY excludes riot_puuid and riot_summoner_id" +info "These must only be updated via Riot sync service" + +BODY="{\"player\": { + \"summoner_name\": \"MassAssignPentestPlayer_${TIMESTAMP}\", + \"role\": \"top\", + \"region\": \"BR\", + \"riot_puuid\": \"injected-puuid-pentest-00000000000000000000\", + \"riot_summoner_id\": \"injected-summoner-id-pentest\" +}}" +probe_field "POST" "${API}/players" "${BODY}" "riot_puuid" "injected-puuid-pentest-00000000000000000000" \ + "Player create with riot_puuid injection" "${TOKEN}" + +# --------------------------------------------------------------------------- +# TEST 7: Player create with organization_id from another org +# --------------------------------------------------------------------------- +header "TEST 7: Player Create - organization_id from Another Org" +info "Inject organization_id = 1 to create a player in a different org" + +BODY="{\"player\": { + \"summoner_name\": \"OrgInjectPlayer_${TIMESTAMP}\", + \"role\": \"mid\", + \"region\": \"BR\", + \"organization_id\": 1 +}}" +probe_field "POST" "${API}/players" "${BODY}" "organization_id" "1" \ + "Player create with organization_id=1" "${TOKEN}" + +# --------------------------------------------------------------------------- +# TEST 8: Player update with organization_id override +# --------------------------------------------------------------------------- +header "TEST 8: Player Update - organization_id Override" +info "Inject organization_id in PATCH to move player to another org" + +if [ -n "${PLAYER_ID}" ]; then + BODY="{\"player\": {\"notes\": \"normal update\", \"organization_id\": 1}}" + probe_field "PATCH" "${API}/players/${PLAYER_ID}" "${BODY}" "organization_id" "1" \ + "Player PATCH with organization_id=1" "${TOKEN}" +else + warn "No player ID available for test 8 - skipping" +fi + +# --------------------------------------------------------------------------- +# TEST 9: Organization update with plan escalation +# --------------------------------------------------------------------------- +header "TEST 9: Organization Update - Plan/Tier Escalation" +info "PATCH /organizations/:id - permitted: name, region, public_tagline" +info "Inject plan, subscription_status, trial_ends_at" + +if [ -n "${ORG_ID}" ]; then + BODY="{\"organization\": { + \"public_tagline\": \"normal update\", + \"plan\": \"enterprise\", + \"subscription_status\": \"active\", + \"trial_ends_at\": \"2099-12-31\", + \"tier\": \"professional\" + }}" + sep + echo "TEST: Org PATCH with plan escalation" + TMP2="$(mktemp)" + CODE2=$(curl -s -o "${TMP2}" -w "%{http_code}" --max-time 15 \ + -X PATCH "${API}/organizations/${ORG_ID}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d "${BODY}" \ + 2>/dev/null) || CODE2="ERR" + echo "HTTP Status: ${CODE2}" + RESPONSE2=$(cat "${TMP2}") + python3 -m json.tool 2>/dev/null <<< "${RESPONSE2}" || echo "${RESPONSE2}" + rm -f "${TMP2}" + + if echo "${RESPONSE2}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + def check(obj): + if isinstance(obj, dict): + if str(obj.get('plan','')).lower() == 'enterprise': return True + if str(obj.get('subscription_status','')).lower() == 'active': return True + for v in obj.values(): + if check(v): return True + elif isinstance(obj, list): + for i in obj: + if check(i): return True + return False + sys.exit(0 if check(d) else 1) +except: sys.exit(1) +" 2>/dev/null; then + finding "MASS ASSIGNMENT: Plan escalation fields reflected in org response" + VULN_COUNT=$((VULN_COUNT + 1)) + else + ok "Plan escalation fields not reflected in response" + fi +else + warn "No org ID available - skipping test 9" +fi + +# --------------------------------------------------------------------------- +# TEST 10: Notification preferences with extra keys (nested mass assignment) +# --------------------------------------------------------------------------- +header "TEST 10: Nested Mass Assignment - notification_preferences" +info "PATCH /profile/notifications - notification_params permits notification_preferences: {}" +info "Inject extra keys inside nested hash to probe open hash behavior" + +BODY="{\"user\": { + \"notifications_enabled\": true, + \"notification_preferences\": { + \"email\": true, + \"role\": \"admin\", + \"__proto__\": {\"polluted\": true}, + \"admin\": true + } +}}" +sep +echo "TEST: Notification preferences with extra keys" +TMP3="$(mktemp)" +CODE3=$(curl -s -o "${TMP3}" -w "%{http_code}" --max-time 15 \ + -X PATCH "${API}/profile/notifications" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d "${BODY}" \ + 2>/dev/null) || CODE3="ERR" +echo "HTTP Status: ${CODE3}" +python3 -m json.tool 2>/dev/null < "${TMP3}" || cat "${TMP3}" +rm -f "${TMP3}" + +info "Result: {} permits all nested keys - verify in DB that only expected keys are stored" +info "Check rails console: User.find(current_user_id).notification_preferences" + +# --------------------------------------------------------------------------- +# SUMMARY +# --------------------------------------------------------------------------- +header "SUMMARY" +sep +echo "Vulnerabilities found: ${VULN_COUNT}" +sep + +if [ "${VULN_COUNT}" -gt 0 ]; then + finding "MASS ASSIGNMENT VULNERABILITIES DETECTED" + finding "Review Strong Parameters permit lists in the affected controllers" +else + ok "No mass assignment vulnerabilities found - Strong Parameters are effective" + info "Note: test 10 (notification_preferences) requires manual DB verification" +fi +echo "" +echo "Output saved to: ${OUTPUT_FILE}" diff --git a/.pentest/scripts/26_tournaments_security.sh b/.pentest/scripts/26_tournaments_security.sh new file mode 100644 index 0000000..acfc7a7 --- /dev/null +++ b/.pentest/scripts/26_tournaments_security.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# ============================================================================= +# 26_tournaments_security.sh - Security tests for the Tournaments module +# +# Purpose: Validate authorization, mass assignment, IDOR, and business logic +# controls on all tournament endpoints: +# 1. Unauthenticated access to public endpoints (should succeed) +# 2. Unauthenticated access to protected endpoints (should return 401) +# 3. Non-admin create tournament (should return 403) +# 4. Non-admin approve/reject team (should return 403) +# 5. Non-admin generate bracket (should return 403) +# 6. IDOR β€” enroll to another org's team slot +# 7. Double enrollment β€” same org enrolls twice (should return 422) +# 8. Checkin outside window (status != checkin_open β†’ 422) +# 9. Submit report without evidence (should return 422) +# 10. Non-admin admin_resolve (should return 403) +# 11. Mass assignment β€” inject winner_id directly in enrollment POST +# 12. Mass assignment β€” inject status directly in enrollment POST +# +# Usage: +# bash 26_tournaments_security.sh +# +# Output: ../snapshots/tournaments_security_TIMESTAMP.txt +# ============================================================================= + +set -euo pipefail + +API="http://localhost:3333/api/v1" +TEST_EMAIL="test@prostaff.gg" +TEST_PASSWORD="Test123!@#" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="/home/bullet/PROJETOS/prostaff-api/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/tournaments_security_${TIMESTAMP}.txt" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*" | tee -a "$OUTPUT_FILE"; } +finding() { echo -e "${RED}[!!]${RESET} $*" | tee -a "$OUTPUT_FILE"; } +info() { echo -e "${CYAN}[*]${RESET} $*" | tee -a "$OUTPUT_FILE"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*" | tee -a "$OUTPUT_FILE"; } + +mkdir -p "$SNAPSHOT_DIR" +echo "# Tournaments Security Test β€” $TIMESTAMP" > "$OUTPUT_FILE" +echo "=================================================================" | tee -a "$OUTPUT_FILE" + +# --------------------------------------------------------------------------- +# Auth setup +# --------------------------------------------------------------------------- +info "Authenticating as test user..." +AUTH_RESPONSE=$(curl -s -X POST "${API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}") + +TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('data', {}).get('access_token') or d.get('access_token') or '') +" 2>/dev/null) + +if [ -z "$TOKEN" ]; then + warn "Could not obtain token β€” some tests will be skipped (server may be offline)" +fi + +# Get a real tournament ID if available +TOURNAMENT_ID=$(curl -s "${API}/tournaments" \ + -H "Content-Type: application/json" | python3 -c " +import sys, json +d = json.load(sys.stdin) +items = d.get('data') or [] +print(items[0]['id'] if items else '') +" 2>/dev/null || echo "") + +# --------------------------------------------------------------------------- +# Test 1: Public list endpoint requires no auth +# --------------------------------------------------------------------------- +info "Test 1: Public list endpoint (GET /tournaments)..." +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${API}/tournaments") +if [ "$STATUS" = "200" ]; then + ok "Test 1 PASS β€” public list returns 200 without auth" +else + finding "Test 1 FAIL β€” expected 200, got $STATUS" +fi + +# --------------------------------------------------------------------------- +# Test 2: Protected endpoints return 401 without token +# --------------------------------------------------------------------------- +info "Test 2: Protected endpoints return 401 without token..." +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${API}/tournaments" \ + -H "Content-Type: application/json" \ + -d '{"name":"Test"}') +if [ "$STATUS" = "401" ]; then + ok "Test 2 PASS β€” POST /tournaments returns 401 without auth" +else + finding "Test 2 FAIL β€” expected 401, got $STATUS" +fi + +# --------------------------------------------------------------------------- +# Test 3: Non-admin cannot create tournament +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ]; then + info "Test 3: Non-admin create tournament (expect 403)..." + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${API}/tournaments" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"name":"Hacker Cup","max_teams":16,"entry_fee_cents":0}') + if [ "$STATUS" = "403" ]; then + ok "Test 3 PASS β€” non-admin create returns 403" + else + finding "Test 3 CONCERN β€” expected 403, got $STATUS (check admin guard)" + fi +else + warn "Test 3 SKIPPED β€” no token" +fi + +# --------------------------------------------------------------------------- +# Test 4: Non-admin cannot approve team +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 4: Non-admin approve team (expect 403)..." + FAKE_TEAM_ID="00000000-0000-0000-0000-000000000000" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PATCH "${API}/tournaments/${TOURNAMENT_ID}/teams/${FAKE_TEAM_ID}/approve" \ + -H "Authorization: Bearer $TOKEN") + if [ "$STATUS" = "403" ] || [ "$STATUS" = "404" ]; then + ok "Test 4 PASS β€” non-admin approve returns $STATUS (403 or 404 acceptable)" + else + finding "Test 4 CONCERN β€” expected 403/404, got $STATUS" + fi +else + warn "Test 4 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Test 5: Non-admin cannot generate bracket +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 5: Non-admin generate bracket (expect 403)..." + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${API}/tournaments/${TOURNAMENT_ID}/generate_bracket" \ + -H "Authorization: Bearer $TOKEN") + if [ "$STATUS" = "403" ]; then + ok "Test 5 PASS β€” non-admin generate_bracket returns 403" + else + finding "Test 5 CONCERN β€” expected 403, got $STATUS" + fi +else + warn "Test 5 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Test 6: Double enrollment returns 422 +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 6: Double enrollment (expect 422 on second attempt)..." + curl -s -o /dev/null -X POST "${API}/tournaments/${TOURNAMENT_ID}/teams" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{}' > /dev/null 2>&1 || true + + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${API}/tournaments/${TOURNAMENT_ID}/teams" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{}') + if [ "$STATUS" = "422" ]; then + ok "Test 6 PASS β€” double enrollment returns 422" + else + warn "Test 6 INFO β€” got $STATUS (tournament may be closed or team not approved)" + fi +else + warn "Test 6 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Test 7: Checkin outside window returns 422 +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 7: Checkin on scheduled match (expect 422)..." + # Get a scheduled match + MATCH_ID=$(curl -s "${API}/tournaments/${TOURNAMENT_ID}/matches" \ + -H "Authorization: Bearer $TOKEN" | python3 -c " +import sys, json +d = json.load(sys.stdin) +matches = d.get('data') or [] +scheduled = [m for m in matches if m.get('status') == 'scheduled'] +print(scheduled[0]['id'] if scheduled else '') +" 2>/dev/null || echo "") + + if [ -n "$MATCH_ID" ]; then + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${API}/tournaments/${TOURNAMENT_ID}/matches/${MATCH_ID}/checkin" \ + -H "Authorization: Bearer $TOKEN") + if [ "$STATUS" = "422" ]; then + ok "Test 7 PASS β€” checkin on scheduled match returns 422" + else + finding "Test 7 CONCERN β€” expected 422, got $STATUS" + fi + else + warn "Test 7 SKIPPED β€” no scheduled match found" + fi +else + warn "Test 7 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Test 8: Submit report without evidence_url returns 422 +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 8: Report without evidence (expect 422)..." + MATCH_ID=$(curl -s "${API}/tournaments/${TOURNAMENT_ID}/matches" \ + -H "Authorization: Bearer $TOKEN" | python3 -c " +import sys, json +d = json.load(sys.stdin) +matches = d.get('data') or [] +ar = [m for m in matches if m.get('status') == 'awaiting_report'] +print(ar[0]['id'] if ar else '') +" 2>/dev/null || echo "") + + if [ -n "$MATCH_ID" ]; then + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${API}/tournaments/${TOURNAMENT_ID}/matches/${MATCH_ID}/report" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"team_a_score":2,"team_b_score":1,"evidence_url":""}') + if [ "$STATUS" = "422" ]; then + ok "Test 8 PASS β€” report without evidence returns 422" + else + finding "Test 8 CONCERN β€” expected 422, got $STATUS" + fi + else + warn "Test 8 SKIPPED β€” no awaiting_report match found" + fi +else + warn "Test 8 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Test 9: Mass assignment β€” inject status in enrollment POST +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 9: Mass assignment β€” inject status=approved in enrollment..." + RESPONSE=$(curl -s -X POST "${API}/tournaments/${TOURNAMENT_ID}/teams" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"team_name":"Hack Team","team_tag":"HACK","status":"approved"}') + ACTUAL_STATUS=$(echo "$RESPONSE" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print((d.get('data') or {}).get('status', 'unknown')) +" 2>/dev/null || echo "unknown") + if [ "$ACTUAL_STATUS" = "pending" ] || [ "$ACTUAL_STATUS" = "unknown" ]; then + ok "Test 9 PASS β€” injected status ignored, team is pending (or request rejected)" + else + finding "Test 9 FAIL β€” mass assignment succeeded, status=$ACTUAL_STATUS" + fi +else + warn "Test 9 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Test 10: Non-admin admin_resolve returns 403 +# --------------------------------------------------------------------------- +if [ -n "$TOKEN" ] && [ -n "$TOURNAMENT_ID" ]; then + info "Test 10: Non-admin admin_resolve (expect 403)..." + FAKE_MATCH_ID="00000000-0000-0000-0000-000000000000" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${API}/tournaments/${TOURNAMENT_ID}/matches/${FAKE_MATCH_ID}/report/admin_resolve" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"winner_team_id":"00000000-0000-0000-0000-000000000001"}') + if [ "$STATUS" = "403" ] || [ "$STATUS" = "404" ]; then + ok "Test 10 PASS β€” non-admin admin_resolve returns $STATUS" + else + finding "Test 10 CONCERN β€” expected 403/404, got $STATUS" + fi +else + warn "Test 10 SKIPPED β€” no token or no tournament" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" | tee -a "$OUTPUT_FILE" +echo "=================================================================" | tee -a "$OUTPUT_FILE" +echo -e "${BOLD}Tournaments security scan complete.${RESET}" | tee -a "$OUTPUT_FILE" +echo "Full output saved to: $OUTPUT_FILE" diff --git a/.pentest/scripts/27_supabase_direct_bypass.sh b/.pentest/scripts/27_supabase_direct_bypass.sh new file mode 100644 index 0000000..f1df359 --- /dev/null +++ b/.pentest/scripts/27_supabase_direct_bypass.sh @@ -0,0 +1,708 @@ +#!/usr/bin/env bash +# ============================================================================= +# 27_supabase_direct_bypass.sh - Supabase layer direct access audit +# +# Purpose: Test whether an attacker who extracts the SUPABASE_ANON_KEY from +# the compiled frontend bundle (VITE_SUPABASE_PUBLISHABLE_KEY) can bypass +# the Rails API entirely and interact with Supabase directly. +# +# ProStaff uses Supabase as a PostgreSQL backend. The anon key is baked into +# the Next.js/Vite build and is therefore publicly visible to any user who +# inspects the JavaScript bundle. +# +# Attack model: +# Attacker extracts anon key from browser DevTools > Sources +# -> Hits https://nnqfvgnvemqctjfhadhz.supabase.co/rest/v1/ directly +# -> Bypasses Rails auth, Pundit policies, rate limiting, audit logging +# +# Tests: +# 1. Schema discovery via Supabase OpenAPI (/rest/v1/) +# 2. RLS audit: anon read on known ProStaff tables +# 3. RLS audit: anon INSERT / UPDATE / DELETE +# 4. Open registration via /auth/v1/signup (bypasses Rails /auth/register) +# 5. Token confusion: Rails JWT used as Supabase Bearer +# 6. Password reset poisoning via /auth/v1/recover (no Rails rate limit) +# 7. RPC (database functions) discovery and probe +# 8. Supabase Storage bucket enumeration +# 9. Authenticated access after self-registration (if Test 4 succeeds) +# +# Result interpretation: +# HTTP 200 on a table read -> RLS misconfigured (CRITICAL) +# HTTP 201 on /auth/v1/signup -> Open registration bypasses Rails (HIGH) +# HTTP 200 with Rails JWT -> Token confusion (CRITICAL) +# RPC functions callable -> Escalation surface (investigate) +# +# Usage: +# bash 27_supabase_direct_bypass.sh +# +# Requires: curl, jq or python3 +# Output: ../snapshots/supabase_bypass_TIMESTAMP.txt +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration β€” override via env vars if needed +# --------------------------------------------------------------------------- +SUPABASE_URL="${SUPABASE_URL:?SUPABASE_URL env var required}" +SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY env var required}" + +# Rails local API β€” used to obtain a real user JWT for token confusion test +RAILS_API="http://localhost:3333/api/v1" +TEST_EMAIL="test@prostaff.gg" +TEST_PASSWORD="Test123!@#" + +# Test account used for open-registration probe (does not need to exist) +PROBE_EMAIL="pentest_probe_$(date +%s)@prostaff-audit.invalid" +PROBE_PASSWORD="Pentest@Audit231!" + +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +SNAPSHOT_DIR="/home/bullet/PROJETOS/prostaff-api/.pentest/snapshots" +OUTPUT_FILE="${SNAPSHOT_DIR}/supabase_bypass_${TIMESTAMP}.txt" + +# Known ProStaff tables to audit (from schema.rb) +KNOWN_TABLES=( + "organizations" + "users" + "players" + "matches" + "player_match_stats" + "audit_logs" + "messages" + "scouting_notes" + "team_goals" + "vod_reviews" + "refresh_tokens" + "watchlists" +) + +# --------------------------------------------------------------------------- +# Colors +# --------------------------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +finding() { echo -e "${RED}[!!]${RESET} $*"; } +info() { echo -e "${CYAN}[*]${RESET} $*"; } +warn() { echo -e "${YELLOW}[?]${RESET} $*"; } +header() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}\n"; } +log_sep() { echo "--------------------------------------------------------------------------------"; } + +mkdir -p "${SNAPSHOT_DIR}" +exec > >(tee -a "${OUTPUT_FILE}") 2>&1 + +VULN_COUNT=0 +RAILS_JWT="" +PROBE_JWT="" +DISCOVERED_TABLES=() +DISCOVERED_RPCS=() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +supa_get() { + local path="$1" + local bearer="${2:-${SUPABASE_ANON_KEY}}" + local extra_headers="${3:-}" + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -H "apikey: ${SUPABASE_ANON_KEY}" \ + -H "Authorization: Bearer ${bearer}" \ + ${extra_headers:+-H "${extra_headers}"} \ + "${SUPABASE_URL}${path}" 2>/dev/null) || code="error" + echo "${code}|$(cat "${tmp}")" + rm -f "${tmp}" +} + +supa_post() { + local path="$1" + local body="$2" + local bearer="${3:-${SUPABASE_ANON_KEY}}" + local extra_headers="${4:-}" + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -H "apikey: ${SUPABASE_ANON_KEY}" \ + -H "Authorization: Bearer ${bearer}" \ + -H "Content-Type: application/json" \ + ${extra_headers:+-H "${extra_headers}"} \ + -X POST \ + -d "${body}" \ + "${SUPABASE_URL}${path}" 2>/dev/null) || code="error" + echo "${code}|$(cat "${tmp}")" + rm -f "${tmp}" +} + +get_rails_jwt() { + info "Obtaining Rails JWT for token confusion test..." + local tmp + tmp="$(mktemp)" + local code + code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 15 \ + -X POST "${RAILS_API}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${TEST_EMAIL}\",\"password\":\"${TEST_PASSWORD}\"}" \ + 2>/dev/null) || code="error" + + RAILS_JWT=$(python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + t = (d.get('access_token') or d.get('token') + or d.get('data', {}).get('access_token') + or d.get('data', {}).get('token') or '') + print(t) +except Exception: + pass +" 2>/dev/null < "${tmp}") || RAILS_JWT="" + rm -f "${tmp}" + + if [ -n "${RAILS_JWT}" ]; then + ok "Rails JWT obtained: ${RAILS_JWT:0:40}..." + else + warn "Could not obtain Rails JWT (HTTP ${code}) β€” token confusion test will be skipped" + fi +} + +# =========================================================================== +# MAIN +# =========================================================================== + +header "SUPABASE DIRECT BYPASS AUDIT" +echo "Supabase URL : ${SUPABASE_URL}" +echo "Anon key : ${SUPABASE_ANON_KEY:0:40}... (from VITE_SUPABASE_PUBLISHABLE_KEY)" +echo "Rails API : ${RAILS_API}" +echo "Started : $(date --iso-8601=seconds)" +echo "Output : ${OUTPUT_FILE}" +echo "" +warn "Attack model: anon key extracted from compiled frontend JS bundle" +warn " (visible in browser DevTools > Sources > main-*.js)" + +# =========================================================================== +# TEST 1: Schema Discovery +# =========================================================================== +header "TEST 1: Schema Discovery via /rest/v1/" + +info "Fetching Supabase OpenAPI schema with anon key only..." +result=$(supa_get "/rest/v1/") +code="${result%%|*}" +body="${result#*|}" + +echo "HTTP ${code}" + +if [ "${code}" == "200" ]; then + finding "Supabase OpenAPI schema is readable with anon key" + VULN_COUNT=$(( VULN_COUNT + 1 )) + + echo "${body}" > "${SNAPSHOT_DIR}/supabase_schema_${TIMESTAMP}.json" + info "Full schema saved to supabase_schema_${TIMESTAMP}.json" + + mapfile -t DISCOVERED_TABLES < <(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + tables = list(d.get('definitions', {}).keys()) + for t in sorted(tables): + print(t) +except Exception: + pass +" 2>/dev/null) || DISCOVERED_TABLES=() + + mapfile -t DISCOVERED_RPCS < <(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + paths = [p.replace('/rpc/', '') for p in d.get('paths', {}).keys() if p.startswith('/rpc/')] + for r in sorted(paths): + print(r) +except Exception: + pass +" 2>/dev/null) || DISCOVERED_RPCS=() + + echo "" + echo "Discovered tables (${#DISCOVERED_TABLES[@]}):" + for t in "${DISCOVERED_TABLES[@]}"; do echo " - ${t}"; done + + if [ "${#DISCOVERED_RPCS[@]}" -gt 0 ]; then + echo "" + echo "Discovered RPC functions (${#DISCOVERED_RPCS[@]}):" + for r in "${DISCOVERED_RPCS[@]}"; do echo " - ${r}"; done + fi +else + ok "Schema not accessible with anon key (HTTP ${code})" + info "Falling back to known ProStaff tables for CRUD tests" + DISCOVERED_TABLES=("${KNOWN_TABLES[@]}") +fi + +# Merge discovered + known tables (deduplicated) +ALL_TABLES=() +declare -A SEEN_TABLES +for t in "${DISCOVERED_TABLES[@]}" "${KNOWN_TABLES[@]}"; do + if [ -z "${SEEN_TABLES[$t]+_}" ]; then + SEEN_TABLES[$t]=1 + ALL_TABLES+=("$t") + fi +done + +# =========================================================================== +# TEST 2: RLS Audit β€” anon READ +# =========================================================================== +header "TEST 2: RLS Audit β€” Anonymous READ on Tables" + +info "Testing GET /rest/v1/?select=* with anon key only" +info "Expected: 200 with empty array (RLS allows read but no rows) or 404" +info "CRITICAL: 200 with rows -> RLS disabled or too permissive" +echo "" + +printf "%-35s | %s\n" "Table" "Result" +log_sep + +for table in "${ALL_TABLES[@]}"; do + result=$(supa_get "/rest/v1/${table}?select=*&limit=5") + code="${result%%|*}" + body="${result#*|}" + + row_count=$(python3 -c " +import sys, json +try: + d = json.loads('${body//\'/\\\'}') + if isinstance(d, list): + print(len(d)) + else: + print('N/A') +except Exception: + print('N/A') +" 2>/dev/null) || row_count="parse_err" + + if [ "${code}" == "200" ]; then + if [ "${row_count}" != "N/A" ] && [ "${row_count}" != "parse_err" ] && [ "${row_count}" -gt 0 ] 2>/dev/null; then + finding "$(printf "%-35s | HTTP 200 - %s rows returned - RLS MISSING or too permissive" "${table}" "${row_count}")" + VULN_COUNT=$(( VULN_COUNT + 1 )) + else + warn "$(printf "%-35s | HTTP 200 - empty (anon can query but RLS returns no rows)" "${table}")" + fi + elif [ "${code}" == "404" ]; then + ok "$(printf "%-35s | HTTP 404 - table not exposed" "${table}")" + elif [ "${code}" == "401" ] || [ "${code}" == "403" ]; then + ok "$(printf "%-35s | HTTP %s - access denied" "${table}" "${code}")" + else + warn "$(printf "%-35s | HTTP %s - unexpected" "${table}" "${code}")" + fi +done + +# =========================================================================== +# TEST 3: RLS Audit β€” anon INSERT / UPDATE / DELETE +# =========================================================================== +header "TEST 3: RLS Audit β€” Anonymous Write Operations" + +info "Testing INSERT/UPDATE/DELETE with anon key only" +info "Expected: 403 or 401 for all (RLS should block writes)" +echo "" + +TEST_WRITE_TABLES=("organizations" "users" "players" "matches" "audit_logs") + +for table in "${TEST_WRITE_TABLES[@]}"; do + echo "" + info "Table: ${table}" + + # INSERT + result=$(supa_post "/rest/v1/${table}" \ + '{"pentest_probe":"supabase_direct_bypass_audit"}' \ + "${SUPABASE_ANON_KEY}" \ + "Prefer: return=minimal") + insert_code="${result%%|*}" + if [[ "${insert_code}" =~ ^(200|201|204)$ ]]; then + finding "INSERT on ${table}: HTTP ${insert_code} - anon write ENABLED (RLS missing!)" + VULN_COUNT=$(( VULN_COUNT + 1 )) + else + ok "INSERT on ${table}: HTTP ${insert_code} - blocked" + fi + + # UPDATE (mass update with no filter β€” if 200 it would update all rows) + tmp="$(mktemp)" + upd_code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 10 \ + -X PATCH \ + -H "apikey: ${SUPABASE_ANON_KEY}" \ + -H "Authorization: Bearer ${SUPABASE_ANON_KEY}" \ + -H "Content-Type: application/json" \ + -H "Prefer: return=minimal" \ + -d '{"pentest_probe":"supabase_bypass"}' \ + "${SUPABASE_URL}/rest/v1/${table}" 2>/dev/null) || upd_code="error" + rm -f "${tmp}" + if [[ "${upd_code}" =~ ^(200|204)$ ]]; then + finding "UPDATE on ${table}: HTTP ${upd_code} - anon update ENABLED (mass update possible!)" + VULN_COUNT=$(( VULN_COUNT + 1 )) + else + ok "UPDATE on ${table}: HTTP ${upd_code} - blocked" + fi + + # DELETE (mass delete) + tmp="$(mktemp)" + del_code=$(curl -s -o "${tmp}" -w "%{http_code}" --max-time 10 \ + -X DELETE \ + -H "apikey: ${SUPABASE_ANON_KEY}" \ + -H "Authorization: Bearer ${SUPABASE_ANON_KEY}" \ + -H "Prefer: return=minimal" \ + "${SUPABASE_URL}/rest/v1/${table}" 2>/dev/null) || del_code="error" + rm -f "${tmp}" + if [[ "${del_code}" =~ ^(200|204)$ ]]; then + finding "DELETE on ${table}: HTTP ${del_code} - anon delete ENABLED (mass delete possible!)" + VULN_COUNT=$(( VULN_COUNT + 1 )) + else + ok "DELETE on ${table}: HTTP ${del_code} - blocked" + fi +done + +# =========================================================================== +# TEST 4: Open Registration via Supabase Auth +# =========================================================================== +header "TEST 4: Open Registration β€” Supabase /auth/v1/signup" + +info "Testing if anyone can register directly on Supabase, bypassing" +info "the Rails /auth/register endpoint (and its org-creation logic)." +echo "" +info "Probe email: ${PROBE_EMAIL}" + +result=$(supa_post "/auth/v1/signup" \ + "{\"email\":\"${PROBE_EMAIL}\",\"password\":\"${PROBE_PASSWORD}\"}") +signup_code="${result%%|*}" +signup_body="${result#*|}" + +echo "HTTP ${signup_code}" + +if [ "${signup_code}" == "200" ] || [ "${signup_code}" == "201" ]; then + finding "Open registration on Supabase! Account created without going through Rails." + finding "This bypasses: org creation, role assignment, audit log, rate limiting." + VULN_COUNT=$(( VULN_COUNT + 1 )) + + # Try to log in with the new account + info "Attempting login with probe credentials..." + login_result=$(supa_post "/auth/v1/token?grant_type=password" \ + "{\"email\":\"${PROBE_EMAIL}\",\"password\":\"${PROBE_PASSWORD}\"}") + login_code="${login_result%%|*}" + login_body="${login_result#*|}" + + if [ "${login_code}" == "200" ]; then + PROBE_JWT=$(echo "${login_body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(d.get('access_token', '')) +except Exception: + pass +" 2>/dev/null) || PROBE_JWT="" + finding "Probe account login succeeded β€” obtained Supabase JWT" + if [ -n "${PROBE_JWT}" ]; then + info "Probe JWT: ${PROBE_JWT:0:40}..." + fi + else + warn "Registration succeeded but login returned HTTP ${login_code} (email confirmation may be required)" + fi +else + ok "Registration blocked (HTTP ${signup_code})" + echo "Response: ${signup_body:0:150}" +fi + +# =========================================================================== +# TEST 5: Token Confusion β€” Rails JWT against Supabase +# =========================================================================== +header "TEST 5: Token Confusion β€” Rails JWT as Supabase Bearer" + +get_rails_jwt + +if [ -n "${RAILS_JWT}" ]; then + info "Using Rails JWT as Bearer token against Supabase REST API..." + echo "" + + for table in "users" "organizations" "players"; do + result=$(supa_get "/rest/v1/${table}?select=*&limit=5" "${RAILS_JWT}") + code="${result%%|*}" + body="${result#*|}" + + echo "GET /rest/v1/${table} with Rails JWT -> HTTP ${code}" + + if [ "${code}" == "200" ]; then + row_count=$(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(len(d) if isinstance(d, list) else 'N/A') +except Exception: + print('N/A') +" 2>/dev/null) || row_count="parse_err" + finding "HTTP 200 with Rails JWT! Token confusion vulnerability β€” rows: ${row_count}" + VULN_COUNT=$(( VULN_COUNT + 1 )) + else + ok "Supabase rejected Rails JWT (HTTP ${code}) β€” correct behavior" + fi + done +else + warn "Skipping token confusion test (no Rails JWT available)" +fi + +# =========================================================================== +# TEST 6: Password Reset Poisoning +# =========================================================================== +header "TEST 6: Password Reset β€” Direct Supabase /auth/v1/recover" + +info "Testing if password reset can be triggered directly on Supabase," +info "bypassing any Rails rate limiting on this operation." +echo "" + +# Use the test account email which should exist in Supabase (it's the DB) +result=$(supa_post "/auth/v1/recover" "{\"email\":\"${TEST_EMAIL}\"}") +recover_code="${result%%|*}" +recover_body="${result#*|}" + +echo "POST /auth/v1/recover -> HTTP ${recover_code}" +echo "Response: ${recover_body:0:200}" + +# HTTP 200 with empty body {} is expected β€” Supabase intentionally does not +# confirm whether the email exists (anti-enumeration). This is correct behavior. +# The real risk is the absence of rate limiting, not the 200 itself. +# Mitigation: set "Rate limit for sending emails" in Supabase Dashboard +# (Auth -> Rate Limits -> Email rate limit, recommended: 2/h) +if [ "${recover_code}" == "200" ]; then + warn "Password reset endpoint reachable directly on Supabase (expected β€” HTTP 200 is anti-enumeration behavior)" + warn "Ensure email rate limit is configured in Supabase Dashboard: Auth -> Rate Limits -> Email" + warn " Recommended: 2/h (aligns with Rails Rack::Attack throttle on /forgot-password)" + info "No vulnerability counted β€” 200 is correct behavior; rate limit is the actual control" +else + ok "Password reset returned HTTP ${recover_code}" +fi + +# =========================================================================== +# TEST 7: RPC Function Probe +# =========================================================================== +header "TEST 7: RPC Function Discovery and Probe" + +if [ "${#DISCOVERED_RPCS[@]}" -gt 0 ]; then + info "Probing ${#DISCOVERED_RPCS[@]} discovered RPC functions with anon key..." + echo "" + + for rpc in "${DISCOVERED_RPCS[@]}"; do + result=$(supa_post "/rest/v1/rpc/${rpc}" '{}') + code="${result%%|*}" + body="${result#*|}" + + echo "POST /rest/v1/rpc/${rpc} -> HTTP ${code}" + + if [ "${code}" == "200" ]; then + finding "RPC ${rpc} callable with anon key β€” inspect returned data" + echo " Response: ${body:0:200}" + VULN_COUNT=$(( VULN_COUNT + 1 )) + elif [ "${code}" == "401" ] || [ "${code}" == "403" ]; then + ok "RPC ${rpc} requires auth (HTTP ${code})" + else + warn "RPC ${rpc}: HTTP ${code}" + fi + done +else + info "No RPC functions discovered (schema not readable or none defined)" +fi + +# =========================================================================== +# TEST 8: Supabase Storage Enumeration +# =========================================================================== +header "TEST 8: Supabase Storage Bucket Enumeration" + +info "Testing if storage buckets are listable with anon key..." +echo "" + +# List buckets +result=$(supa_get "/storage/v1/bucket") +code="${result%%|*}" +body="${result#*|}" + +echo "GET /storage/v1/bucket -> HTTP ${code}" + +if [ "${code}" == "200" ]; then + buckets=$(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + if isinstance(d, list): + for b in d: + if isinstance(b, dict): + print(b.get('name', b.get('id', '?'))) +except Exception: + pass +" 2>/dev/null) || buckets="" + + if [ -n "${buckets}" ]; then + finding "Storage buckets listed with anon key:" + echo "${buckets}" | sed 's/^/ - /' + VULN_COUNT=$(( VULN_COUNT + 1 )) + + # Try listing files in each bucket + while IFS= read -r bucket; do + [ -z "${bucket}" ] && continue + files_result=$(supa_get "/storage/v1/object/list/${bucket}") + files_code="${files_result%%|*}" + files_body="${files_result#*|}" + echo " GET /storage/v1/object/list/${bucket} -> HTTP ${files_code}" + if [ "${files_code}" == "200" ]; then + file_count=$(echo "${files_body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(len(d) if isinstance(d, list) else 'N/A') +except Exception: + print('N/A') +" 2>/dev/null) || file_count="N/A" + finding "Bucket '${bucket}': ${file_count} files listable anonymously" + fi + done <<< "${buckets}" + else + ok "Bucket list is empty or returned non-array body" + fi +else + ok "Storage bucket list blocked (HTTP ${code})" +fi + +# =========================================================================== +# TEST 9: Authenticated Escalation (if probe account obtained JWT) +# =========================================================================== +header "TEST 9: Authenticated Escalation (Post-Registration)" + +if [ -n "${PROBE_JWT}" ]; then + info "Testing data access with probe account JWT (registered directly on Supabase)" + info "This account has no organization in Rails β€” testing what it can see/do" + echo "" + + for table in "organizations" "users" "players" "matches" "audit_logs"; do + result=$(supa_get "/rest/v1/${table}?select=*&limit=5" "${PROBE_JWT}") + code="${result%%|*}" + body="${result#*|}" + + row_count=$(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(len(d) if isinstance(d, list) else 'N/A') +except Exception: + print('N/A') +" 2>/dev/null) || row_count="parse_err" + + echo "GET /rest/v1/${table} with probe JWT -> HTTP ${code} | rows: ${row_count}" + + if [ "${code}" == "200" ] && [ "${row_count}" != "0" ] && [ "${row_count}" != "N/A" ] && [ "${row_count}" != "parse_err" ]; then + finding "Probe account (no org) can read ${row_count} rows from ${table} β€” RLS too permissive" + VULN_COUNT=$(( VULN_COUNT + 1 )) + fi + done +else + info "Skipping β€” no probe JWT available (registration was blocked or requires confirmation)" +fi + +# =========================================================================== +# TEST 10: Supabase Auth Config Disclosure +# =========================================================================== +header "TEST 10: Auth Configuration Disclosure" + +info "Checking Supabase auth settings endpoint..." +result=$(supa_get "/auth/v1/settings") +code="${result%%|*}" +body="${result#*|}" + +echo "GET /auth/v1/settings -> HTTP ${code}" + +if [ "${code}" == "200" ]; then + info "Auth settings readable (expected for public config):" + echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + interesting = {k: v for k, v in d.items() if k in [ + 'external', 'disable_signup', 'email_autoconfirm', + 'phone_autoconfirm', 'sms_provider', 'mfa_enabled' + ]} + for k, v in interesting.items(): + print(f' {k}: {v}') +except Exception: + pass +" 2>/dev/null || echo "${body:0:300}" + + # Check critical settings + email_autoconfirm=$(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(d.get('email_autoconfirm', 'unknown')) +except Exception: + print('unknown') +" 2>/dev/null) || email_autoconfirm="unknown" + + disable_signup=$(echo "${body}" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(d.get('disable_signup', 'unknown')) +except Exception: + print('unknown') +" 2>/dev/null) || disable_signup="unknown" + + echo "" + if [ "${email_autoconfirm}" == "true" ]; then + finding "email_autoconfirm=true: registrations confirmed instantly, no email verification" + VULN_COUNT=$(( VULN_COUNT + 1 )) + fi + if [ "${disable_signup}" == "false" ] || [ "${disable_signup}" == "unknown" ]; then + warn "disable_signup=${disable_signup}: public registration may be enabled on Supabase" + fi +else + ok "Auth settings endpoint returned HTTP ${code}" +fi + +# =========================================================================== +# SUMMARY +# =========================================================================== +echo "" +log_sep +header "SUPABASE DIRECT BYPASS AUDIT β€” SUMMARY" +echo "Completed : $(date --iso-8601=seconds)" +echo "Output : ${OUTPUT_FILE}" +echo "" +echo "Supabase URL: ${SUPABASE_URL}" +echo "Anon key : publicly accessible via compiled frontend bundle" +echo "" +echo "Total findings: ${VULN_COUNT}" +echo "" + +if [ "${VULN_COUNT}" -gt 0 ]; then + finding "SUPABASE LAYER HAS EXPLOITABLE ISSUES β€” review all [!!] findings above" + echo "" + echo "Remediation checklist:" + echo "" + echo " 1. Row Level Security (for each table with a [!!] READ finding):" + echo " ALTER TABLE
ENABLE ROW LEVEL SECURITY;" + echo " CREATE POLICY anon_no_access ON
FOR ALL TO anon USING (false);" + echo "" + echo " 2. Open registration:" + echo " Supabase Dashboard -> Auth -> Providers -> Email -> Disable signups" + echo " (ProStaff manages registration through Rails /auth/register)" + echo "" + echo " 3. Token confusion:" + echo " Verify JWT_SECRET_KEY in Rails does NOT match Supabase JWT secret" + echo " Supabase Dashboard -> Project Settings -> API -> JWT Secret" + echo "" + echo " 4. Password reset rate limiting:" + echo " Supabase Dashboard -> Auth -> Rate limits -> Email rate limit" + echo "" + echo " 5. Storage buckets:" + echo " Supabase Dashboard -> Storage -> Policies -> restrict anon access" +else + ok "No direct bypass vulnerabilities confirmed" + echo " The Supabase layer appears to be properly protected." + echo " Consider verifying RLS policies via Supabase Dashboard -> Table Editor" +fi +log_sep diff --git a/.pentest/test-secrets-quick.sh b/.pentest/test-secrets-quick.sh index 252549b..e323bd6 100644 --- a/.pentest/test-secrets-quick.sh +++ b/.pentest/test-secrets-quick.sh @@ -44,7 +44,7 @@ fi # 3. Check .env is in .gitignore echo "[3/5] Checking .env is ignored..." -if grep -q "^\.env$" .gitignore 2>/dev/null; then +if grep -qP "^\.env\r?$" .gitignore 2>/dev/null; then echo -e "${GREEN}[PASS]${NC} .env is in .gitignore" else echo -e "${RED}[FAIL]${NC} .env should be in .gitignore" @@ -80,7 +80,6 @@ if [ $ISSUES -eq 0 ]; then echo -e "${GREEN}βœ“ No secrets exposed${NC}" exit 0 else - echo -e "${YELLOW}⚠ $ISSUES potential issues detected${NC}" - echo "Review findings above" - exit 0 # Warning, not critical failure + echo -e "${RED}βœ— $ISSUES issue(s) detected β€” review findings above${NC}" + exit 1 fi diff --git a/.pentest/test-ssrf-quick.sh b/.pentest/test-ssrf-quick.sh index d5f4b7a..0d7d01d 100644 --- a/.pentest/test-ssrf-quick.sh +++ b/.pentest/test-ssrf-quick.sh @@ -1,8 +1,6 @@ #!/bin/bash # Quick SSRF Protection Test (works without auth since we check rejection) -set -e - API_URL="http://localhost:3333" GREEN='\033[0;32m' RED='\033[0;31m' @@ -25,8 +23,8 @@ test_result() { fi } -# Note: Image proxy now requires authentication -# So all these should return 401 Unauthorized (which is good - not vulnerable to SSRF from unauthenticated users) +# Note: Image proxy intentionally skips JWT auth (browsers can't send Authorization on src). +# Security is enforced via domain allowlist + HTTPS-only + private IP/scheme blocking. echo "[1/9] Testing localhost access (should be blocked)..." RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/v1/images/proxy?url=http://localhost:6379") @@ -108,14 +106,14 @@ else test_result "FAIL" "Should block HTTP (got HTTP $HTTP_CODE)" fi -echo "[9/9] Authentication required check..." -RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/v1/images/proxy?url=https://upload.wikimedia.org/test.png") +echo "[9/9] Testing file:// scheme (local file read)..." +RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/v1/images/proxy?url=file:///etc/passwd") HTTP_CODE=$(echo "$RESULT" | tail -n1) -if [ "$HTTP_CODE" = "401" ]; then - test_result "PASS" "Endpoint requires authentication (HTTP 401)" +if [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "403" ]; then + test_result "PASS" "Blocks file:// scheme (HTTP $HTTP_CODE)" else - test_result "FAIL" "Endpoint should require authentication (got HTTP $HTTP_CODE)" + test_result "FAIL" "Should block file:// scheme (got HTTP $HTTP_CODE)" fi echo "" @@ -127,7 +125,7 @@ echo -e "${GREEN}Passed: $PASSED${NC}" echo -e "${RED}Failed: $FAILED${NC}" echo "" -if [ $PASSED -ge 8 ]; then +if [ $FAILED -eq 0 ]; then echo -e "${GREEN}βœ“ SSRF protection is SECURE${NC}" echo "" echo "Notes:" @@ -136,6 +134,6 @@ if [ $PASSED -ge 8 ]; then echo "- Private IPs, localhost, and metadata endpoints protected" exit 0 else - echo -e "${RED}βœ— SSRF vulnerabilities detected!${NC}" + echo -e "${RED}βœ— SSRF vulnerabilities detected ($FAILED failure(s))!${NC}" exit 1 fi diff --git a/.rubocop.yml b/.rubocop.yml index fbc67d8..854efd7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ # RuboCop Configuration for ProStaff API -# Ruby on Rails 7.2+ API with Ruby 3.4.5 +# Ruby on Rails 7.2+ API with Ruby 3.4.8 # # This configuration balances code quality with pragmatic Rails development. # Generated: 2025-10-23 @@ -29,6 +29,7 @@ Metrics/BlockLength: - 'config/routes/**/*' - 'db/migrate/*' - 'db/seeds.rb' + - 'spec/factories/**/*' Max: 50 Metrics/MethodLength: diff --git a/.semgrepignore b/.semgrepignore index bcac9ce..5f7e86c 100644 --- a/.semgrepignore +++ b/.semgrepignore @@ -31,3 +31,9 @@ DOCS/legacy/ # in non-production environments and does not represent a real security risk. config/environments/development.rb config/environments/test.rb + +# Pentest scripts β€” test JWT tokens and payloads are intentional, not leaked secrets. +.pentest/ + +# Static documentation page β€” not served by the Rails app in production. +docs-page/ diff --git a/.snyk b/.snyk new file mode 100644 index 0000000..a3115b6 --- /dev/null +++ b/.snyk @@ -0,0 +1,22 @@ +# Snyk ignore file +# Docs: https://docs.snyk.io/snyk-cli/commands/ignore +# +# Use this file to suppress false positives or CVEs that are not exploitable +# in this application's runtime context. +# +# Format: +# : +# - '*': +# reason: +# expires: '' +# +# Base image: ruby:3.4.8-slim (Debian Bookworm) +# +# How to add an entry: +# 1. A CVE appears in the GitHub Security tab after a Snyk scan +# 2. Confirm it is not exploitable (e.g. package not used at runtime, +# only in build stage, or mitigated by another control) +# 3. Add it below with a clear reason and a review expiry date + +version: v1.19.0 +ignore: {} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3774704 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,119 @@ +# Changelog + +All notable changes to ProStaff API will be documented in this file. + +--- + +## [1.0.3] - 2026-03-23 + +### Added + +#### Support System +- Full support ticket lifecycle: create, view, update, close, reopen +- Support ticket messages with types: `user`, `staff`, `system`, `chatbot` +- Staff dashboard with real-time stats (open, in_progress, waiting_user, urgent, unassigned, my tickets) +- Staff analytics: tickets created/resolved, avg response time, avg resolution time, resolution rate, trending issues by category +- Ticket assignment and resolution by staff members with audit logging +- Chatbot integration (OpenAI) on ticket creation with FAQ suggestions and LLM solution + +#### File Attachments (Supabase S3) +- `POST /api/v1/support/uploads` β€” authenticated file upload endpoint +- Supabase S3-compatible storage via `aws-sdk-s3` +- Validation: allowed MIME types (image/*, PDF, TXT, CSV), max 10MB per file +- Pre-signed URL generation (1h expiry) on message serialization +- Attachments stored as JSONB on `support_ticket_messages` + +#### Internal Messenger +- Real-time team chat via Action Cable (WebSockets) +- JWT authentication over WebSocket query param +- Organization-scoped message streams +- REST endpoint for message history + +#### Mailer +- Contact form email delivery via SMTP +- Conditional mailer (no-op when SMTP not configured) + +#### Feedback +- `POST /api/v1/feedbacks` β€” user feedback submission +- `POST /api/v1/feedbacks/:id/vote` β€” upvote feedback items + +#### AI Intelligence Module +- Draft analysis and insights powered by OpenAI +- Aggressive timeout (<10s) to prevent blocking requests + +### Changed + +- Support ticket `category` validation now includes `getting_started` +- Support ticket `status` field uses `waiting_user` (renamed from `waiting_client`) +- `SupportTicketMessage#create_system_message` falls back to ticket owner when no staff assigned +- `tickets_controller` serializer now includes `attachments` with signed URLs on all messages +- `message_params` strong params updated to accept structured attachment objects (`%i[key filename content_type size]`) + +### Fixed + +- `SupportTicket#ticket_number` β€” removed unsafe navigation chain causing RuboCop `SafeNavigationChainLength` offense +- `StaffController#calculate_dashboard_stats` β€” corrected `waiting_client` to `waiting_user` key +- `UploadsController` β€” corrected `unless` modifier style per RuboCop `Style/IfUnlessModifier` +- Mail logger warning in production (conditional SMTP setup) + +### Security + +- Upload endpoint requires authentication (`authenticate_request!` via `BaseController`) +- File type whitelist enforced server-side (rejects `application/octet-stream` and other binary types) +- S3 credentials stored exclusively in environment variables, never in source code + +--- + +## [1.0.2] - 2026-02-25 + +### Added +- Failure mode analysis documentation (FAILURE_MODE_ANALYSIS.md) +- Redis identified as SPOF for ActionCable, Sidekiq, Rack::Attack, and cache subsystems + +### Changed +- Real-time messaging (Action Cable) with JWT auth and organization isolation +- Lograge structured JSON logging + +### Fixed +- Data loss incident protections: guard in `rails_helper.rb` aborts tests if `DATABASE_URL` points to production +- `.env.test` created with local PostgreSQL exclusively for tests +- Daily backup script: `scripts/backup_database.sh` (cron 3AM, 30-day retention) + +--- + +## [1.0.1] - 2025-10-25 + +### Added +- k6 load testing suite (smoke, load, stress scenarios) +- OWASP security test suite +- CI/CD workflows: security scan on every push, nightly full audit +- Redis caching on dashboard/stats (5min TTL) +- 8 database indexes on hot query paths + +### Changed +- Code quality overhaul: Codacy issues reduced from 1,569 to 219 (86% reduction) +- Grade improved from C to A- +- YARD documentation added to 22 files + +### Fixed +- N+1 queries via `.includes()` on player and match endpoints +- RuboCop offenses across analytics, scouting, and auth modules + +--- + +## [1.0.0] - 2025-09-01 + +### Added +- Initial release +- JWT authentication with refresh tokens and token blacklist +- Multi-tenant organization structure +- Player management with Riot API sync (Sidekiq jobs) +- Match history via Riot API + PandaScore +- VOD reviews with timestamps +- Team goals tracking +- Player scouting and watchlist +- Analytics and performance metrics +- Full-text search via Meilisearch +- Pundit authorization +- Rack::Attack rate limiting +- Swagger/Rswag API documentation diff --git a/DOCS/deployment/DEPLOYMENT.md b/DOCS/deployment/DEPLOYMENT.md index 11e120b..e95c6be 100644 --- a/DOCS/deployment/DEPLOYMENT.md +++ b/DOCS/deployment/DEPLOYMENT.md @@ -25,7 +25,7 @@ A aplicacao roda via **Coolify** (self-hosted PaaS) com **Traefik** como reverse | Componente | Tecnologia | Versao | |---------------|--------------------------|------------| -| Runtime | Ruby | 3.4.5 | +| Runtime | Ruby | 3.4.8 | | Framework | Rails | 7.2 | | Servidor web | Puma | ~> 6.0 | | Banco de dados| PostgreSQL | 15+ | diff --git a/DOCS/deployment/DEPLOYMENT_SETUP_COMPLETE.md b/DOCS/deployment/DEPLOYMENT_SETUP_COMPLETE.md index 0f2a707..70712c8 100644 --- a/DOCS/deployment/DEPLOYMENT_SETUP_COMPLETE.md +++ b/DOCS/deployment/DEPLOYMENT_SETUP_COMPLETE.md @@ -36,7 +36,7 @@ Rede interna (coolify): ### Docker -- `Dockerfile.production` - Build multi-stage (`ruby:3.4.5-slim`) +- `Dockerfile.production` - Build multi-stage (`ruby:3.4.8-slim`) - Stage `build`: instala dependencias, compila bootsnap - Stage final: copia gems e app, cria usuario `rails` (uid 1000), healthcheck no `/up` - `docker-compose.production.yml` - Servicos de producao na rede `coolify` diff --git a/DOCS/tests/TESTING_GUIDE.md b/DOCS/tests/TESTING_GUIDE.md index f2fcfa9..6e0bd4e 100644 --- a/DOCS/tests/TESTING_GUIDE.md +++ b/DOCS/tests/TESTING_GUIDE.md @@ -43,7 +43,7 @@ Guia de referencia para executar testes unitarios, de integracao, de carga e de ### Pre-requisitos locais ```bash -ruby --version # 3.4.5 +ruby --version # 3.4.8 bundle install # Banco de teste (necessario PostgreSQL rodando) diff --git a/DOCS/troubleshoot/SECURITY_TROUBLESHOOTING.md b/DOCS/troubleshoot/SECURITY_TROUBLESHOOTING.md index bd2824d..063cd7a 100644 --- a/DOCS/troubleshoot/SECURITY_TROUBLESHOOTING.md +++ b/DOCS/troubleshoot/SECURITY_TROUBLESHOOTING.md @@ -21,7 +21,7 @@ Instrucoes para executar scans de seguranca manualmente e resolver problemas com ## Pre-requisitos ```bash -ruby --version # 3.4.5 +ruby --version # 3.4.8 docker --version # jq (opcional, para parsing de JSON) diff --git a/Dockerfile b/Dockerfile index 34f2b23..6586cd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# Use Ruby 3.4.5 slim image (better Windows compatibility) -FROM ruby:3.4.5-slim +# Use Ruby 3.4.8 slim image (better Windows compatibility) +FROM ruby:3.4.8-slim # Install system dependencies without version pinning for compatibility # Note: Using latest available versions from Debian repositories @@ -8,12 +8,10 @@ RUN apt-get update -qq && apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ libyaml-dev \ + libargon2-dev \ git \ tzdata \ - nodejs \ - npm \ curl \ - && npm install -g yarn@1.22.22 \ && rm -rf /var/lib/apt/lists/* # Set working directory diff --git a/Dockerfile.production b/Dockerfile.production index 8e9bc16..682162d 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -3,7 +3,7 @@ ############################ # Base ############################ -FROM ruby:3.4.5-slim AS base +FROM ruby:3.4.8-slim AS base # Instala dependΓͺncias essenciais (incluindo curl para healthcheck) # hadolint ignore=DL3008 @@ -20,7 +20,8 @@ RUN apt-get update -qq && \ ENV RAILS_ENV=production \ BUNDLE_DEPLOYMENT=1 \ BUNDLE_PATH=/usr/local/bundle \ - BUNDLE_WITHOUT=development:test + BUNDLE_WITHOUT=development:test \ + MAKEFLAGS="-j2" WORKDIR /app @@ -33,13 +34,18 @@ FROM base AS build RUN apt-get update -qq && \ apt-get install --no-install-recommends -y \ build-essential \ - git && \ + git \ + gfortran \ + libopenblas-dev && \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* COPY Gemfile Gemfile.lock ./ -RUN bundle install --jobs 4 --retry 3 && \ - rm -rf /usr/local/bundle/ruby/*/cache && \ +# Pin bundler to lockfile version to avoid reinstall on each build +RUN gem install bundler -v "$(grep -A1 'BUNDLED WITH' Gemfile.lock | tail -1 | tr -d ' ')" --no-document + +RUN --mount=type=cache,id=prostaff-bundle,target=/usr/local/bundle/cache \ + bundle install --jobs 4 --retry 3 && \ rm -rf /usr/local/bundle/ruby/*/bundler/gems/*/.git COPY . . diff --git a/Gemfile b/Gemfile index c16bb0b..b994184 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby '3.4.5' +ruby '3.4.8' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem 'rails', '~> 7.2.3', '>= 7.2.3.1' @@ -30,6 +30,9 @@ gem 'redis', '~> 5.0' # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem 'bcrypt', '~> 3.1.7' +# Argon2id password hashing (OWASP recommended, PHC winner 2015) +gem 'argon2', '~> 2.3' + # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] @@ -42,8 +45,8 @@ gem 'bootsnap', require: false # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible gem 'rack-cors' -# JWT for authentication -gem 'jwt' +# JWT for authentication β€” >= 3.2.0 fixes inadequate authentication CVE +gem 'jwt', '>= 3.2.0' # Serializers for API responses gem 'blueprinter' @@ -56,8 +59,8 @@ gem 'sidekiq-scheduler' gem 'dotenv-rails' # HTTP client for Riot API -gem 'faraday' -gem 'faraday-retry' +gem 'faraday', '>= 2.14.2' +gem 'faraday-retry', '>= 2.4.0' # Authorization gem 'pundit' @@ -77,13 +80,13 @@ gem 'rswag-api' gem 'rswag-ui' # Elasticsearch client (for analytics queries) -gem 'elasticsearch', '~> 9.1', '>= 9.1.3' +gem 'elasticsearch', '~> 9.0', '>= 9.0.0' # Meilisearch β€” full-text search for players, organizations, scouting targets, etc. gem 'meilisearch', '~> 0.33' # LLM Integration for Support Chatbot -gem 'ruby-openai', '~> 7.0' +gem 'ruby-openai', '~> 8.0', '>= 8.0.0' # S3-compatible storage for file uploads (Supabase Storage) gem 'aws-sdk-s3', '~> 1.0' @@ -115,6 +118,10 @@ group :development do gem 'rubocop-rails' gem 'rubocop-rspec' + # Security tooling β€” runs locally and in CI via bundle exec + gem 'brakeman', require: false + gem 'bundler-audit', '~> 0.9' + # Deploy tools (only needed for deployment operations, not runtime) gem 'kamal', '~> 2.0' end diff --git a/Gemfile.lock b/Gemfile.lock index dd68b9c..5b72d9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -74,11 +74,14 @@ GEM minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) + argon2 (2.3.3) + ffi (~> 1.15) + ffi-compiler (~> 1.0) ast (2.4.3) aws-eventstream (1.4.0) aws-partitions (1.1229.0) @@ -107,10 +110,15 @@ GEM blueprinter (1.2.1) bootsnap (1.18.6) msgpack (~> 1.2) + brakeman (8.0.4) + racc builder (3.3.0) bullet (8.1.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) cgi (0.5.1) concurrent-ruby (1.3.6) connection_pool (2.5.5) @@ -135,15 +143,16 @@ GEM railties (>= 6.1) drb (2.2.3) ed25519 (1.4.0) - elastic-transport (8.4.1) + elastic-transport (8.5.1) faraday (< 3) multi_json - elasticsearch (9.2.0) + elasticsearch (9.4.0) elastic-transport (~> 8.3) - elasticsearch-api (= 9.2.0) - elasticsearch-api (9.2.0) + elasticsearch-api (= 9.4.0) + elasticsearch-api (9.4.0) + base64 multi_json - erb (6.0.2) + erb (6.0.4) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -155,7 +164,7 @@ GEM railties (>= 6.1.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -163,8 +172,12 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.2) net-http (~> 0.5) - faraday-retry (2.3.2) + faraday-retry (2.4.0) faraday (~> 2.0) + ffi (1.17.4-x86_64-linux-gnu) + ffi-compiler (1.4.2) + ffi (>= 1.15.5) + rake fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) @@ -188,11 +201,11 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.19.2) + json (2.19.5) json-schema (5.2.2) addressable (~> 2.8) bigdecimal (~> 3.1) - jwt (3.1.2) + jwt (3.2.0) base64 kamal (2.11.0) activesupport (>= 7.0) @@ -240,13 +253,13 @@ GEM mini_mime (1.1.5) minitest (5.27.0) msgpack (1.8.0) - multi_json (1.18.0) + multi_json (1.21.1) multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.3) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) @@ -261,7 +274,7 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) numo-narray (0.9.2.1) ostruct (0.6.3) @@ -295,7 +308,7 @@ GEM rack-cors (3.0.0) logger rack (>= 3.0.14) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -404,7 +417,7 @@ GEM rubocop-rspec (3.7.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) - ruby-openai (7.4.0) + ruby-openai (8.3.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) @@ -465,22 +478,25 @@ PLATFORMS DEPENDENCIES annotate + argon2 (~> 2.3) aws-sdk-s3 (~> 1.0) bcrypt (~> 3.1.7) blueprinter bootsnap + brakeman bullet + bundler-audit (~> 0.9) connection_pool (< 3.0) database_cleaner-active_record debug dotenv-rails - elasticsearch (~> 9.1, >= 9.1.3) + elasticsearch (~> 9.0, >= 9.0.0) factory_bot_rails faker - faraday - faraday-retry + faraday (>= 2.14.2) + faraday-retry (>= 2.4.0) hashid-rails (~> 1.0) - jwt + jwt (>= 3.2.0) kamal (~> 2.0) kaminari lograge @@ -503,7 +519,7 @@ DEPENDENCIES rubocop rubocop-rails rubocop-rspec - ruby-openai (~> 7.0) + ruby-openai (~> 8.0, >= 8.0.0) securerandom shoulda-matchers sidekiq (~> 7.0) @@ -514,7 +530,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.5p51 + ruby 3.4.8p72 BUNDLED WITH - 2.3.27 + 2.6.9 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 863cbef..00480a3 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,21 @@ ```
+ +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/30bf4e093ece4ceb8ea46dbe7aecdee1)](https://app.codacy.com/gh/Bulletdev/prostaff-api/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FBulletdev%2Fprostaff-api.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FBulletdev%2Fprostaff-api?ref=badge_shield&issueType=license) +[![Snyk Container Scan](https://img.shields.io/github/actions/workflow/status/Bulletdev/prostaff-api/snyk-container.yml?style=plastic&logo=snyk&logoColor=4B45A1&labelColor=white&label=Snyk)](https://github.com/Bulletdev/prostaff-api/actions/workflows/snyk-container.yml) [![Security Scan](https://github.com/Bulletdev/prostaff-api/actions/workflows/security-scan.yml/badge.svg)](https://github.com/Bulletdev/prostaff-api/actions/workflows/security-scan.yml) [![CodeQL](https://github.com/Bulletdev/prostaff-api/actions/workflows/codeql.yml/badge.svg)](https://github.com/Bulletdev/prostaff-api/actions/workflows/codeql.yml) -[![Codacy Badge](https://app.codacy.com/project/badge/Grade/30bf4e093ece4ceb8ea46dbe7aecdee1)](https://app.codacy.com/gh/Bulletdev/prostaff-api/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FBulletdev%2Fprostaff-api.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FBulletdev%2Fprostaff-api?ref=badge_shield&issueType=license) -[![Ruby Version](https://img.shields.io/badge/ruby-3.4.5-CC342D?logo=ruby)](https://www.ruby-lang.org/) -[![Rails Version](https://img.shields.io/badge/rails-7.2-CC342D?logo=rubyonrails)](https://rubyonrails.org/) + +[![Ruby Version](https://img.shields.io/badge/ruby-3.4.8-CC342D?logo=ruby)](https://www.ruby-lang.org/) +[![Rails Version](https://img.shields.io/badge/rails-7.2.3.1-CC342D?logo=rubyonrails)](https://rubyonrails.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-14+-blue.svg?logo=postgresql)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-6+-red.svg?logo=redis)](https://redis.io/) [![Swagger](https://img.shields.io/badge/API-Swagger-85EA2D?logo=swagger)](http://localhost:3333/api-docs) -[![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](http://creativecommons.org/licenses/by-nc-sa/4.0/) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
@@ -32,7 +35,7 @@ β•‘ PROSTAFF API β€” Ruby on Rails 7.2 (API-Only) β•‘ ╠══════════════════════════════════════════════════════════════════════════════╣ β•‘ Backend for the ProStaff.gg esports team management platform. β•‘ -β•‘ 200+ documented endpoints Β· JWT Auth Β· Modular Monolith Β· p95 ~500ms β•‘ +β•‘ 200+ documented endpoints Β· JWT Auth Β· Modular Monolith Β· p95 ~200ms β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• ``` @@ -44,6 +47,7 @@ ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ [β– ] JWT Authentication β€” Refresh tokens + token blacklisting β”‚ +β”‚ [β– ] Argon2id Password Hashingβ€” OWASP preferred Β· lazy migration from bcryptβ”‚ β”‚ [β– ] HashID URLs β€” Base62 encoding for obfuscated URLs β”‚ β”‚ [β– ] Swagger Docs β€” 200+ endpoints documented interactively β”‚ β”‚ [β– ] Riot Games API β€” Automatic match and player import β”‚ @@ -52,19 +56,27 @@ β”‚ [β– ] VOD Review System β€” Collaborative timestamp annotations β”‚ β”‚ [β– ] Schedule Management β€” Matches, scrims and team events β”‚ β”‚ [β– ] Goal Tracking β€” Performance goals (team and players) β”‚ -β”‚ [β– ] Competitive Module β€” PandaScore integration + draft analysis β”‚ +β”‚ [β– ] Competitive Module β€” PandaScore + ES match detail + H2H β”‚ +β”‚ [β– ] Match Detail View β€” Per-game picks, KDA, gold, CS, DMG from ES β”‚ +β”‚ [β– ] Pro Match Data Lake β€” 97K+ games (2014-2026) in Elasticsearch β”‚ +β”‚ [β– ] Multi-League Backfill β€” CBLOL Β· Academy Β· CD auto-sync daily β”‚ β”‚ [β– ] Scrims Management β€” Opponent tracking + analytics β”‚ β”‚ [β– ] Strategy Module β€” Draft planning + tactical boards β”‚ +β”‚ [β– ] AI Pick Recommendations β€” Champion2Vec + XGBoost, 97K+ game dataset β”‚ β”‚ [β– ] Meta Intelligence β€” Build aggregation, champion/item analytics β”‚ β”‚ [β– ] Support System β€” Ticketing + staff dashboard + FAQ β”‚ β”‚ [β– ] Global Search β€” Meilisearch full-text search across models β”‚ +β”‚ [β– ] Search Fallback β€” PostgreSQL ILIKE fallback when Meili offlineβ”‚ β”‚ [β– ] Real-time Messaging β€” Action Cable WebSocket team chat β”‚ β”‚ [β– ] Background Jobs β€” Sidekiq for async background processing β”‚ +β”‚ [β– ] Circuit Breaker β€” Riot API isolation (3-state, Redis-backed) β”‚ +β”‚ [β– ] Async Audit Log β€” Non-blocking audit trail via Sidekiq job β”‚ +β”‚ [β– ] Response Cache Layer β€” Redis cache on 6 endpoints (TTL 5–30 min) β”‚ β”‚ [β– ] Security Hardened β€” OWASP Top 10, Brakeman, Semgrep, CodeQL, ZAPβ”‚ β”‚ [β– ] Rate Limiting β€” Rack::Attack: 5 rules + Retry-After headers β”‚ -β”‚ [β– ] High Performance β€” p95: ~500ms Β· cached: ~50ms β”‚ +β”‚ [β– ] High Performance β€” p95: ~200ms prod Β· cached: ~50ms Β· >60% hit β”‚ β”‚ [β– ] Modular Monolith β€” Scalable modular architecture β”‚ -β”‚ [β– ] Observability β€” /health/live + /health/ready + Sidekiq mon. β”‚ +β”‚ [β– ] Observability β€” /health+/live /health/ready + cache metrics β”‚ β”‚ [β– ] 401 Rate Spike Detection β€” Sliding-window middleware, alerts at >5% β”‚ β”‚ [β– ] Job Heartbeat Tracking β€” Stale scheduled job detection via Redis β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ @@ -168,10 +180,10 @@ open http://localhost:3333/api-docs ╔══════════════════════╦════════════════════════════════════════════════════╗ β•‘ LAYER β•‘ TECNOLOGY β•‘ ╠══════════════════════╬════════════════════════════════════════════════════╣ -β•‘ Language β•‘ Ruby 3.4.5 β•‘ -β•‘ Framework β•‘ Rails 7.2.0 (API-only mode) β•‘ +β•‘ Language β•‘ Ruby 3.4.8 β•‘ +β•‘ Framework β•‘ Rails 7.2.3.1 (API-only mode) β•‘ β•‘ Database β•‘ PostgreSQL 14+ β•‘ -β•‘ Authentication β•‘ JWT (access + refresh tokens) β•‘ +β•‘ Authentication β•‘ JWT (access + refresh tokens) + Argon2id hashing β•‘ β•‘ URL Obfuscation β•‘ HashID with Base62 encoding β•‘ β•‘ Background Jobs β•‘ Sidekiq β•‘ β•‘ Caching β•‘ Redis (port 6380) β•‘ @@ -181,41 +193,16 @@ open http://localhost:3333/api-docs β•‘ Serialization β•‘ Blueprinter β•‘ β•‘ Full-text Search β•‘ Meilisearch β•‘ β•‘ Real-time β•‘ Action Cable (WebSocket) β•‘ +β•‘ Data Lake β•‘ Elasticsearch 8 (97K+ pro games, all leagues) β•‘ +β•‘ ML Service β•‘ Python 3.11 Β· FastAPI Β· XGBoost Β· Gensim Word2Vec β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•©β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• ``` --- -## 03 Β· Architecture -This API follows a **modular monolith** architecture: - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MODULE β”‚ RESPONSIBILITY β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ core β”‚ Shared base classes, concerns and constants β”‚ -β”‚ authentication β”‚ User auth and authorization β”‚ -β”‚ admin β”‚ Organization, audit log and admin player management β”‚ -β”‚ dashboard β”‚ Dashboard statistics and metrics β”‚ -β”‚ players β”‚ Player management, rosters and statistics β”‚ -β”‚ scouting β”‚ Player scouting and talent discovery β”‚ -β”‚ analytics β”‚ Performance, competitive draft, tournament & opponentβ”‚ -β”‚ matches β”‚ Match data and statistics β”‚ -β”‚ schedules β”‚ Event and schedule management β”‚ -β”‚ vod_reviews β”‚ Video review and timestamp management β”‚ -β”‚ team_goals β”‚ Goal setting and tracking β”‚ -β”‚ riot_integration β”‚ Riot Games API integration β”‚ -β”‚ competitive β”‚ PandaScore integration, pro matches, draft analysis β”‚ -β”‚ meta_intelligence β”‚ Build aggregation, champion/item meta analytics β”‚ -β”‚ scrims β”‚ Scrim management and opponent team tracking β”‚ -β”‚ strategy β”‚ Draft planning and tactical board system β”‚ -β”‚ support β”‚ Support ticket system with staff dashboard and FAQ β”‚ -β”‚ messaging β”‚ Real-time team chat via Action Cable WebSocket β”‚ -β”‚ search β”‚ Global full-text search powered by Meilisearch β”‚ -β”‚ notifications β”‚ In-app notification system β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` +## 03 Β· Architecture + ### Architecture @@ -448,12 +435,13 @@ graph TB ### Prerequisites ``` -[βœ“] Ruby 3.4.5+ +[βœ“] Ruby 3.4.8+ [βœ“] PostgreSQL 14+ [βœ“] Redis 6+ ``` -### Installation +
+β–Ά Installation (click to expand) **1. Clone the repository:** ```bash @@ -501,6 +489,8 @@ rails server ``` > API available at `http://localhost:3333` +
+ --- @@ -704,7 +694,7 @@ curl -X POST http://localhost:3333/api/v1/auth/refresh \ #### Riot Integration - `GET /riot-integration/sync-status` β€” Get sync status for all players -#### Competitive (PandaScore Integration) +#### Competitive (PandaScore + Elasticsearch) - `GET /competitive-matches` β€” List competitive matches - `GET /competitive-matches/:id` β€” Get competitive match details - `GET /competitive/pro-matches` β€” List all pro matches @@ -713,11 +703,15 @@ curl -X POST http://localhost:3333/api/v1/auth/refresh \ - `GET /competitive/pro-matches/past` β€” Get past pro matches - `POST /competitive/pro-matches/refresh` β€” Refresh pro matches from PandaScore - `POST /competitive/pro-matches/import` β€” Import specific pro match +- `GET /competitive/pro-matches/match-preview` β€” Per-game picks + stats for a recent series (ES) +- `GET /competitive/pro-matches/es-series` β€” H2H series history between two teams (ES) - `POST /competitive/draft-comparison` β€” Compare team compositions - `GET /competitive/meta/:role` β€” Get meta champions by role - `GET /competitive/composition-winrate` β€” Get composition winrate statistics - `GET /competitive/counters` β€” Get champion counter suggestions +> `match-preview` and `es-series` query the Elasticsearch data lake (97K+ games) and are league-agnostic. They accept `?team1=&team2=&league=&limit=` query params. + #### Scrims Management - `GET /scrims/scrims` β€” List all scrims - `GET /scrims/scrims/:id` β€” Get scrim details @@ -734,6 +728,49 @@ curl -X POST http://localhost:3333/api/v1/auth/refresh \ - `DELETE /scrims/opponent-teams/:id` β€” Delete opponent team - `GET /scrims/opponent-teams/:id/scrim-history` β€” Get scrim history with opponent +#### AI Intelligence + +> Requires Tier 1 (Professional) subscription β€” `predictive_analytics` feature gate. + +- `POST /ai/draft/analyze` β€” Analyze a saved draft plan (synergy, counter, risk, readiness) +- `POST /ai/recommend-pick` β€” Top-5 ML champion recommendations for a partial draft + +**Request** (`/ai/recommend-pick`): +```json +{ + "our_picks": ["Jinx", "Thresh", "Azir"], + "opponent_picks": ["Caitlyn", "Nautilus", "Syndra", "Renekton", "Graves"], + "our_bans": ["Corki"], + "opponent_bans": ["Zeri"], + "patch": "16.08", + "league": "LCK" +} +``` + +**Response**: +```json +{ + "data": { + "source": "ml_v2", + "model_version": "v2", + "recommendations": [ + { + "champion": "Lissandra", + "score": 0.5219, + "win_probability": 0.553, + "synergy_score": 0.3557, + "counter_score": 0.3252, + "reasoning_tokens": ["high win probability (55%)", "decent synergy with current picks"] + } + ] + } +} +``` + +Response header `X-AI-Source: ml_v2` (XGBoost) or `X-AI-Source: legacy` (DraftSuggester fallback when ML service is unreachable). + +The ML service (`prostaff-ml`) is a FastAPI container trained on 97K+ competitive matches using Champion2Vec embeddings (64D, Gensim Word2Vec) and an XGBoost classifier with 327 features. Training pipeline: `extract_features β†’ train_champion2vec β†’ train_win_probability β†’ validate β†’ export`. See [`prostaff-ml`](https://github.com/bulletdev/prostaff-ml). + #### Strategy Module - `GET /strategy/draft-plans` β€” List draft plans - `GET /strategy/draft-plans/:id` β€” Get draft plan details @@ -777,6 +814,24 @@ curl -X POST http://localhost:3333/api/v1/auth/refresh \ - `POST /support/staff/tickets/:id/assign` β€” Assign ticket to staff (staff only) - `POST /support/staff/tickets/:id/resolve` β€” Resolve ticket (staff only) +#### Tournaments (ArenaBR) +- `GET /tournaments` β€” List active tournaments (public) +- `GET /tournaments/:id` β€” Show tournament with full bracket (public) +- `POST /tournaments` β€” Create tournament (admin only) +- `PATCH /tournaments/:id` β€” Update tournament (admin only) +- `POST /tournaments/:id/generate_bracket` β€” Generate 16-team double-elimination bracket (admin only) +- `GET /tournaments/:id/teams` β€” List enrolled teams with roster snapshot (public) +- `POST /tournaments/:id/teams` β€” Enroll organization as team +- `PATCH /tournaments/:id/teams/:team_id/approve` β€” Approve enrollment + lock roster (admin only) +- `PATCH /tournaments/:id/teams/:team_id/reject` β€” Reject enrollment (admin only) +- `DELETE /tournaments/:id/teams/:team_id` β€” Withdraw team (own org, before bracket) +- `GET /tournaments/:id/matches` β€” List all bracket matches (public) +- `GET /tournaments/:id/matches/:match_id` β€” Show match detail with checkin status +- `POST /tournaments/:id/matches/:match_id/checkin` β€” Captain confirms presence +- `GET /tournaments/:id/matches/:match_id/report` β€” Get report status +- `POST /tournaments/:id/matches/:match_id/report` β€” Submit result report with evidence +- `POST /tournaments/:id/matches/:match_id/report/admin_resolve` β€” Admin resolves dispute (admin only) + #### Global Search - `GET /search?q=:query` β€” Full-text search across players, organizations, scouting targets, opponent teams and FAQs @@ -799,10 +854,14 @@ GET /health/ready β€” Readiness probe: checks PostgreSQL + Redis + M Returns 200 (ok/disabled) or 503 (any dep unreachable). Use for load balancer traffic routing. -GET /api/v1/monitoring/sidekiq β€” Admin only. Full Sidekiq snapshot: - queue depths, worker count, dead queue, retry queue, - scheduled job heartbeats (stale detection), alert flags. - Returns 503 if Redis unavailable. +GET /api/v1/monitoring/sidekiq β€” Admin only. Full Sidekiq snapshot: + queue depths, worker count, dead queue, retry queue, + scheduled job heartbeats (stale detection), alert flags. + Returns 503 if Redis unavailable. + +GET /api/v1/monitoring/cache_stats β€” Admin only. Real-time cache hit rate: + total reads, hits, misses, hit_rate (%). + Counters persist in Redis, reset on Redis flush. ``` > **Monitoring endpoint response includes:** @@ -921,12 +980,25 @@ open coverage/index.html β•‘ PERFORMANCE BENCHMARKS β•‘ ╠══════════════════╦════════════════════╣ β•‘ p(95) Docker β•‘ ~880ms β•‘ -β•‘ p(95) Prod est. β•‘ ~500ms β•‘ +β•‘ p(95) Prod est. β•‘ <200ms(target) β•‘ β•‘ With cache β•‘ ~50ms β•‘ +β•‘ Cache hit rate β•‘ >60%(after warmup)β•‘ β•‘ Error rate β•‘ 0% β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•©β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• ``` +**Cached endpoints** (Redis, org-scoped, bypass on filter params): + +| Endpoint | TTL | Invalidation | +|---|---|---| +| `GET /players` | 5 min | `after_commit` on Player | +| `GET /players/:id` | 5 min | After Riot sync | +| `GET /matches` | 5 min | `after_commit` on Match | +| `GET /analytics/performance` | 15 min | After Match sync | +| `GET /tournaments` | 30 min | `after_commit` on Tournament | + +All cached responses include `X-Cache-Hit: true/false` header. + > See [TESTING_GUIDE.md](DOCS/tests/TESTING_GUIDE.md) and [QUICK_START.md](DOCS/setup/QUICK_START.md) --- @@ -966,23 +1038,24 @@ open coverage/index.html [βœ“] Timing oracle: login/register user enumeration [βœ“] Mass assignment: StrongParameters coverage [βœ“] CI/CD: security gates on every push + weekly CodeQL +[βœ“] Password hashing: Argon2id (m=64MiB, t=3, p=2) β€” bcrypt lazy migration on login ``` ### Security Status -**Last Audit**: 2026-03-11 +**Last Audit**: 2026-04-21 **Overall Grade**: A (all application security tests passing) **Status**: Production-ready ### Rate Limiting (Rack::Attack) -| Rule | Limit | Window | -|------|-------|--------| -| `logins/ip` | 5 requests | 20 seconds | -| `register/ip` | 3 requests | 1 hour | -| `password_reset/ip` | 5 requests | 1 hour | -| `req/ip` | 300 requests (configurable) | per period | -| `req/authenticated_user` | 1000 requests | 1 hour | +| Rule | Limit | Window | +|-------------------------|-----------------------------|-----------------------| +| `logins/ip` | 5 requests | 20 seconds | +| `register/ip` | 3 requests | 1 hour | +| `password_reset/ip` | 5 requests | 1 hour | +| `req/ip` | 300 requests (configurable) | per period | +| `req/authenticated_user`| 1000 requests | 1 hour | All 429 responses include a `Retry-After` header with the exact seconds until the window resets. @@ -1002,14 +1075,16 @@ We take security seriously. If you discover a security vulnerability, please fol --- ## 10 Β· Observability & Monitoring +
+β–Ά for details (click to expand) ### Health Probes -| Endpoint | Purpose | Returns | -|---|---|---| -| `GET /health/live` | Liveness β€” is Puma responding? | Always 200 | -| `GET /health/ready` | Readiness β€” all deps reachable? | 200 / 503 | -| `GET /up` | Legacy backward-compatible alias | 200 | +| Endpoint | Purpose | Returns | +|---------------------|----------------------------------|------------| +| `GET /health/live` | Liveness β€” is Puma responding? | Always 200 | +| `GET /health/ready` | Readiness β€” all deps reachable? | 200 / 503 | +| `GET /up` | Legacy backward-compatible alias | 200 | > **Rule**: never point the liveness probe at an endpoint that checks Redis or DB. > A Redis crash β†’ liveness fail β†’ container restart β†’ reconnect storm β†’ worse incident. @@ -1019,6 +1094,10 @@ We take security seriously. If you discover a security vulnerability, please fol ```bash # Requires admin Bearer token curl -H "Authorization: Bearer $TOKEN" https://api.prostaff.gg/api/v1/monitoring/sidekiq + +# Cache hit rate +curl -H "Authorization: Bearer $TOKEN" https://api.prostaff.gg/api/v1/monitoring/cache_stats +# { "reads": 4200, "hits": 2730, "misses": 1470, "hit_rate": "65.0%" } ``` Response shape: @@ -1049,6 +1128,28 @@ Response shape: | `degraded` | queue > 100, dead > 10, or any scheduled job stale | | `critical` | no Sidekiq workers running | +### Circuit Breaker β€” Riot API + +`CircuitBreakerService` protects the Riot API integration from cascade failures. +State persists in Redis (shared across all Puma workers and Sidekiq threads). + +``` +closed (normal) β€” requests pass through; failure count incremented on error +open (tripped) β€” requests rejected immediately (<100ms); no upstream call +half-open (recovery)β€” one probe request allowed; success closes, failure re-opens +``` + +| Parameter | Default | Env override | +|-------------------|----------------------|-----------------------------| +| Failure threshold | 5 consecutive errors | `CIRCUIT_BREAKER_THRESHOLD` | +| Recovery timeout | 60 seconds | β€” | + +Log events emitted on state transitions: +``` +[CIRCUIT_BREAKER] Circuit riot_api OPENED after 5 consecutive failures +[CIRCUIT_BREAKER] Circuit riot_api CLOSED after recovery +``` + ### 401 Rate Spike Detection `Middleware::AuthFailureTracker` counts 401s vs total requests using Redis @@ -1079,14 +1180,28 @@ AUTH_TRACKER_WINDOW=5 # default: 5 minutes SIDEKIQ_QUEUE_ALERT_THRESHOLD=100 # queue depth that triggers degraded SIDEKIQ_DEAD_ALERT_THRESHOLD=10 # dead queue size that triggers degraded ``` +
--- ## 11 Β· Deployment +### Ecosystem + +This API is one service in the ProStaff ecosystem. The other services it integrates with: + +| Service | Stack | Role | +|-----------------------------------------------------|------------------|-----------------------------------------------------------------------------------------------------------| +| [prostaff-events](https://github.com/bulletdev/prostaff-events) | Elixir / Phoenix 1.7 | Real-time event bus β€” subscribes to Redis pub/sub and pushes via Phoenix Channels | +| [prostaff-riot-gateway](https://github.com/bulletdev/prostaff-gateway) | Go 1.23 | Riot API proxy β€” token bucket rate limiting, L1/L2 cache, circuit breaker | +| [ProStaff-Scraper](https://github.com/bulletdev/ProStaff-Scraper) | Python / FastAPI | Pro match data pipeline β€” Leaguepedia + Oracle's Elixir β†’ Elasticsearch | +|πŸ”’prostaff-ml | Python 3.11 / FastAPI | ML service β€” Champion2Vec + XGBoost pick recommendations (serves `POST /ai/recommend-pick`) | +|πŸ”’prostaff-analytics-hub | Next.js 15 / vinext | Frontend SPA β€” consumes API (also: https://prostaff.gg, https://scrims.lol) + ### Deployment Architecture ```mermaid + graph TB subgraph "Clients" FrontendApp["ProStaff.gg
Front + TypeScript SPA"] @@ -1095,74 +1210,169 @@ graph TB subgraph "Production β€” Coolify" Traefik["Traefik
TLS + Let's Encrypt
WebSocket proxy"] + CoolifyNode["Coolify
Deploys & Manages
all production services"] end subgraph "Rails β€” Puma" - Cable["Action Cable
WebSocket /cable
(team chat)"] + Cable["Action Cable
WSS /cable
(team chat)"] Router["Rails Router
REST API v1
200+ endpoints"] - Sidekiq["Sidekiq
Background Workers
(Riot sync + reindex)"] + Sidekiq["Sidekiq
Background Workers
(sync + backfill)"] + end + + subgraph "prostaff-events β€” Elixir/Phoenix" + PhoenixEndpoint["Phoenix Endpoint
WSS /socket
(domain events)"] + RedisSub["RedisSubscriber
PSUBSCRIBE prostaff:events:*"] + InhouseQ["InhouseQueue
GenServer per active queue"] + end + + subgraph "prostaff-riot-gateway β€” Go" + Gateway["Riot Gateway :4444
Token bucket Β· L1/L2 cache
Circuit breaker"] + end + + subgraph "prostaff-ml β€” Python/FastAPI" + MlService["ML Service :8001
Champion2Vec + XGBoost
POST /recommend Β· /win-probability"] + MlModels[("Models
champion2vec.bin
win_probability_v2.pkl")] + end + + subgraph "prostaff-scraper β€” Python/FastAPI" + ScraperApi["Scraper API :8000
GET /health Β· /matches Β· /status"] + ScraperCron["scraper-cron
polls LoL Esports API
(every SYNC_INTERVAL_HOURS)"] + Enrichment["enrichment daemon
Leaguepedia + Riot
(items/runes/KDA)"] + Backfill["backfill daemon
historical Leaguepedia
(2013 β†’ present)"] end subgraph "Data" PG[("PostgreSQL")] RD[("Redis")] Meili[("Meilisearch")] + ES[("Elasticsearch\n97K+ pro games")] end subgraph "External APIs" RiotAPI["Riot Games API"] PandaScore["PandaScore API"] + Grid.gg["Grid.gg"] + LoLEsports["LoL Esports API"] + Leaguepedia["Leaguepedia
(lol.fandom.com)"] end + %% === ConexΓ΅es === FrontendApp -- "HTTPS REST" --> Traefik FrontendApp -- "WSS /cable" --> Traefik + FrontendApp -- "WSS /socket" --> Traefik PlayerPortal -- "HTTPS REST" --> Traefik Traefik -- "HTTP" --> Router Traefik -- "WS upgrade /cable" --> Cable + Traefik -- "WS upgrade /socket" --> PhoenixEndpoint Router -- "reads / writes" --> PG Router -- "cache Β· JWT blacklist" --> RD Router -- "full-text search" --> Meili + Router -- "publish prostaff:events:*" --> RD + Router -- "match detail Β· H2H" --> ES + Router -. "internal JWT
(internal only)" .-> Gateway + Cable -- "pub/sub" --> RD + Sidekiq -- "async jobs" --> PG Sidekiq -- "queue Β· cache" --> RD Sidekiq -- "reindex docs" --> Meili + Sidekiq -- "historical backfill" --> ES + Sidekiq -. "internal JWT
(internal only)" .-> Gateway - Router -- "player data" --> RiotAPI - Sidekiq -- "match + profile sync" --> RiotAPI - Router -- "pro matches" --> PandaScore + RedisSub -- "PSUBSCRIBE" --> RD + RedisSub --> InhouseQ + RedisSub --> PhoenixEndpoint + Gateway -- "rate limited" --> RiotAPI + Router -- "pro matches" --> PandaScore + Router -- "pro matches" --> Grid.gg + Router -. "HTTP POST /recommend
(fallback: DraftSuggester)" .-> MlService + MlService --- MlModels + + ScraperCron -- "indexes new games" --> ES + ScraperCron -- "polls events" --> LoLEsports + Enrichment -- "enriches KDA/items" --> ES + Enrichment -- "items/runes/KDA" --> Leaguepedia + Backfill -- "historical backfill" --> ES + Backfill -- "historical data" --> Leaguepedia + ScraperApi -- "reads / status" --> ES + + %% === Estilos === style FrontendApp fill:#1e88e5 style PlayerPortal fill:#5c6bc0 style Traefik fill:#1565c0 + style CoolifyNode fill:#0d47a1, stroke:#ffffff, stroke-width:3px style Cable fill:#b1003e style Sidekiq fill:#b1003e + style PhoenixEndpoint fill:#4B275F + style RedisSub fill:#4B275F + style InhouseQ fill:#4B275F + style Gateway fill:#00ADD8 style PG fill:#336791 style RD fill:#d82c20 style Meili fill:#ff5722 + style ES fill:#005571 style RiotAPI fill:#eb0029 - style PandaScore fill:#ff6b35 + style PandaScore fill:#B069DB + style Grid.gg fill:#000000 + style LoLEsports fill:#c89b3c + style Leaguepedia fill:#8a6914 + style MlService fill:#1a6b3a + style MlModels fill:#0f3d22 + style ScraperApi fill:#3d6b1a + style ScraperCron fill:#2d5010 + style Enrichment fill:#2d5010 + style Backfill fill:#2d5010 + ``` +> - **[View in Mermaid Live Editor](https://mermaidviewer.com/diagrams/_3ywx5nr73X6VrQF9XEn7)** + +### Scheduled Jobs (Sidekiq Scheduler) + +``` +╔══════════════════════════════╦═══════════════╦═══════════════════════════════════════════╗ +β•‘ Job β•‘ Schedule β•‘ Description β•‘ +╠══════════════════════════════╬═══════════════╬═══════════════════════════════════════════╣ +β•‘ CleanupExpiredTokensJob β•‘ 0 2 * * * β•‘ Purge expired JWT blacklist + pwd tokens β•‘ +β•‘ RefreshMetadataViewsJob β•‘ 0 */2 * * * β•‘ Refresh DB metadata materialized views β•‘ +β•‘ HistoricalBackfillJob β•‘ 0 4 * * * β•‘ CBLOL: Leaguepedia β†’ ES β†’ DB β•‘ +β•‘ HistoricalBackfillJob β•‘ 30 4 * * * β•‘ CBLOL Academy: Leaguepedia β†’ ES β†’ DB β•‘ +β•‘ HistoricalBackfillJob β•‘ 0 5 * * * β•‘ Circuito Desafiante: Leaguepedia β†’ ES β•‘ +β•‘ ScrimResultReminderJob β•‘ 0 10 * * * β•‘ Send deadline reminders, expire reports β•‘ +β•‘ RebuildChampionMatrixJob β•‘ 0 3 * * * β•‘ Rebuild AI champion matrices/vectors β•‘ +β•‘ StatusSnapshotJob β•‘ */15 * * * * β•‘ Record component health snapshots β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•©β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•©β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` + +> Backfill jobs are resumable β€” re-running skips already-completed tournaments. First run imports full history (~8-12h); subsequent runs only process new/failed tournaments (minutes). + **Production Stack (Coolify):** - **Reverse Proxy**: Traefik with automatic TLS (Let's Encrypt) -- **WebSocket Support**: Native WebSocket proxy for Action Cable - **Application**: Rails 7.2 API (Puma) + Action Cable + Sidekiq -- **Database**: PostgreSQL 14+ (Supabase) +- **Event Bus**: prostaff-events β€” Elixir/Phoenix 1.7 (domain events via Phoenix Channels) +- **Riot Gateway**: prostaff-riot-gateway β€” Go 1.23 (token bucket, L1/L2 cache, circuit breaker) +- **Database**: PostgreSQL 14+ (Supabase self-hosted) - **Cache/Queue**: Redis 7 - **Search**: Meilisearch (self-hosted) +- **Data Lake**: Elasticsearch 8 (self-hosted, 97K+ pro games) **Data Flow:** 1. Clients connect via HTTPS/WSS through Traefik -2. REST requests β†’ Rails Router β†’ PostgreSQL/Redis/Meilisearch -3. WebSocket connections β†’ Action Cable β†’ Redis (pub/sub) -4. Background jobs β†’ Sidekiq β†’ PostgreSQL/Redis/Meilisearch -5. External API calls β†’ Riot Games API / PandaScore API +2. REST requests β†’ Rails Router β†’ PostgreSQL / Redis / Meilisearch / Elasticsearch +3. Team chat WebSocket β†’ Action Cable β†’ Redis pub/sub +4. Domain event WebSocket β†’ prostaff-events (Phoenix Channels) ← Redis `PSUBSCRIBE prostaff:events:*` ← Rails +5. Riot API calls β†’ prostaff-riot-gateway (rate limiter + cache) β†’ Riot Games API +6. Background jobs β†’ Sidekiq β†’ PostgreSQL / Redis / Meilisearch / Elasticsearch / Gateway --- ### Environment Variables +
+β–Ά Environments (click to expand) + ```bash # Core @@ -1175,6 +1385,8 @@ JWT_SECRET_KEY=your-production-secret # External APIs RIOT_API_KEY=your-riot-api-key +RIOT_GATEWAY_URL=http://riot-gateway:4444 # prostaff-riot-gateway internal URL +INTERNAL_JWT_SECRET=your-internal-jwt-secret # shared with prostaff-riot-gateway (must match) PANDASCORE_API_KEY=your-pandascore-api-key # Frontend @@ -1190,7 +1402,27 @@ SIDEKIQ_QUEUE_ALERT_THRESHOLD=100 # queue depth β†’ degraded SIDEKIQ_DEAD_ALERT_THRESHOLD=10 # dead queue β†’ degraded AUTH_TRACKER_THRESHOLD=0.05 # 401 rate spike threshold (5%) AUTH_TRACKER_WINDOW=5 # sliding window in minutes + +# Circuit breaker (optional, defaults shown) +CIRCUIT_BREAKER_THRESHOLD=5 # consecutive failures before opening circuit + +# Elasticsearch data lake +ELASTICSEARCH_URL=https://user:password@elastic.example.com # ES 8.x with basic auth + +# ML AI Service (prostaff-ml FastAPI container) +# Local dev: http://localhost:8001 | Coolify production: http://ai-service:8001 +AI_SERVICE_URL=http://ai-service:8001 + +# Historical backfill (Sidekiq scheduled jobs β€” override per-job via sidekiq.yml kwargs) +BACKFILL_LEAGUE=CBLOL # default league for manual runs +BACKFILL_OUR_TEAM=paiN Gaming # team name used in sync step +BACKFILL_MIN_YEAR=2013 # earliest year to import +BACKFILL_SYNC_LIMIT=500 # max matches synced per job run +SIDEKIQ_CONCURRENCY=10 # Sidekiq thread count (keep DB_POOL equal) +DB_POOL=10 # ActiveRecord pool size for Sidekiq container ``` +
+ ### Docker @@ -1205,15 +1437,16 @@ docker run -p 3333:3000 prostaff-api ### CI/CD Workflows -| Workflow | Trigger | What it does | -|----------|---------|-------------| -| `security-scan.yml` | Push / PR to master | Brakeman, Bundle Audit, Semgrep, TruffleHog, SSRF + auth + SQLi runtime tests | -| `codeql.yml` | Push / PR to master + Saturdays 3am | CodeQL `security-extended` on Ruby + Actions workflows; SARIF to GitHub Security tab | -| `nightly-security.yml` | Manual dispatch | Full audit: Brakeman + Bundle Audit + ZAP baseline + ZAP API scan | -| `load-test.yml` | Nightly + manual | k6 smoke/load/stress tests | -| `deploy-production.yml` | Push to master | Build, test, deploy to Coolify + CORS smoke test post-deploy | -| `deploy-staging.yml` | Push to develop | Same pipeline targeting staging | -| `update-architecture-diagram.yml` | Changes in `app/`, `config/routes.rb`, `Gemfile` | Auto-regenerates Mermaid diagram and commits | +| Workflow | Trigger | What it does | +|------------------------|-----------------------------------------|-------------------------------------------------------------------------------| +| `security-scan.yml` | Push / PR β†’ master, develop | Brakeman, Bundle Audit, Semgrep, TruffleHog, SSRF + auth + SQLi runtime tests | +| `codeql.yml` | Push / PR β†’ master + Saturdays 3am UTC | CodeQL `security-extended` + Actions workflows; SARIF to GitHub Security tab | +| `nightly-security.yml` | Nightly 1am UTC + manual dispatch | Full audit: Brakeman + Bundle Audit + ZAP baseline + ZAP API scan | +| `load-test.yml` | Manual dispatch | k6 smoke/load/stress tests | +| `snyk-container.yml` | Push / PR β†’ master, develop + weekly | Snyk container image vulnerability scan | +| `deploy-production.yml`| Push tag `v*.*.*` + manual dispatch | Build, test, deploy to Coolify + CORS smoke test post-deploy | +| `deploy-staging.yml` | Push β†’ develop + manual dispatch | Same pipeline targeting staging | +| `update-architecture-diagram.yml` Push / PR + manual dispatch | Auto-regenerates Mermaid diagram and commits | ### CodeQL Analysis @@ -1310,20 +1543,11 @@ We follow [Ruby Style Guide](https://rubystyle.guide/) and enforce code quality β•‘ This repository contains the official ProStaff.gg API source code. β•‘ β•‘ Released under: β•‘ β•‘ β•‘ -β•‘ Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International β•‘ +β•‘ GNU Affero General Public License v3.0 (AGPLv3) β•‘ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• ``` -[![CC BY-NC-SA 4.0][cc-by-nc-sa-shield]][cc-by-nc-sa] - -This work is licensed under a -[Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License][cc-by-nc-sa]. - -[![CC BY-NC-SA 4.0][cc-by-nc-sa-image]][cc-by-nc-sa] - -[cc-by-nc-sa]: http://creativecommons.org/licenses/by-nc-sa/4.0/ -[cc-by-nc-sa-image]: https://licensebuttons.net/l/by-nc-sa/4.0/88x31.png -[cc-by-nc-sa-shield]: https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg +This project is licensed under the [GNU Affero General Public License v3.0](LICENSE). --- diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 344f51f..c7a85ed 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -11,49 +11,86 @@ module ApplicationCable # wss://api.prostaff.gg/cable?token= # # On success, sets: - # - current_user β†’ the authenticated User record - # - current_org_id β†’ organization_id extracted from the user + # - current_user β†’ the authenticated User record (nil for player tokens) + # - current_player β†’ the authenticated Player record (nil for user tokens) + # - current_org_id β†’ organization_id extracted from the token # # On failure, calls reject_unauthorized_connection. class Connection < ActionCable::Connection::Base - identified_by :current_user, :current_org_id + identified_by :current_user, :current_player, :current_org_id def connect - self.current_user = find_verified_user - self.current_org_id = current_user.organization_id + payload = decode_token + route_by_token_type(payload) end private - def find_verified_user # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def decode_token token = request.params[:token] - reject_unauthorized_connection if token.blank? payload = JwtService.decode(token) - # Only accept access tokens β€” reject refresh tokens if payload[:type] != 'access' logger.warn "[ActionCable] Rejected non-access token type: #{payload[:type]}" reject_unauthorized_connection end - user = User.find_by(id: payload[:user_id]) + payload + rescue JwtService::AuthenticationError => e + logger.warn "[ActionCable] JWT rejected: #{e.message}" + reject_unauthorized_connection + end - if user.nil? - logger.warn "[ActionCable] User not found for token user_id=#{payload[:user_id]}" - reject_unauthorized_connection + def route_by_token_type(payload) + if payload[:entity_type] == 'player' + authenticate_player_connection(payload) + else + authenticate_user_connection(payload) end + end - unless user.organization_id.present? - logger.warn "[ActionCable] User #{user.id} has no organization β€” rejected" - reject_unauthorized_connection - end + def authenticate_user_connection(payload) + user = find_user(payload[:user_id]) + validate_organization!(user.organization_id, label: "User #{user.id}") + self.current_user = user + self.current_player = nil + self.current_org_id = user.organization_id logger.info "[ActionCable] Connected: user=#{user.id} org=#{user.organization_id}" - user - rescue JwtService::AuthenticationError => e - logger.warn "[ActionCable] JWT rejected: #{e.message}" + end + + def authenticate_player_connection(payload) + player = find_player(payload[:player_id]) + validate_organization!(player.organization_id, label: "Player #{player.id}") + + self.current_user = nil + self.current_player = player + self.current_org_id = player.organization_id + logger.info "[ActionCable] Connected: player=#{player.id} org=#{player.organization_id}" + end + + def find_user(user_id) + user = User.find_by(id: user_id) + return user if user + + logger.warn "[ActionCable] User not found for token user_id=#{user_id}" + reject_unauthorized_connection + end + + def find_player(player_id) + player = Player.unscoped.find_by(id: player_id, player_access_enabled: true) + return player if player + + logger.warn "[ActionCable] Player not found or access disabled: player_id=#{player_id}" + reject_unauthorized_connection + end + + def validate_organization!(org_id, label:) + return if org_id.present? + + logger.warn "[ActionCable] #{label} has no organization β€” rejected" reject_unauthorized_connection end end diff --git a/app/controllers/api/v1/feedbacks_controller.rb b/app/controllers/api/v1/feedbacks_controller.rb index 17f982a..8b074be 100644 --- a/app/controllers/api/v1/feedbacks_controller.rb +++ b/app/controllers/api/v1/feedbacks_controller.rb @@ -59,8 +59,9 @@ def vote def set_feedback # Feedback is a public board β€” all authenticated users can vote on any item. - # Intentionally unscoped. nosemgrep: ruby.rails.security.brakeman.check-unscoped-find.check-unscoped-find - @feedback = Feedback.find(params[:id]) + # Intentionally cross-org: users vote on any feedback regardless of their org. + # nosemgrep: ruby.rails.security.brakeman.check-unscoped-find.check-unscoped-find + @feedback = Feedback.find(params[:id]) # brakeman:ignore:UnscopedFind end def feedback_params diff --git a/app/controllers/api/v1/images_controller.rb b/app/controllers/api/v1/images_controller.rb index f7691c3..5144d31 100644 --- a/app/controllers/api/v1/images_controller.rb +++ b/app/controllers/api/v1/images_controller.rb @@ -15,14 +15,17 @@ module V1 # GET /api/v1/images/proxy?url=https://upload.wikimedia.org/... # Headers: { Authorization: "Bearer " } class ImagesController < BaseController - # SECURITY: Removed skip_before_action - authentication now required + # ALLOWED_DOMAINS + HTTPS-only + SSRF protection are sufficient guards; + # JWT auth is skipped because browsers cannot attach Authorization headers to src requests. + skip_before_action :authenticate_request!, only: [:proxy] ALLOWED_DOMAINS = [ 'upload.wikimedia.org', 'ddragon.leagueoflegends.com', 'raw.communitydragon.org', 'static.wikia.nocookie.net', - 'commons.wikimedia.org' + 'commons.wikimedia.org', + 'cdn-api.pandascore.co' ].freeze HTTP_TIMEOUT_OPTIONS = { open_timeout: 5, read_timeout: 10 }.freeze diff --git a/app/controllers/api/v1/monitoring_controller.rb b/app/controllers/api/v1/monitoring_controller.rb index 9379405..cfc0443 100644 --- a/app/controllers/api/v1/monitoring_controller.rb +++ b/app/controllers/api/v1/monitoring_controller.rb @@ -32,6 +32,31 @@ class MonitoringController < BaseController { name: 'Authentication::CleanupExpiredTokensJob', interval_hours: 24, alert_after_hours: 25 } ].freeze + # GET /api/v1/monitoring/cache_stats + # + # Returns Redis-backed cache hit rate counters incremented by the + # cache_instrumentation initializer on every cache read. + # + # @return [JSON] { reads, hits, misses, hit_rate } + def cache_stats + redis = Rails.cache.redis + reads = redis.call('GET', 'metrics:cache:reads').to_i + hits = redis.call('GET', 'metrics:cache:hits').to_i + misses = redis.call('GET', 'metrics:cache:misses').to_i + rate = reads.positive? ? (hits.to_f / reads * 100).round(2) : 0.0 + + render json: { + reads: reads, + hits: hits, + misses: misses, + hit_rate: "#{rate}%", + timestamp: Time.current.iso8601 + } + rescue StandardError => e + Rails.logger.error("[CACHE] Failed to read cache stats: #{e.message}") + render json: { error: 'Cache stats unavailable' }, status: :service_unavailable + end + # GET /api/v1/monitoring/sidekiq # # Returns a snapshot of Sidekiq operational state including queue depths, diff --git a/app/controllers/api/v1/organizations_controller.rb b/app/controllers/api/v1/organizations_controller.rb index 023b00f..7ecde69 100644 --- a/app/controllers/api/v1/organizations_controller.rb +++ b/app/controllers/api/v1/organizations_controller.rb @@ -55,6 +55,21 @@ def upload_logo ) end + # PATCH /api/v1/organizations/:id/lines + def update_lines + lines = Array(params[:enabled_lines]).select { |l| l.in?(Constants::Player::LINES) } + + if lines.empty? + return render_error(message: 'At least one valid line is required', code: 'VALIDATION_ERROR', + status: :unprocessable_entity) + end + + lines = (['main'] | lines).uniq + @organization.update!(enabled_lines: lines) + + render json: { message: 'Roster lines updated', enabled_lines: @organization.enabled_lines }, status: :ok + end + private def set_organization @@ -63,10 +78,10 @@ def set_organization end def require_admin_or_owner - return if %w[admin owner].include?(@current_user.role) + return if %w[admin owner coach].include?(@current_user.role) render_error( - message: 'Only admins and owners can update organization settings', + message: 'Only coaches, admins and owners can update organization settings', code: 'FORBIDDEN', status: :forbidden ) diff --git a/app/controllers/api/v1/wallet_controller.rb b/app/controllers/api/v1/wallet_controller.rb new file mode 100644 index 0000000..e83f9dc --- /dev/null +++ b/app/controllers/api/v1/wallet_controller.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'net/http' + +module Api + module V1 + # WalletController + # + # Transparent proxy between ArenaBR frontend and the ProPay service. + # All requests are forwarded with the caller's Authorization header so + # ProPay can validate the same JWT (shared INTERNAL_JWT_SECRET is NOT + # used here β€” the user/player Bearer token is passed through as-is). + # + # Authentication is enforced by BaseController before any action runs. + # + # @example Get wallet balance + # GET /api/v1/wallet + # Authorization: Bearer + # + # @example Submit a deposit + # POST /api/v1/wallet/deposit + # Authorization: Bearer + # Idempotency-Key: + # Body: { "amount": 5000 } + class WalletController < BaseController + # Returns the current user's wallet (balance, currency, status). + # + # @return [JSON] Proxied response from ProPay + def show + proxy_to_propay(:get, '/v1/wallet') + end + + # Returns a paginated list of wallet transactions. + # + # @return [JSON] Proxied response from ProPay + def transactions + proxy_to_propay(:get, '/v1/wallet/transactions') + end + + # Initiates a deposit request (PIX or other method). + # + # @return [JSON] Proxied response from ProPay + def deposit + ensure_propay_customer! + proxy_to_propay( + :post, + '/v1/wallet/deposit', + body: request.raw_post, + idempotency_key: request.headers['Idempotency-Key'] + ) + end + + # Returns the status of a specific charge by txid. + # + # @param txid [String] The transaction ID (URL param) + # @return [JSON] Proxied response from ProPay + def charge_status + proxy_to_propay(:get, "/v1/charges/#{params[:txid]}") + end + + # Creates a payout request. + # + # @return [JSON] Proxied response from ProPay + def create_payout + proxy_to_propay( + :post, + '/v1/wallet/payouts', + body: request.raw_post, + idempotency_key: request.headers['Idempotency-Key'] + ) + end + + # Returns the status of a specific payout. + # + # @param id [String] The payout ID (URL param) + # @return [JSON] Proxied response from ProPay + def payout_status + proxy_to_propay(:get, "/v1/wallet/payouts/#{params[:id]}") + end + + private + + # Registers the current user as a ProPay customer (find-or-create). + # Called before any action that requires a Customer record in ProPay. + # Errors are logged but not raised β€” the downstream call surfaces them. + # + # @return [void] + def ensure_propay_customer! + propay_url = ENV.fetch('PROPAY_URL', 'http://propay:5555') + uri = URI("#{propay_url}/v1/customers") + + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 10 + + headers = { + 'Content-Type' => 'application/json', + 'Authorization' => request.headers['Authorization'] + } + req = Net::HTTP::Post.new(uri.request_uri, headers) + req.body = { full_name: current_user.full_name, email: current_user.email }.to_json + http.request(req) + rescue StandardError => e + Rails.logger.warn("[WALLET] ensure_propay_customer! failed: #{e.message}") + end + + # Forwards the request to ProPay and renders the response verbatim. + # + # @param method [Symbol] HTTP method (:get or :post) + # @param path [String] ProPay endpoint path + # @param body [String, nil] Raw request body (JSON string) + # @param idempotency_key [String, nil] Value for Idempotency-Key header + # @return [void] + def proxy_to_propay(method, path, body: nil, idempotency_key: nil) + propay_url = ENV.fetch('PROPAY_URL', 'http://propay:5555') + uri = URI("#{propay_url}#{path}") + + http = build_http_client(uri) + http_request = build_http_request(method, uri, body, idempotency_key) + + response = http.request(http_request) + parsed = parse_propay_body(response.body) + render json: parsed, status: response.code.to_i + rescue Net::OpenTimeout, Net::ReadTimeout + render json: { error: { message: 'ProPay timeout' } }, status: :gateway_timeout + rescue StandardError => e + Rails.logger.error("[WALLET] ProPay proxy error for #{path}: #{e.message}") + render json: { error: { message: e.message } }, status: :bad_gateway + end + + # Parses a ProPay response body, returning a fallback hash on invalid JSON. + # + # @param body [String] Raw response body + # @return [Hash] + def parse_propay_body(body) + JSON.parse(body) + rescue JSON::ParserError + Rails.logger.error("[WALLET] ProPay returned non-JSON body: #{body.to_s.truncate(200)}") + { 'error' => 'ProPay returned an invalid response' } + end + + # Builds a configured Net::HTTP instance. + # + # @param uri [URI] Target URI + # @return [Net::HTTP] + def build_http_client(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 10 + http + end + + # Builds the HTTP request object with all required headers. + # + # @param method [Symbol] :get or :post + # @param uri [URI] Target URI + # @param body [String, nil] Raw JSON body + # @param idempotency_key [String, nil] Idempotency-Key header value + # @return [Net::HTTPRequest] + def build_http_request(method, uri, body, idempotency_key) + req_class = http_method_class(method) + http_request = req_class.new(uri.request_uri, build_headers(idempotency_key)) + http_request.body = body if body.present? + http_request + end + + # Maps a symbol to a Net::HTTP request class. + # + # @param method [Symbol] :get or :post + # @return [Class] + def http_method_class(method) + { get: Net::HTTP::Get, post: Net::HTTP::Post }.fetch(method) + end + + # Builds the forwarded headers hash. + # + # @param idempotency_key [String, nil] + # @return [Hash] + def build_headers(idempotency_key) + headers = { + 'Content-Type' => 'application/json', + 'Authorization' => request.headers['Authorization'], + 'User-Agent' => 'prostaff-api/1.0' + } + headers['Idempotency-Key'] = idempotency_key if idempotency_key.present? + headers + end + end + end +end diff --git a/app/controllers/concerns/authenticatable.rb b/app/controllers/concerns/authenticatable.rb index 7353fee..47af3c4 100644 --- a/app/controllers/concerns/authenticatable.rb +++ b/app/controllers/concerns/authenticatable.rb @@ -14,7 +14,7 @@ module Authenticatable private - def authenticate_request! # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def authenticate_request! token = extract_token_from_header if token.nil? @@ -22,56 +22,63 @@ def authenticate_request! # rubocop:disable Metrics/AbcSize, Metrics/MethodLengt return end - begin - @jwt_payload = JwtService.decode(token) - - if @jwt_payload[:entity_type] == 'player' - # ── Player token ────────────────────────────────────────────────────── - # Free agents (auto-cadastro via ArenaBR) tΓͺm organization_id: nil - @current_player = Player.unscoped.find(@jwt_payload[:player_id]) - - org_id = @jwt_payload[:organization_id] - @current_organization = org_id.present? ? Organization.find(org_id) : nil - - Current.organization_id = @current_organization&.id - org_label = @current_organization&.id || 'free_agent' - Rails.logger.info("[AUTH] Player token: player_id=#{@current_player.id} org=#{org_label}") - return - end - - # ── Regular user token ──────────────────────────────────────────────── - # Bypass RLS for authentication queries - we need to find the user before we can set RLS context - @current_user = User.unscoped.find(@jwt_payload[:user_id]) - @current_organization = @current_user.organization - - # Set request-scoped attributes for OrganizationScoped models (thread-safe) - Current.organization_id = @current_organization.id - Current.user_id = @current_user.id - Current.user_role = @current_user.role - - # Debug log in production to verify Current is being set - Rails.logger.info("[AUTH] Set Current.organization_id=#{Current.organization_id} for user #{@current_user.email}") - - # Update last login time (uses update_column which skips callbacks/audit logs) - @current_user.update_last_login! if should_update_last_login? - rescue JwtService::AuthenticationError => e - Rails.logger.error("JWT Authentication error: #{e.class} - #{e.message}") - render_unauthorized(e.message) - rescue ActiveRecord::RecordNotFound => e - Rails.logger.error("User not found during authentication: #{e.message}") - render_unauthorized('User not found') - rescue StandardError => e - Rails.logger.error("Unexpected authentication error: #{e.class} - #{e.message}") - Rails.logger.error(e.backtrace.join("\n")) - render json: { - error: { - code: 'INTERNAL_ERROR', - message: 'An internal error occurred' - } - }, status: :internal_server_error + perform_authentication(token) + end + + def perform_authentication(token) + @jwt_payload = JwtService.decode(token) + raise JwtService::TokenInvalidError, 'Invalid token type' unless valid_access_token_type?(@jwt_payload) + + dispatch_token_authentication + rescue JwtService::AuthenticationError => e + Rails.logger.error("JWT Authentication error: #{e.class} - #{e.message}") + render_unauthorized(e.message) + rescue ActiveRecord::RecordNotFound => e + Rails.logger.error("User not found during authentication: #{e.message}") + render_unauthorized('User not found') + rescue StandardError => e + handle_unexpected_auth_error(e) + end + + def dispatch_token_authentication + # Reject refresh tokens used as access tokens. + # Refresh tokens carry type: 'refresh' and must never authenticate a request. + # Player access tokens carry entity_type: 'player' AND type: 'access'. + if @jwt_payload[:entity_type] == 'player' + authenticate_player_token + else + authenticate_user_token end end + def handle_unexpected_auth_error(error) + Rails.logger.error("Unexpected authentication error: #{error.class} - #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + render json: { error: { code: 'INTERNAL_ERROR', message: 'An internal error occurred' } }, + status: :internal_server_error + end + + def authenticate_player_token + # Free agents (auto-cadastro via ArenaBR) tΓͺm organization_id: nil + @current_player = Player.unscoped.find(@jwt_payload[:player_id]) + org_id = @jwt_payload[:organization_id] + @current_organization = org_id.present? ? Organization.find(org_id) : nil + Current.organization_id = @current_organization&.id + org_label = @current_organization&.id || 'free_agent' + Rails.logger.info("[AUTH] Player token: player_id=#{@current_player.id} org=#{org_label}") + end + + def authenticate_user_token + # Bypass RLS for authentication queries - we need to find the user before we can set RLS context + @current_user = User.unscoped.find(@jwt_payload[:user_id]) + @current_organization = @current_user.organization + Current.organization_id = @current_organization.id + Current.user_id = @current_user.id + Current.user_role = @current_user.role + Rails.logger.info("[AUTH] Set Current.organization_id=#{Current.organization_id} for user #{@current_user.email}") + @current_user.update_last_login! if should_update_last_login? + end + def extract_token_from_header auth_header = request.headers['Authorization'] return nil unless auth_header @@ -145,6 +152,18 @@ def set_current_organization # This method can be overridden in controllers if needed end + # Returns true only for tokens that are valid for authenticating API requests. + # + # Refresh tokens (type: 'refresh') must be rejected even if they are otherwise + # well-formed and not expired. Player access tokens carry entity_type: 'player' + # AND type: 'access'; user access tokens carry type: 'access'. + # + # @param payload [HashWithIndifferentAccess] Decoded JWT payload + # @return [Boolean] + def valid_access_token_type?(payload) + payload[:type] == 'access' + end + def should_update_last_login? return false unless @current_user return true if @current_user.last_login_at.nil? diff --git a/app/controllers/concerns/cacheable.rb b/app/controllers/concerns/cacheable.rb new file mode 100644 index 0000000..da20e81 --- /dev/null +++ b/app/controllers/concerns/cacheable.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Provides lightweight HTTP-level response caching for controller actions. +# +# The cache is skipped entirely when query parameters are present (filters, +# search terms, pagination) so that parameterised requests always hit the +# database and receive accurate results. +# +# A response header `X-Cache-Hit: true/false` is set on every eligible request +# so that clients and reverse proxies can observe cache behaviour. +# +# Cache keys are organisation-scoped to preserve multi-tenant isolation. +# +# @example Cache the index action for 5 minutes +# class PlayersController < Api::V1::BaseController +# include Cacheable +# +# def index +# data = cache_response('players', expires_in: 5.minutes) do +# PlayerSerializer.render_as_hash(organization_scoped(Player).all) +# end +# render_success(players: data) +# end +# end +module Cacheable + extend ActiveSupport::Concern + + # Fetches the value from the Rails cache or executes the block and stores + # the result. Caching is bypassed when any non-routing params are present. + # + # @param key [String] short identifier appended to the org-scoped cache key + # @param expires_in [ActiveSupport::Duration] cache TTL (default 5 minutes) + # @yield the block whose return value will be cached + # @return [Object] cached or freshly computed value + def cache_response(key, expires_in: 5.minutes, &block) + return block.call if params.except(:controller, :action, :format).keys.any? + + cache_key = build_cache_key(key) + cache_hit = Rails.cache.exist?(cache_key) + response.set_header('X-Cache-Hit', cache_hit.to_s) + + Rails.cache.fetch(cache_key, expires_in: expires_in, &block) + end + + # Deletes one or more org-scoped cache keys. + # Use in after_action callbacks on mutating actions. + # + # @param keys [Array] keys to invalidate (same identifiers passed to cache_response) + def invalidate_cache(*keys) + keys.each { |key| Rails.cache.delete(build_cache_key(key)) } + end + + private + + # Builds an organisation-scoped cache key to prevent cross-tenant leakage. + # Falls back to 'public' scope for unauthenticated actions (e.g. tournament index). + # + # @param key [String] action-specific key segment + # @return [String] full namespaced cache key + def build_cache_key(key) + org_segment = current_organization&.id || 'public' + "v1:#{org_segment}:#{key}" + end +end diff --git a/app/controllers/concerns/internal_service_authenticatable.rb b/app/controllers/concerns/internal_service_authenticatable.rb new file mode 100644 index 0000000..8498e9c --- /dev/null +++ b/app/controllers/concerns/internal_service_authenticatable.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module InternalServiceAuthenticatable + extend ActiveSupport::Concern + + included do + before_action :authenticate_internal_service! + end + + private + + def authenticate_internal_service! + token = request.headers['Authorization']&.delete_prefix('Bearer ') + expected = ENV.fetch('INTERNAL_JWT_SECRET', nil) + + return if expected.present? && token.present? && + ActiveSupport::SecurityUtils.secure_compare(token, expected) + + render json: { error: 'unauthorized' }, status: :unauthorized + end +end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index f4e3b2b..703318d 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -42,7 +42,8 @@ def ready checks = { database: check_database, redis: check_redis, - meilisearch: check_meilisearch + meilisearch: check_meilisearch, + events_service: check_events_service } # 'disabled' means the service is not configured (expected in some environments). @@ -99,6 +100,29 @@ def check_redis { status: 'error', message: e.message } end + # Pings the prostaff-events Phoenix service GET /health endpoint. + # Non-critical: events service being down does not break Rails (Redis is the transport). + # Reports as disabled if PHOENIX_EVENTS_ENABLED is not set to 'true'. + # + # @return [Hash] { status: 'ok'|'disabled'|'error', message: String } + def check_events_service + return { status: 'disabled', message: 'prostaff-events not enabled' } unless ENV['PHOENIX_EVENTS_ENABLED'] == 'true' + + events_url = ENV['PHOENIX_EVENTS_URL'].presence || 'http://localhost:4000' + + conn = Faraday.new { |f| f.options.timeout = 2 } + response = conn.get("#{events_url}/health") + + if response.success? + { status: 'ok' } + else + { status: 'error', message: "HTTP #{response.status}" } + end + rescue StandardError => e + Rails.logger.warn "[HealthCheck] prostaff-events check failed: #{e.message}" + { status: 'error', message: e.message } + end + # Calls Meilisearch /health to confirm the search service is reachable. # Non-critical: if Meilisearch is disabled (no URL), reports as disabled rather than error. # diff --git a/app/controllers/internal/organizations_controller.rb b/app/controllers/internal/organizations_controller.rb new file mode 100644 index 0000000..488b9fb --- /dev/null +++ b/app/controllers/internal/organizations_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Internal + # Internal API controller for updating organization tier and subscription data. + # Called exclusively by the ProPay payment gateway via a signed internal JWT. + class OrganizationsController < ActionController::API + include InternalServiceAuthenticatable + + ALLOWED_TIERS = Constants::Organization::TIERS + ALLOWED_PLANS = Constants::Organization::SUBSCRIPTION_PLANS + ALLOWED_STATUSES = Constants::Organization::SUBSCRIPTION_STATUSES + + def update_tier + user = User.find_by(id: params[:user_id]) + return render json: { error: 'user not found' }, status: :not_found unless user + + org = user.organization + return render json: { error: 'organization not found' }, status: :not_found unless org + + tier = params[:tier].to_s + plan = params[:subscription_plan].to_s + status = params[:subscription_status].to_s + + unless ALLOWED_TIERS.include?(tier) + return render json: { error: "invalid tier: #{tier}" }, status: :unprocessable_entity + end + + unless ALLOWED_PLANS.include?(plan) + return render json: { error: "invalid subscription_plan: #{plan}" }, status: :unprocessable_entity + end + + unless ALLOWED_STATUSES.include?(status) + return render json: { error: "invalid subscription_status: #{status}" }, status: :unprocessable_entity + end + + org.update!(tier: tier, subscription_plan: plan, subscription_status: status) + + render json: { + data: { + id: org.id, + tier: org.tier, + subscription_plan: org.subscription_plan, + subscription_status: org.subscription_status + } + } + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/sitemap_controller.rb b/app/controllers/sitemap_controller.rb index c5ef733..e7c8745 100644 --- a/app/controllers/sitemap_controller.rb +++ b/app/controllers/sitemap_controller.rb @@ -10,8 +10,6 @@ def index @base_url = ENV.fetch('APP_URL', 'https://prostaff.gg') @current_time = Time.current.iso8601 - respond_to do |format| - format.xml { render template: 'sitemap/index', layout: false } - end + render template: 'sitemap/index', layout: false, content_type: 'application/xml' end end diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb index 0ed662f..52d124a 100644 --- a/app/controllers/status_controller.rb +++ b/app/controllers/status_controller.rb @@ -15,36 +15,38 @@ class StatusController < ActionController::API }.freeze def index - components = build_component_statuses - incidents = build_incidents - uptime = build_uptime_history - indicator, description = overall_status(components) + cached = Rails.cache.fetch('status_page/v2', expires_in: 30.seconds) do + components = build_component_statuses + incidents = build_incidents + uptime = build_uptime_history + indicator, description = overall_status(components) + + { + status: { indicator: indicator, description: description }, + components: components, + incidents: incidents, + uptime_history: uptime + } + end - render json: { + render json: cached.merge( page: { id: 'prostaff', name: 'ProStaff', url: 'https://status.prostaff.gg', time_zone: 'UTC', updated_at: Time.current.iso8601 - }, - status: { - indicator: indicator, - description: description - }, - components: components, - incidents: incidents, - uptime_history: uptime - }, status: :ok + } + ), status: :ok end private def build_component_statuses - StatusIncident::COMPONENTS.map do |component| - snapshot = StatusSnapshot.for_component(component).order(checked_at: :desc).first + latest = StatusSnapshot.latest_per_component - if snapshot + StatusIncident::COMPONENTS.map do |component| + if (snapshot = latest[component]) build_component_from_snapshot(component, snapshot) else build_component_live(component) @@ -136,8 +138,9 @@ def serialize_incident(incident) end def build_uptime_history + bulk = StatusSnapshot.bulk_uptime_by_day(days: 90) StatusIncident::COMPONENTS.each_with_object({}) do |component, hash| - hash[component] = StatusSnapshot.uptime_by_day(component: component, days: 90) + hash[component] = bulk[component] || [] end rescue StandardError => e Rails.logger.error("[STATUS] Failed to build uptime history: #{e.message}") diff --git a/app/jobs/audit_log_job.rb b/app/jobs/audit_log_job.rb new file mode 100644 index 0000000..1360d72 --- /dev/null +++ b/app/jobs/audit_log_job.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Persists an audit log entry asynchronously so that write-heavy models +# (Player, Match, etc.) do not pay the cost of a synchronous INSERT on every +# update. +# +# Retried up to 3 times with Sidekiq's default back-off before being moved to +# the dead queue. Audit loss is preferable to blocking the request thread. +# +# @example Enqueue from a model after_update_commit callback +# AuditLogJob.perform_later( +# organization_id: organization_id, +# entity_type: 'Player', +# entity_id: id, +# old_values: saved_changes.transform_values(&:first), +# new_values: saved_changes.transform_values(&:last) +# ) +class AuditLogJob < ApplicationJob + queue_as :default + sidekiq_options retry: 3 + + # @param organization_id [String] UUID of the owning organization + # @param entity_type [String] ActiveRecord model name (e.g. 'Player') + # @param entity_id [String] UUID of the changed record + # @param old_values [Hash] attribute values before the update + # @param new_values [Hash] attribute values after the update + # @param user_id [String, nil] UUID of the user who triggered the change (optional) + def perform(organization_id:, entity_type:, entity_id:, old_values:, new_values:, user_id: nil) + Current.organization_id = organization_id + AuditLog.create!( + organization_id: organization_id, + action: 'update', + entity_type: entity_type, + entity_id: entity_id, + old_values: old_values, + new_values: new_values, + user_id: user_id + ) + ensure + Current.organization_id = nil + end +end diff --git a/app/jobs/events/event_publish_job.rb b/app/jobs/events/event_publish_job.rb new file mode 100644 index 0000000..e0cfc66 --- /dev/null +++ b/app/jobs/events/event_publish_job.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Events + # Publishes a domain event to Redis pub/sub for Phoenix to consume. + # + # Phoenix subscribes to the same Redis instance via Phoenix.PubSub Redis adapter. + # No HTTP between Rails and Phoenix β€” Redis is the transport. + # + # Queue: :events (dedicated, low priority, retry: 0) + # Stale events have no user value β€” better to drop than deliver 30s late. + class EventPublishJob < ApplicationJob + queue_as :events + sidekiq_options retry: 0 + + def perform(user_id:, org_id:, type:, payload: {}) + envelope = build_envelope(user_id: user_id, org_id: org_id, type: type, payload: payload) + channel = "#{Events::EventPublisher::REDIS_CHANNEL_PREFIX}:#{org_id}" + + Sidekiq.redis do |redis| + redis.call('PUBLISH', channel, JSON.generate(envelope)) + end + + Rails.logger.info(event: 'event_published', type: type, org_id: org_id) + rescue StandardError => e + Rails.logger.error(event: 'event_publish_error', type: type, org_id: org_id, error: e.message) + end + + private + + def build_envelope(user_id:, org_id:, type:, payload:) + { + id: SecureRandom.uuid, + type: type, + user_id: user_id, + org_id: org_id, + payload: payload, + published_at: Time.current.iso8601 + } + end + end +end diff --git a/app/jobs/inhouse_check_in_deadline_job.rb b/app/jobs/inhouse_check_in_deadline_job.rb new file mode 100644 index 0000000..9140337 --- /dev/null +++ b/app/jobs/inhouse_check_in_deadline_job.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Enforces the inhouse queue check-in deadline. +# +# Scheduled from InhouseQueuesController#start_checkin when the queue transitions +# to check_in state. Fires at check_in_deadline. +# +# Behavior at deadline: +# - Removes entries for players who did not check in. +# - If fewer than 2 checked-in players remain, closes the queue automatically. +# - Broadcasts the updated queue state via Action Cable. +# +# Scheduling: +# InhouseCheckInDeadlineJob.set(wait_until: deadline).perform_later(queue.id) +class InhouseCheckInDeadlineJob < ApplicationJob + queue_as :default + + def perform(queue_id) + queue = InhouseQueue.includes(inhouse_queue_entries: :player).find_by(id: queue_id) + return unless queue + return unless queue.check_in? + return if Time.current < queue.check_in_deadline + + process_expired_check_in(queue) + record_job_heartbeat + end + + private + + def process_expired_check_in(queue) + unchecked = queue.inhouse_queue_entries.where(checked_in: false) + removed_count = unchecked.count + unchecked.destroy_all + + checked_in_count = queue.inhouse_queue_entries.where(checked_in: true).count + + if checked_in_count < 2 + queue.update!(status: 'closed') + Rails.logger.info( + event: 'inhouse_queue_closed_deadline', + queue_id: queue.id, + org_id: queue.organization_id, + checked_in: checked_in_count, + removed: removed_count + ) + broadcast_closed(queue, removed_count) + else + Rails.logger.info( + event: 'inhouse_queue_check_in_expired', + queue_id: queue.id, + org_id: queue.organization_id, + checked_in: checked_in_count, + removed: removed_count + ) + broadcast_updated(queue, removed_count) + end + end + + def broadcast_closed(queue, removed_count) + ActionCable.server.broadcast( + "inhouse_queue_#{queue.organization_id}", + { + event: 'check_in_expired', + queue_id: queue.id, + status: 'closed', + removed_count: removed_count, + message: 'Queue closed: not enough players checked in before deadline' + } + ) + end + + def broadcast_updated(queue, removed_count) + ActionCable.server.broadcast( + "inhouse_queue_#{queue.organization_id}", + { + event: 'check_in_expired', + queue_id: queue.id, + status: queue.status, + removed_count: removed_count, + queue: queue.reload.serialize(detailed: true) + } + ) + end +end diff --git a/app/jobs/ml_health_check_job.rb b/app/jobs/ml_health_check_job.rb new file mode 100644 index 0000000..34e4839 --- /dev/null +++ b/app/jobs/ml_health_check_job.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Periodically checks the health of the ML service and logs circuit breaker status. +# +# This job uses a direct Faraday GET (not MlServiceClient) because /health is a +# read-only probe that must bypass the circuit breaker β€” it is how we decide when +# to reset it manually or alert on degraded ML availability. +# +# Scheduled at low priority so it never competes with critical path jobs. +# Configure frequency in config/sidekiq.yml or sidekiq-scheduler config. +# +# ENV vars read: +# AI_SERVICE_URL β€” base URL of the ML FastAPI service (default: http://localhost:8001) +class MlHealthCheckJob < ApplicationJob + queue_as :low_priority + sidekiq_options retry: 0 # health checks are best-effort; no retry noise + + ML_HEALTH_TIMEOUT = 2 # seconds + + def perform + check_service_health + log_circuit_status + rescue StandardError => e + Rails.logger.warn("[MlHealthCheckJob] Unexpected error during health check: #{e.message}") + end + + private + + def check_service_health + conn = Faraday.new(url: ENV.fetch('AI_SERVICE_URL', 'http://localhost:8001')) do |f| + f.options.timeout = ML_HEALTH_TIMEOUT + f.options.open_timeout = ML_HEALTH_TIMEOUT + f.adapter Faraday.default_adapter + end + + resp = conn.get('/health') + body = JSON.parse(resp.body) + + unless resp.success? + Rails.logger.warn("[MlHealthCheckJob] ML /health returned HTTP #{resp.status}") + return + end + + if body['model_loaded'] == false + Rails.logger.warn('[MlHealthCheckJob] ML service health: model_loaded=false') + else + Rails.logger.info("[MlHealthCheckJob] ML service healthy (model_loaded=#{body['model_loaded']})") + end + rescue Faraday::TimeoutError + Rails.logger.warn('[MlHealthCheckJob] ML /health timed out') + rescue Faraday::ConnectionFailed => e + Rails.logger.warn("[MlHealthCheckJob] ML /health connection failed: #{e.message}") + rescue JSON::ParserError => e + Rails.logger.warn("[MlHealthCheckJob] ML /health returned invalid JSON: #{e.message}") + end + + def log_circuit_status + open_until = Sidekiq.redis { |r| r.call('GET', MlServiceClient::CIRCUIT_OPEN_UNTIL_KEY).to_i } + + if open_until > Time.now.to_i + remaining = open_until - Time.now.to_i + Rails.logger.warn("[MlHealthCheckJob] ML circuit breaker is OPEN β€” resets in #{remaining}s") + else + failures = Sidekiq.redis { |r| r.call('GET', MlServiceClient::CIRCUIT_FAILURES_KEY).to_i } + if failures.positive? + Rails.logger.info("[MlHealthCheckJob] ML circuit CLOSED with #{failures} recent failure(s) recorded") + end + end + rescue StandardError => e + Rails.logger.warn("[MlHealthCheckJob] Could not read circuit breaker state from Redis: #{e.message}") + end +end diff --git a/app/jobs/riot_api_ping_job.rb b/app/jobs/riot_api_ping_job.rb new file mode 100644 index 0000000..d35d63f --- /dev/null +++ b/app/jobs/riot_api_ping_job.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'net/http' + +# Lightweight scheduled job that pings the Riot platform status endpoint every 6 hours. +# Purpose: keep the prostaff:job_heartbeat:RiotApiPingJob key alive in Redis so that +# StatusSnapshotJob correctly reports the Riot API as operational. +# Uses /lol/status/v4/platform-data β€” does not consume player-data rate limit quota. +class RiotApiPingJob < ApplicationJob + queue_as :low + + PING_REGION = 'br1' + PING_TIMEOUT = 10 + + def perform + api_key = ENV['RIOT_API_KEY'] + unless api_key.present? + Rails.logger.warn('[RIOT PING] RIOT_API_KEY not configured β€” skipping') + return + end + + ping_riot_status_api(api_key) + end + + private + + def ping_riot_status_api(api_key) + uri = URI("https://#{PING_REGION}.api.riotgames.com/lol/status/v4/platform-data") + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, + open_timeout: PING_TIMEOUT, + read_timeout: PING_TIMEOUT) do |http| + http.request(request) + end + + if response.is_a?(Net::HTTPSuccess) + Rails.logger.info('[RIOT PING] Riot API reachable') + record_job_heartbeat + else + Rails.logger.warn("[RIOT PING] Riot API returned #{response.code} β€” heartbeat not written") + end + rescue StandardError => e + Rails.logger.warn("[RIOT PING] Riot API unreachable: #{e.message}") + end +end diff --git a/app/jobs/rolling_auc_job.rb b/app/jobs/rolling_auc_job.rb new file mode 100644 index 0000000..2f1d1f8 --- /dev/null +++ b/app/jobs/rolling_auc_job.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Nightly job that computes a rolling AUC-ROC over the last 200 settled ml_v2 +# predictions and writes monitoring metrics to Redis for the admin dashboard. +# +# Scheduled at 03:00 UTC via sidekiq-cron (see config/sidekiq.yml or +# config/schedule.yml depending on the project setup). The job is entirely +# silent when fewer than 50 outcomes are available β€” it simply returns early +# without logging anything at warn/error level. +# +# Redis keys written: +# ml:metrics:rolling_auc β€” AUC-ROC rounded to 4 decimal places (string) +# ml:metrics:n_predictions β€” sample size used (string) +# ml:metrics:mean_win_prob β€” mean predicted probability (string) +# +# Alert thresholds: +# AUC < 0.51 β†’ model is no better than random; warn in logs +# mean < 0.48 or > 0.58 β†’ systematic probability drift; warn in logs +# +# AUC-ROC algorithm: pure Ruby trapezoidal method β€” no external gems required. +# Sort predictions by descending score, walk the list accumulating true/false +# positives, and sum the trapezoid areas. +class RollingAucJob < ApplicationJob + queue_as :low_priority + sidekiq_options retry: 0 + + MIN_SAMPLE = 50 + SAMPLE_SIZE = 200 + + def perform + logs = MlPredictionLog.with_outcome.recent(SAMPLE_SIZE).to_a + return if logs.size < MIN_SAMPLE + + y_true = logs.map { |l| l.blue_won ? 1 : 0 } + y_score = logs.map { |l| l.predicted_win_prob.to_f } + + auc = calculate_auc_roc(y_true, y_score) + mean_prob = y_score.sum / y_score.size + + persist_metrics(auc: auc.round(4), sample_size: logs.size, mean_prob: mean_prob.round(4)) + emit_alerts(auc: auc, mean_prob: mean_prob, sample_size: logs.size) + + record_job_heartbeat + rescue StandardError => e + Rails.logger.warn("[RollingAucJob] Unexpected error: #{e.message}") + end + + private + + # Trapezoidal AUC-ROC β€” O(n log n) sort + O(n) walk. + # + # Algorithm: + # 1. Sort (label, score) pairs by descending score. + # 2. Walk the list. For each positive (label == 1) increment tp. + # For each negative, the current tp covers the strip from prev_fp to fp+1 + # on the ROC curve β€” add tp * strip_width / (n_pos * n_neg). + # 3. After the loop, flush any remaining tp accumulated at the last negative. + # + # Returns a value in [0.0, 1.0]. Returns 0.5 if all labels are the same + # (degenerate case β€” AUC is undefined, 0.5 is the random baseline). + def calculate_auc_roc(y_true, y_score) + n_pos = y_true.count(1).to_f + n_neg = y_true.count(0).to_f + return 0.5 if n_pos.zero? || n_neg.zero? + + sorted = y_true.zip(y_score).sort_by { |_, score| -score } + + tp = 0 + fp = 0 + prev_fp = 0 + auc = 0.0 + + sorted.each_key do |label| + if label == 1 + tp += 1 + else + # Accumulate trapezoid area for the strip [prev_fp..fp] + auc += tp.to_f * (fp - prev_fp + 1) / (n_pos * n_neg) + prev_fp = fp + fp += 1 + end + end + + # Flush any remaining tp after the last negative + auc += tp.to_f * (fp - prev_fp) / (n_pos * n_neg) if fp > prev_fp + + [auc, 1.0].min + end + + def persist_metrics(auc:, sample_size:, mean_prob:) + Sidekiq.redis { |r| r.call('SET', 'ml:metrics:rolling_auc', auc.to_s) } + Sidekiq.redis { |r| r.call('SET', 'ml:metrics:n_predictions', sample_size.to_s) } + Sidekiq.redis { |r| r.call('SET', 'ml:metrics:mean_win_prob', mean_prob.to_s) } + rescue StandardError => e + Rails.logger.warn("[RollingAucJob] Failed to persist metrics to Redis: #{e.message}") + end + + def emit_alerts(auc:, mean_prob:, sample_size:) + Rails.logger.warn("[RollingAucJob] ML rolling AUC degraded: #{auc} (n=#{sample_size})") if auc < 0.51 + + return unless mean_prob < 0.48 || mean_prob > 0.58 + + Rails.logger.warn("[RollingAucJob] ML win prob drift: mean=#{mean_prob} (n=#{sample_size})") + end +end diff --git a/app/jobs/status_snapshot_job.rb b/app/jobs/status_snapshot_job.rb index bbed709..3e3d2a8 100644 --- a/app/jobs/status_snapshot_job.rb +++ b/app/jobs/status_snapshot_job.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'sidekiq/api' + # Records a health snapshot for every infrastructure component every 5 minutes. # Results are persisted in status_snapshots and consumed by the public status page. class StatusSnapshotJob < ApplicationJob diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 73f31b5..1502af3 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -3,4 +3,11 @@ class ApplicationMailer < ActionMailer::Base default from: ENV.fetch('MAILER_FROM_EMAIL', 'noreply@prostaff.gg') layout 'mailer' + + private + + def frontend_url_for(record) + source = record.source_app.presence || 'prostaff' + Constants::SOURCE_APP_URLS.fetch(source, ENV.fetch('PROSTAFF_URL', 'https://prostaff.gg')) + end end diff --git a/app/mailers/player_mailer.rb b/app/mailers/player_mailer.rb new file mode 100644 index 0000000..3daeaac --- /dev/null +++ b/app/mailers/player_mailer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class PlayerMailer < ApplicationMailer + def password_reset(player, reset_token, frontend_url_override = nil) + @player = player + base = frontend_url_override || frontend_url_for(player) + parsed_uri = URI.parse(base) + unless parsed_uri.is_a?(URI::HTTP) + raise ArgumentError, "Frontend URL must use http or https (got: #{parsed_uri.scheme.inspect})" + end + + @reset_url = "#{base}/reset-password?token=#{reset_token.token}" + @expires_in = ((reset_token.expires_at - Time.current) / 60).to_i + + mail(to: @player.player_email, subject: 'Redefinicao de senha - ArenaBR') + end + + def password_reset_confirmation(player) + @player = player + @frontend_url = frontend_url_for(player) + mail(to: @player.player_email, subject: 'Senha redefinida com sucesso - ArenaBR') + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 2141fec..879d534 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -1,39 +1,42 @@ # frozen_string_literal: true class UserMailer < ApplicationMailer - def password_reset(user, reset_token) + def password_reset(user, reset_token, frontend_url_override = nil) @user = user - @reset_token = reset_token - frontend_url = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') - parsed_uri = URI.parse(frontend_url) + base = frontend_url_override || frontend_url_for(user) + parsed_uri = URI.parse(base) unless parsed_uri.is_a?(URI::HTTP) - raise ArgumentError, "FRONTEND_URL must use http or https scheme (got: #{parsed_uri.scheme.inspect})" + raise ArgumentError, "Frontend URL must use http or https (got: #{parsed_uri.scheme.inspect})" end - @reset_url = "#{frontend_url}/reset-password?token=#{reset_token.token}" - @expires_in = ((reset_token.expires_at - Time.current) / 60).to_i # minutes + @reset_url = "#{base}/reset-password?token=#{reset_token.token}" + @expires_in = ((reset_token.expires_at - Time.current) / 60).to_i - mail( - to: @user.email, - subject: 'Password Reset Request - ProStaff' - ) + mail(to: @user.email, subject: 'Redefinicao de senha - ProStaff') end def password_reset_confirmation(user) @user = user - - mail( - to: @user.email, - subject: 'Password Successfully Reset - ProStaff' - ) + @frontend_url = frontend_url_for(user) + mail(to: @user.email, subject: 'Senha redefinida com sucesso - ProStaff') end def welcome(user) @user = user + @frontend_url = frontend_url_for(user) + mail(to: @user.email, subject: "Bem-vindo ao ProStaff, #{user.full_name}!") + end + + def trial_expired(user) + @user = user + @organization = user.organization + mail(to: @user.email, subject: 'Seu periodo de teste ProStaff encerrou') + end - mail( - to: @user.email, - subject: 'Welcome to ProStaff!' - ) + def trial_expiring_soon(user, days_remaining) + @user = user + @organization = user.organization + @days_remaining = days_remaining + mail(to: @user.email, subject: "Seu teste ProStaff expira em #{days_remaining} dia(s)") end end diff --git a/app/models/concerns/constants.rb b/app/models/concerns/constants.rb index 3b521a1..f4c5405 100644 --- a/app/models/concerns/constants.rb +++ b/app/models/concerns/constants.rb @@ -25,6 +25,15 @@ module Organization }.freeze end + # Source application β€” identifies which frontend originated the record + SOURCE_APPS = %w[prostaff scrims arena_br].freeze + + SOURCE_APP_URLS = { + 'prostaff' => ENV.fetch('PROSTAFF_URL', 'https://prostaff.gg'), + 'scrims' => ENV.fetch('SCRIMS_URL', 'https://scrims.lol'), + 'arena_br' => ENV.fetch('ARENA_BR_URL', 'https://arena-br.vercel.app') + }.freeze + # User roles module User ROLES = %w[owner admin coach analyst viewer].freeze @@ -42,6 +51,7 @@ module User module Player ROLES = %w[top jungle mid adc support].freeze STATUSES = %w[active inactive benched trial removed].freeze + LINES = %w[main academy farm female other].freeze QUEUE_RANKS = %w[I II III IV].freeze QUEUE_TIERS = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER].freeze diff --git a/app/models/concerns/organization_scoped.rb b/app/models/concerns/organization_scoped.rb index f149df9..59f65ad 100644 --- a/app/models/concerns/organization_scoped.rb +++ b/app/models/concerns/organization_scoped.rb @@ -12,6 +12,8 @@ module OrganizationScoped org_id = Current.organization_id if org_id.present? where(organization_id: org_id) + elsif Current.skip_organization_scope + all else # SECURITY: Fail-safe - retorna scope vazio em vez de expor dados de todas as orgs Rails.logger.error("[SECURITY] OrganizationScoped: organization_id is nil for #{name} - BLOCKING ACCESS") diff --git a/app/models/concerns/upgradeable_password.rb b/app/models/concerns/upgradeable_password.rb new file mode 100644 index 0000000..a2d663d --- /dev/null +++ b/app/models/concerns/upgradeable_password.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module UpgradeablePassword + extend ActiveSupport::Concern + + # Verifies plain_password against the stored digest and, if the digest still + # uses bcrypt, transparently re-hashes with Argon2id on the same request. + # + # @param plain_password [String] the password to verify + # @param digest_attr [Symbol] the attribute name holding the stored digest + # @param digest_setter [Symbol] the column name to write the upgraded digest + # @return [self, nil] returns self on success, nil on failure + def authenticate_with_upgrade(plain_password, digest_attr:, digest_setter:) + digest = send(digest_attr) + return nil unless Authentication::PasswordHasher.verify(plain_password, digest) + + if Authentication::PasswordHasher.needs_upgrade?(digest) + new_digest = Authentication::PasswordHasher.hash(plain_password) + # Two separate update_column calls instead of update_columns so that Rails + # dirty tracking is cleared field-by-field β€” avoids unexpected behavior on + # read-replica setups where a bulk UPDATE could interleave with pending reads. + # update_column bypasses callbacks intentionally (no before_save/after_save + # during a transparent hash upgrade). + update_column(digest_setter, new_digest) + update_column(:updated_at, Time.current) + end + + self + end +end diff --git a/app/models/current.rb b/app/models/current.rb index 21bcf64..e331280 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -3,5 +3,5 @@ # Thread-safe storage for request-scoped data # Use Current.organization_id instead of Thread.current[:organization_id] class Current < ActiveSupport::CurrentAttributes - attribute :organization_id, :user_id, :user_role + attribute :organization_id, :user_id, :user_role, :skip_organization_scope end diff --git a/app/models/organization.rb b/app/models/organization.rb index 87ce011..0f4db5f 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -40,6 +40,7 @@ class Organization < ApplicationRecord has_many :players, dependent: :destroy has_many :matches, dependent: :destroy has_many :scouting_targets, dependent: :destroy + has_many :scouting_watchlists, dependent: :destroy has_many :schedules, dependent: :destroy has_many :vod_reviews, dependent: :destroy has_many :team_goals, dependent: :destroy @@ -163,6 +164,7 @@ def check_trial_expiration def generate_slug return if slug.present? + return if name.blank? base_slug = name.parameterize counter = 1 diff --git a/app/models/password_reset_token.rb b/app/models/password_reset_token.rb index c00b0db..235863b 100644 --- a/app/models/password_reset_token.rb +++ b/app/models/password_reset_token.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true -# Secure, single-use expiring token for user password reset flows. +# Secure, single-use expiring token for password reset flows. +# Supports both User (staff) and Player (ArenaBR) via polymorphic owner. class PasswordResetToken < ApplicationRecord - belongs_to :user + belongs_to :user, optional: true + belongs_to :player, optional: true validates :token, presence: true, uniqueness: true validates :expires_at, presence: true + validate :owner_present scope :valid, -> { where('expires_at > ? AND used_at IS NULL', Time.current) } scope :expired, -> { where('expires_at <= ?', Time.current) } @@ -14,6 +17,10 @@ class PasswordResetToken < ApplicationRecord before_validation :generate_token, on: :create before_validation :set_expiration, on: :create + def owner + user || player + end + def mark_as_used! update!(used_at: Time.current) end @@ -40,6 +47,10 @@ def self.cleanup_old_tokens private + def owner_present + errors.add(:base, 'must belong to a user or a player') if user_id.nil? && player_id.nil? + end + def generate_token self.token ||= self.class.generate_secure_token end diff --git a/app/models/status_snapshot.rb b/app/models/status_snapshot.rb index ca8600b..afa8e33 100644 --- a/app/models/status_snapshot.rb +++ b/app/models/status_snapshot.rb @@ -15,29 +15,41 @@ class StatusSnapshot < ApplicationRecord scope :for_component, ->(component) { where(component: component) } # Returns daily uptime percentage for a component over the last N days. - # - # @param component [String] one of COMPONENTS - # @param days [Integer] number of days to look back (default 90) - # @return [Array] array of { date: Date, uptime_pct: Float, status: String } - # Days without snapshots are omitted from the result. def self.uptime_by_day(component:, days: 90) - rows = fetch_rows(component, days) - grouped = rows.group_by { |checked_at, _| checked_at.to_date } - grouped.map { |date, entries| aggregate_day(date, entries) } + rows = for_component(component).recent(days).order(checked_at: :asc).pluck(:checked_at, :status) + rows.group_by { |checked_at, _| checked_at.to_date } + .map { |date, entries| aggregate_day(date, entries) } end - private_class_method def self.fetch_rows(component, days) - for_component(component) - .recent(days) - .order(checked_at: :asc) - .pluck(:checked_at, :status) + # Single-query bulk version: returns { component => [{ date:, uptime_pct:, status: }] } + def self.bulk_uptime_by_day(days: 90) + rows = where(checked_at: days.days.ago..Time.current) + .order(checked_at: :asc) + .pluck(:component, :checked_at, :status) + + rows + .group_by(&:first) + .transform_values do |component_rows| + component_rows + .map { |_, checked_at, status| [checked_at, status] } + .group_by { |checked_at, _| checked_at.to_date } + .map { |date, entries| aggregate_day(date, entries) } + end + end + + # Single-query bulk version: returns { component => snapshot } for the latest per component + def self.latest_per_component + select('DISTINCT ON (component) *') + .order('component, checked_at DESC') + .index_by(&:component) end - private_class_method def self.aggregate_day(date, entries) + def self.aggregate_day(date, entries) total = entries.size ok = entries.count { |_, s| s == 'operational' } uptime_pct = (ok.to_f / total * 100).round(2) dominant = entries.map { |_, s| s }.tally.max_by { |_, c| c }&.first { date: date, uptime_pct: uptime_pct, status: dominant } end + private_class_method :aggregate_day end diff --git a/app/models/token_blacklist.rb b/app/models/token_blacklist.rb index b454c97..084b53b 100644 --- a/app/models/token_blacklist.rb +++ b/app/models/token_blacklist.rb @@ -8,6 +8,9 @@ # @attr [String] jti JWT unique identifier # @attr [DateTime] expires_at Token expiration timestamp class TokenBlacklist < ApplicationRecord + REDIS_ROTATION_PREFIX = 'jwt_rotation:' + REDIS_ROTATION_TTL = 300 # 5 minutes β€” covers the rotation window + validates :jti, presence: true, uniqueness: true validates :expires_at, presence: true @@ -24,6 +27,31 @@ def self.add_to_blacklist(jti, expires_at) nil end + # Atomically claims a refresh token jti for rotation using Rails.cache write with + # unless_exist: true (maps to Redis SET NX EX under the redis_cache_store adapter). + # + # Returns true if this caller is the first to claim the jti (safe to rotate). + # Returns false if the jti was already claimed (concurrent replay β€” reject). + # + # The key expires after REDIS_ROTATION_TTL seconds. This window covers the gap + # between the first JWT decode and the database blacklist insert in refresh_access_token. + # The database uniqueness constraint on jti is the durable last line of defense + # once the Redis key expires. + # + # Falls back to true (fail open) if Redis is completely unavailable, relying on + # the database uniqueness constraint to absorb the race window. + # + # @param jti [String] The JWT unique identifier from the refresh token payload + # @return [Boolean] true if claimed successfully, false if already claimed + def self.claim_for_rotation(jti) + key = "#{REDIS_ROTATION_PREFIX}#{jti}" + Rails.cache.write(key, '1', expires_in: REDIS_ROTATION_TTL, unless_exist: true) + rescue StandardError => e + Rails.logger.error("[AUTH] Cache unavailable for rotation claim (jti=#{jti}): #{e.message}") + # Fail open β€” database uniqueness constraint is the last line of defense + true + end + def self.cleanup_expired expired.delete_all end diff --git a/app/models/user.rb b/app/models/user.rb index 03138fa..64b9460 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,10 +2,9 @@ # Authenticated user within an organization, with role-based access and notification support. class User < ApplicationRecord - has_secure_password - # Concerns include Constants + include UpgradeablePassword # Associations belongs_to :organization @@ -21,10 +20,28 @@ class User < ApplicationRecord has_many :password_reset_tokens, dependent: :destroy has_many :messages, dependent: :nullify + # Virtual password attribute β€” set when changing password, nil otherwise. + # has_secure_password is not used; hashing is handled by Authentication::PasswordHasher. + attr_reader :password + + def password=(plain_password) + @password = plain_password.blank? ? nil : plain_password + end + + def authenticate(plain_password) + authenticate_with_upgrade( + plain_password, + digest_attr: :password_digest, + digest_setter: :password_digest + ) + end + # Validations + validates :password_digest, presence: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :full_name, presence: true, length: { maximum: 255 } validates :role, presence: true, inclusion: { in: Constants::User::ROLES } + validates :source_app, inclusion: { in: Constants::SOURCE_APPS } validates :timezone, length: { maximum: 100 } validates :language, length: { maximum: 10 } validates :discord_user_id, @@ -40,7 +57,8 @@ class User < ApplicationRecord if: -> { password.present? } # Callbacks - before_save :downcase_email + before_validation :downcase_email + before_validation :hash_password, if: -> { password.present? } after_update :log_audit_trail, if: :saved_changes? # Scopes @@ -91,6 +109,10 @@ def downcase_email self.email = email.downcase.strip if email.present? end + def hash_password + self.password_digest = Authentication::PasswordHasher.hash(password) + end + def log_audit_trail AuditLog.create!( organization: organization, diff --git a/app/modules/admin/controllers/ml_metrics_controller.rb b/app/modules/admin/controllers/ml_metrics_controller.rb new file mode 100644 index 0000000..b99dcb9 --- /dev/null +++ b/app/modules/admin/controllers/ml_metrics_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Admin + module Controllers + # Exposes rolling ML quality metrics to admin/staff users. + # + # All values are written to Redis by RollingAucJob (runs nightly at 03:00 UTC) + # and by MlHealthCheckJob (reads circuit-breaker state). + # + # GET /api/v1/admin/ml-metrics + # + # Response: + # { + # rolling_auc: Float | null, # AUC-ROC over last 200 settled predictions + # mean_win_prob: Float | null, # mean predicted probability + # n_predictions: Integer | null, # sample size used for last AUC calculation + # circuit_open: Boolean # true when ML circuit breaker is currently open + # } + # + # Returns 200 even when metrics have not been calculated yet (fields will be null). + class MlMetricsController < Api::V1::BaseController + before_action :require_admin_or_staff! + + # GET /api/v1/admin/ml-metrics + def index + metrics = read_metrics_from_redis + render_success(metrics) + rescue StandardError => e + Rails.logger.warn("[Admin::MlMetricsController] Failed to read metrics: #{e.message}") + render_success({ + rolling_auc: nil, + mean_win_prob: nil, + n_predictions: nil, + circuit_open: false + }) + end + + private + + def require_admin_or_staff! + return if current_user&.admin? || current_user&.owner? || current_user&.staff? + + render_error( + message: 'Admin or staff access required', + code: 'FORBIDDEN', + status: :forbidden + ) + end + + def read_metrics_from_redis + Sidekiq.redis do |r| + auc_raw = r.call('GET', 'ml:metrics:rolling_auc') + n_raw = r.call('GET', 'ml:metrics:n_predictions') + mean_raw = r.call('GET', 'ml:metrics:mean_win_prob') + open_until = r.call('GET', MlServiceClient::CIRCUIT_OPEN_UNTIL_KEY).to_i + + { + rolling_auc: auc_raw&.to_f, + mean_win_prob: mean_raw&.to_f, + n_predictions: n_raw&.to_i, + circuit_open: open_until > Time.now.to_i + } + end + end + end + end +end diff --git a/app/modules/admin/controllers/players_controller.rb b/app/modules/admin/controllers/players_controller.rb index dcc27d1..676b1ab 100644 --- a/app/modules/admin/controllers/players_controller.rb +++ b/app/modules/admin/controllers/players_controller.rb @@ -239,46 +239,12 @@ def change_status # Transfers a player to another organization def transfer new_organization_id = params[:new_organization_id] - reason = params[:reason] || 'Player transfer' - - unless new_organization_id.present? - return render_error( - message: 'New organization ID is required', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - end - - new_organization = Organization.find_by(id: new_organization_id) - unless new_organization - return render_error( - message: 'Organization not found', - code: 'NOT_FOUND', - status: :not_found - ) - end + new_organization = resolve_transfer_target(new_organization_id) + return unless new_organization old_org_id = @player.organization_id - - ActiveRecord::Base.transaction do - # Save current organization as previous - @player.update!(previous_organization_id: old_org_id) - - # Transfer to new organization - @player.update!(organization: new_organization, status: 'inactive') - - log_user_action( - action: 'transfer', - entity_type: 'Player', - entity_id: @player.id, - old_values: { organization_id: old_org_id }, - new_values: { - organization_id: new_organization_id, - previous_organization_id: old_org_id, - transfer_reason: reason - } - ) - end + execute_player_transfer(@player, new_organization, old_org_id, params[:reason]) + publish_player_transferred(@player, old_org_id, new_organization_id) render_success({ message: 'Player transferred successfully', @@ -296,6 +262,50 @@ def transfer private + def resolve_transfer_target(new_organization_id) + unless new_organization_id.present? + render_error(message: 'New organization ID is required', code: 'VALIDATION_ERROR', + status: :unprocessable_entity) + return nil + end + + org = Organization.find_by(id: new_organization_id) + render_error(message: 'Organization not found', code: 'NOT_FOUND', status: :not_found) unless org + org + end + + def execute_player_transfer(player, new_organization, old_org_id, reason) + ActiveRecord::Base.transaction do + player.update!(previous_organization_id: old_org_id) + player.update!(organization: new_organization, status: 'inactive') + log_user_action( + action: 'transfer', + entity_type: 'Player', + entity_id: player.id, + old_values: { organization_id: old_org_id }, + new_values: { + organization_id: new_organization.id, + previous_organization_id: old_org_id, + transfer_reason: reason || 'Player transfer' + } + ) + end + end + + def publish_player_transferred(player, old_org_id, new_organization_id) + Events::EventPublisher.publish( + user_id: current_user.id, + org_id: old_org_id, + type: 'player.transferred', + payload: { + player_id: player.id, + player_name: player.summoner_name, + from_org_id: old_org_id, + to_org_id: new_organization_id + } + ) + end + def require_admin_access return if current_user.admin? || current_user.owner? diff --git a/app/modules/admin/controllers/status_incidents_controller.rb b/app/modules/admin/controllers/status_incidents_controller.rb index b227250..34c1b6a 100644 --- a/app/modules/admin/controllers/status_incidents_controller.rb +++ b/app/modules/admin/controllers/status_incidents_controller.rb @@ -111,10 +111,7 @@ def require_admin_access end def set_incident - # StatusIncidents are platform-wide (not org-scoped) β€” intentionally unscoped. - # This endpoint requires admin or owner role (see require_admin_access before_action). - # nosemgrep: ruby.rails.security.brakeman.check-unscoped-find - @incident = StatusIncident.find(params[:id]) + @incident = StatusIncident.find(params[:id]) # brakeman:ignore:UnscopedFind # nosemgrep end def create_params diff --git a/app/modules/ai_intelligence/channels/draft_channel.rb b/app/modules/ai_intelligence/channels/draft_channel.rb index 9edca84..659428a 100644 --- a/app/modules/ai_intelligence/channels/draft_channel.rb +++ b/app/modules/ai_intelligence/channels/draft_channel.rb @@ -3,30 +3,114 @@ # WebSocket channel for real-time draft analysis. # Frontend connects with: { channel: 'DraftChannel', draft_id: '' } # Authentication is handled by ApplicationCable::Connection (JWT via ?token= query param). +# +# Security: draft_id is validated against the current user's organization. +# A user from org A cannot subscribe to org B's draft stream. class DraftChannel < ApplicationCable::Channel def subscribed - draft_id = params[:draft_id] - reject and return if draft_id.blank? + # ActionCable channels do not go through authenticate_request!, so + # Current.organization_id must be set manually for OrganizationScoped models. + Current.organization_id = current_org_id + + return if unauthorized_draft_subscription? - stream_from "draft_#{draft_id}" + stream_from "draft_#{current_org_id}_#{params[:draft_id]}" + logger.info "[DraftChannel] user=#{current_user&.id || current_player&.id} subscribed to draft=#{params[:draft_id]}" end def unsubscribed stop_all_streams end - # Client sends: { team_a: [...], team_b: [...] } + # Client sends: { team_a: [...], team_b: [...], patch: "16.08", league: "CBLOL" } def picks_updated(data) - team_a = data['team_a'].presence || [] - team_b = data['team_b'].presence || [] + return unless valid_picks_context? + team_a = Array(data['team_a']) + team_b = Array(data['team_b']) return unless team_a.any? || team_b.any? - result = DraftAnalyzer.call(team_a:, team_b:) + broadcast_ai_update(params[:draft_id], team_a, team_b, data['patch']) + rescue StandardError => e + Rails.logger.error "[DraftChannel] picks_updated error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" + end + + private + + def valid_picks_context? + params[:draft_id].present? && current_org_id.present? + end + + def broadcast_ai_update(draft_id, team_a, team_b, patch) + draft_result = DraftAnalyzer.call(team_a:, team_b:, patch:) + synergy_data = fetch_synergy_data(team_a) + top_synergies = resolve_top_synergies(synergy_data, draft_result) + top_counters = resolve_top_counters(draft_result) + patch_win_rates = fetch_patch_win_rates(team_a, team_b, patch) + + publish_ai_update(draft_id, draft_result, top_synergies, top_counters, patch_win_rates) + end + + def publish_ai_update(draft_id, draft_result, top_synergies, top_counters, patch_win_rates) + ActionCable.server.broadcast( + "draft_#{current_org_id}_#{draft_id}", + type: 'ai_update', + payload: { + win_probability: draft_result.win_probability, + confidence: draft_result.confidence, + source: draft_result.source, + low_sample: draft_result.low_sample, + top_synergies: top_synergies, + top_counters: top_counters, + suggested_picks: draft_result.suggested_picks || [], + patch_win_rates: patch_win_rates + } + ) + end + + def fetch_synergy_data(team_a) + if team_a.size >= 2 + SynergyMatrixService.call(champions: team_a) + else + { champions: team_a, matrix: [], top_pairs: [], weakest_pairs: [] } + end + end + + def resolve_top_synergies(synergy_data, draft_result) + if synergy_data[:top_pairs].any? + synergy_data[:top_pairs].first(5).map { |entry| { pair: entry[:pair], score: entry[:score] } } + else + (draft_result.synergy_scores || {}) + .sort_by { |_, val| -val[:score].to_f } + .first(5) + .map { |(champ_a, champ_b), val| { pair: [champ_a, champ_b], score: val[:score] } } + end + end + + def resolve_top_counters(draft_result) + (draft_result.counter_scores || {}) + .sort_by { |_, val| -val[:advantage].to_f.abs } + .first(5) + .map { |(champ_a, champ_b), val| { matchup: [champ_a, champ_b], advantage: val[:advantage], games: val[:games] } } + end + + def fetch_patch_win_rates(team_a, team_b, patch) + return {} unless patch.present? + + ChampionWinrateService.bulk_lookup((team_a + team_b).uniq, patch) + end + + def unauthorized_draft_subscription? + draft_id = params[:draft_id] + if draft_id.blank? || current_org_id.blank? + reject + return true + end + draft = DraftPlan.find_by(id: draft_id, organization_id: current_org_id) + return false if draft - ActionCable.server.broadcast("draft_#{params[:draft_id]}", { - type: 'ai_update', - payload: DraftAnalysisBlueprint.render_as_hash(result) - }) + logger.warn "[DraftChannel] user=#{current_user.id} unauthorized draft=#{draft_id}" + reject + true end end diff --git a/app/modules/ai_intelligence/controllers/champion_analytics_controller.rb b/app/modules/ai_intelligence/controllers/champion_analytics_controller.rb new file mode 100644 index 0000000..791880e --- /dev/null +++ b/app/modules/ai_intelligence/controllers/champion_analytics_controller.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module AiIntelligence + module Controllers + # GET /api/v1/ai/champion-analytics + # + # Returns tier classification (S/A/B/C), win rate, and trend for each + # champion in the supplied list, plus an aggregate pool_strength score. + # + # Query params: + # patch [String] e.g. "16" or "16.08" β€” optional + # team_champions[] [Array] champion names, max 20 + # + # Requires Tier 1 (Professional) subscription β€” feature: predictive_analytics. + class ChampionAnalyticsController < Api::V1::BaseController + before_action :require_predictive_analytics_access! + + # GET /api/v1/ai/champion-analytics?patch=16&team_champions[]=Azir&team_champions[]=Jinx + def index + patch = params[:patch] + champions = Array(params[:team_champions]).first(20).map(&:strip).uniq.reject(&:blank?) + + return render json: { error: 'team_champions required' }, status: :bad_request if champions.empty? + + data = build_champion_data(champions, patch) + pool_strength = calculate_pool_strength(data) + + render_success({ + patch: patch, + champions: data, + pool_strength: pool_strength, + champions_without_data: champions - data.map { |d| d[:name] } + }) + end + + private + + def build_champion_data(champions, patch) + champions.filter_map do |champ| + win_rate = ChampionWinrateService.win_rate_for(champion: champ, patch: patch) + next if win_rate.nil? + + prev_win_rate = previous_patch_win_rate(champ, patch) + { name: champ, win_rate: win_rate.round(4), tier: classify_tier(win_rate), + trend: calculate_trend(win_rate, prev_win_rate), prev_win_rate: prev_win_rate&.round(4) } + end + end + + def previous_patch_win_rate(champ, patch) + return nil unless patch.present? + + prev_patch = patch.to_s.split('.').first.to_i - 1 + ChampionWinrateService.win_rate_for(champion: champ, patch: prev_patch.to_s) + end + + def classify_tier(win_rate) + if win_rate >= 0.56 then 'S' + elsif win_rate >= 0.52 then 'A' + elsif win_rate >= 0.48 then 'B' + else + 'C' + end + end + + def calculate_trend(current_rate, previous_rate) + return 'stable' if previous_rate.nil? + return 'up' if current_rate > previous_rate + 0.02 + return 'down' if current_rate < previous_rate - 0.02 + + 'stable' + end + + def calculate_pool_strength(data) + return nil if data.empty? + + (data.sum { |d| d[:win_rate] } / data.size).round(4) + end + + def require_predictive_analytics_access! + return if current_organization.can_access?('predictive_analytics') + + render_error( + message: 'AI champion analytics requires Tier 1 (Professional) subscription', + code: 'UPGRADE_REQUIRED', + status: :forbidden + ) + end + end + end +end diff --git a/app/modules/ai_intelligence/controllers/draft_controller.rb b/app/modules/ai_intelligence/controllers/draft_controller.rb index ad10316..64550be 100644 --- a/app/modules/ai_intelligence/controllers/draft_controller.rb +++ b/app/modules/ai_intelligence/controllers/draft_controller.rb @@ -9,12 +9,44 @@ class DraftController < Api::V1::BaseController # POST /api/v1/ai/draft/analyze def analyze - result = DraftAnalyzer.call( - team_a: params.require(:team_a), - team_b: params.require(:team_b), - patch: params[:patch] - ) - render_success(DraftAnalysisBlueprint.render_as_hash(result)) + team_a = Array(params[:team_a]).reject(&:blank?) + team_b = Array(params[:team_b]).reject(&:blank?) + patch = params[:patch] + + if team_a.empty? && team_b.empty? + return render json: { error: 'team_a or team_b required' }, + status: :bad_request + end + + result = DraftAnalyzer.call(team_a: team_a, team_b: team_b, patch: patch) + + if result.source == 'ml_v2' + PredictionLogger.log( + blue_picks: Array(team_a), + red_picks: Array(team_b), + predicted_win_prob: result.win_probability, + source: result.source, + patch: patch, + league: params[:league] + ) + end + + blueprint = DraftAnalysisBlueprint.render_as_hash(result) + + all_champs = (Array(team_a) + Array(team_b)).uniq + champion_win_rates = ChampionWinrateService.bulk_lookup(all_champs, patch) + blueprint[:champion_win_rates] = champion_win_rates + + render_success(blueprint) + end + + # POST /api/v1/ai/draft/synergy-matrix + def synergy_matrix + champions = Array(params[:champions]).first(10) + return render json: { error: 'champions required' }, status: :bad_request if champions.size < 2 + + result = SynergyMatrixService.call(champions: champions) + render_success(result) end private diff --git a/app/modules/ai_intelligence/controllers/recommend_controller.rb b/app/modules/ai_intelligence/controllers/recommend_controller.rb new file mode 100644 index 0000000..ac58b15 --- /dev/null +++ b/app/modules/ai_intelligence/controllers/recommend_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module AiIntelligence + module Controllers + # Champion pick recommendations powered by the ProStaff ML AI Service. + # + # Calls the FastAPI ML service (ai-service container) and falls back to the + # Ruby DraftSuggester when the ML service is unavailable. + # + # The X-AI-Source response header indicates which engine answered: + # X-AI-Source: ml_v2 β€” ML service (XGBoost + Champion2Vec, 327 features) + # X-AI-Source: legacy β€” DraftSuggester (cosine similarity, AiChampionVector table) + class RecommendController < Api::V1::BaseController + before_action :require_predictive_analytics_access! + + # POST /api/v1/ai/recommend-pick + # + # @param our_picks [Array] champions already picked by our team (0-4) + # @param opponent_picks [Array] champions picked by the opponent (0-5) + # @param our_bans [Array] champions banned by our team (optional) + # @param opponent_bans [Array] champions banned by opponent (optional) + # @param patch [String] patch version, e.g. "16.08" (optional) + # @param league [String] league identifier, e.g. "LCK" (optional) + # + # @return [JSON] { recommendations: [...], source: "ml_v2"|"legacy", model_version: "v2"|nil } + def recommend_pick + result = AiRecommendationService.call( + our_picks: Array(params[:our_picks]), + opponent_picks: Array(params[:opponent_picks]), + our_bans: Array(params[:our_bans]), + opponent_bans: Array(params[:opponent_bans]), + patch: params[:patch], + league: params[:league] + ) + + patch = params[:patch] + if patch.present? && result[:recommendations].is_a?(Array) + result[:recommendations].each do |rec| + rec[:patch_win_rate] = ChampionWinrateService.win_rate_for( + champion: rec[:champion], + patch: patch + ) + end + end + + response.set_header('X-AI-Source', result[:source]) + render_success(result) + end + + private + + def require_predictive_analytics_access! + return if current_organization.can_access?('predictive_analytics') + + render_error( + message: 'AI recommendations require Tier 1 (Professional) subscription', + code: 'UPGRADE_REQUIRED', + status: :forbidden + ) + end + end + end +end diff --git a/app/modules/ai_intelligence/jobs/rebuild_champion_matrix_job.rb b/app/modules/ai_intelligence/jobs/rebuild_champion_matrix_job.rb index 16905d5..545785a 100644 --- a/app/modules/ai_intelligence/jobs/rebuild_champion_matrix_job.rb +++ b/app/modules/ai_intelligence/jobs/rebuild_champion_matrix_job.rb @@ -8,11 +8,29 @@ class RebuildChampionMatrixJob < ApplicationJob queue_as :low_priority def perform(scope: :all, league: nil) - Rails.logger.info("[AI] Starting champion matrix rebuild scope=#{scope} league=#{league}") + lock_key = 'sidekiq:rebuild_champion_matrix:lock' + acquired = Sidekiq.redis { |r| r.call('SET', lock_key, '1', 'NX', 'EX', 3600) } + + unless acquired + Rails.logger.info('[AI] RebuildChampionMatrixJob skipped β€” already running') + return + end + + # 31k+ records with per-row upserts exceed the default 10s statement_timeout. + # Scope this to the current session only β€” the connection returns to the pool + # with its normal timeout restored after the job finishes. + ActiveRecord::Base.connection.execute('SET statement_timeout = 0') + rebuild_matrices(scope:, league:) + ensure + Sidekiq.redis { |r| r.call('DEL', lock_key) } if acquired + end + private + + def rebuild_matrices(scope:, league:) + Rails.logger.info("[AI] Starting champion matrix rebuild scope=#{scope} league=#{league}") ChampionMatrixBuilder.call(scope: scope.to_sym, league:) ChampionVectorBuilder.rebuild_all! - Rails.logger.info("[AI] Champion matrices rebuilt at #{Time.current}") end end diff --git a/app/modules/ai_intelligence/models/ai_champion_matrix.rb b/app/modules/ai_intelligence/models/ai_champion_matrix.rb index c99744b..f3a358c 100644 --- a/app/modules/ai_intelligence/models/ai_champion_matrix.rb +++ b/app/modules/ai_intelligence/models/ai_champion_matrix.rb @@ -8,12 +8,18 @@ class AiChampionMatrix < ApplicationRecord scope :with_sufficient_sample, -> { where('total_games >= ?', 10) } + UPSERT_WIN_SQL = <<~SQL.squish.freeze + INSERT INTO ai_champion_matrices + (champion_a, champion_b, patch, league, wins_a, total_games, updated_at, created_at) + VALUES (?, ?, ?, ?, 1, 1, NOW(), NOW()) + ON CONFLICT (champion_a, champion_b) WHERE patch IS NULL AND league IS NULL + DO UPDATE SET wins_a = ai_champion_matrices.wins_a + 1, + total_games = ai_champion_matrices.total_games + 1, + updated_at = NOW() + SQL + def self.upsert_win(winner, loser, patch: nil, league: nil) - matrix = find_or_initialize_by(champion_a: winner, champion_b: loser, patch: patch, league: league) - matrix.wins_a = matrix.wins_a.to_i + 1 - matrix.total_games = matrix.total_games.to_i + 1 - matrix.updated_at = Time.current - matrix.save! + connection.execute(sanitize_sql_array([UPSERT_WIN_SQL, winner, loser, patch, league])) end def win_rate diff --git a/app/modules/ai_intelligence/models/ml_prediction_log.rb b/app/modules/ai_intelligence/models/ml_prediction_log.rb new file mode 100644 index 0000000..9ade1e2 --- /dev/null +++ b/app/modules/ai_intelligence/models/ml_prediction_log.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Stores every ml_v2 draft prediction for offline quality monitoring. +# +# Global table (no organization_id) β€” captures tournament-level signal across all teams. +# Outcomes are back-filled via PredictionLogger.record_outcome when a match result +# is known (blue_won is NULL until then). +# +# Used by RollingAucJob to calculate a rolling AUC-ROC over the last 200 settled +# predictions and persist it to Redis for the admin dashboard. +class MlPredictionLog < ApplicationRecord + validates :blue_picks, :red_picks, :predicted_win_prob, presence: true + + # Predictions that already have an outcome β€” eligible for AUC calculation. + scope :with_outcome, -> { where.not(blue_won: nil) } + + # Most recent N predictions, regardless of outcome. + scope :recent, ->(n) { order(predicted_at: :desc).limit(n) } +end diff --git a/app/modules/ai_intelligence/serializers/draft_analysis_blueprint.rb b/app/modules/ai_intelligence/serializers/draft_analysis_blueprint.rb index e6d879f..dad4b37 100644 --- a/app/modules/ai_intelligence/serializers/draft_analysis_blueprint.rb +++ b/app/modules/ai_intelligence/serializers/draft_analysis_blueprint.rb @@ -5,6 +5,7 @@ class DraftAnalysisBlueprint < Blueprinter::Base field :win_probability field :confidence field :low_sample + field :source field :top_synergies do |result| result.synergy_scores diff --git a/app/modules/ai_intelligence/services/ai_recommendation_service.rb b/app/modules/ai_intelligence/services/ai_recommendation_service.rb new file mode 100644 index 0000000..286f47e --- /dev/null +++ b/app/modules/ai_intelligence/services/ai_recommendation_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +# HTTP client for the ProStaff ML AI Service (FastAPI). +# +# Calls POST /recommend on the ML service and returns top-N champion picks +# with composite scores. Falls back to DraftSuggester (Ruby cosine-similarity +# implementation) when the ML service is unreachable, returns an error, is +# disabled via kill switch, or when the circuit breaker is open. +# +# Configuration: +# AI_SERVICE_URL β€” base URL of the FastAPI service, e.g. http://ai-service:8001 +# Defaults to http://localhost:8001 for local development. +# ML_SERVICE_ENABLED β€” set to 'false' to disable all ML calls (kill switch). +# +# Source tagging: +# Returns { source: "ml_v2" } when ML responded successfully. +# Returns { source: "legacy" } when falling back to DraftSuggester. +# +# @example +# result = AiRecommendationService.call( +# our_picks: %w[Jinx Thresh Azir Gnar], +# opponent_picks: %w[Caitlyn Nautilus Syndra Renekton Graves], +# our_bans: [], +# opponent_bans: [], +# patch: "16.08", +# league: "LCK" +# ) +# result[:source] # => "ml_v2" +# result[:recommendations] # => [{ champion: "Lissandra", score: 0.52, ... }] +class AiRecommendationService + class MlServiceError < StandardError; end + + REQUEST_TIMEOUT = ENV.fetch('ML_SERVICE_TIMEOUT', '5').to_i + + def self.call(**) + new(**).call + end + + def initialize(our_picks:, opponent_picks:, our_bans: [], opponent_bans: [], patch: nil, league: nil) + @our_picks = our_picks + @opponent_picks = opponent_picks + @our_bans = our_bans + @opponent_bans = opponent_bans + @patch = patch + @league = league + end + + def call + call_ml_service + rescue MlServiceClient::MlServiceDisabledError, MlServiceClient::MlCircuitOpenError => e + error_type = e.class.name.split('::').last + Rails.logger.info("[AiRecommendationService] ML unavailable (#{error_type}), using legacy fallback: #{e.message}") + legacy_fallback + rescue MlServiceError => e + Rails.logger.warn("[AiRecommendationService] ML service error, using legacy fallback: #{e.message}") + legacy_fallback + end + + private + + def call_ml_service + body = MlServiceClient.post('/recommend', build_payload, timeout: REQUEST_TIMEOUT) + result = { + source: body[:source] || 'ml_v2', + model_version: body[:model_version], + recommendations: body[:recommendations] || [] + } + + if result[:source] == 'ml_v2' + win_prob = result[:recommendations].first&.dig(:win_probability)&.to_f || 0.5 + PredictionLogger.log( + blue_picks: @our_picks, + red_picks: @opponent_picks, + predicted_win_prob: win_prob, + source: result[:source], + model_version: result[:model_version], + patch: @patch, + league: @league + ) + end + + result + rescue MlServiceClient::MlServiceError => e + raise MlServiceError, e.message + end + + def legacy_fallback + suggestions = DraftSuggester.call(team_a: @our_picks, team_b: @opponent_picks) + { + source: 'legacy', + model_version: nil, + recommendations: suggestions.map do |champ| + { + champion: champ, + score: nil, + win_probability: nil, + synergy_score: nil, + counter_score: nil, + reasoning_tokens: [] + } + end + } + end + + def build_payload + { + our_picks: @our_picks, + opponent_picks: @opponent_picks, + our_bans: @our_bans, + opponent_bans: @opponent_bans, + patch: @patch, + league: @league + } + end +end diff --git a/app/modules/ai_intelligence/services/champion_matrix_builder.rb b/app/modules/ai_intelligence/services/champion_matrix_builder.rb index a4be267..5ac4e2d 100644 --- a/app/modules/ai_intelligence/services/champion_matrix_builder.rb +++ b/app/modules/ai_intelligence/services/champion_matrix_builder.rb @@ -28,6 +28,15 @@ def build end end + RECORD_APPEARANCE_SQL = <<~SQL.squish.freeze + INSERT INTO ai_champion_matrices + (champion_a, champion_b, patch, league, wins_a, total_games, updated_at, created_at) + VALUES (?, ?, NULL, NULL, 0, 1, NOW(), NOW()) + ON CONFLICT (champion_a, champion_b) WHERE patch IS NULL AND league IS NULL + DO UPDATE SET total_games = ai_champion_matrices.total_games + 1, + updated_at = NOW() + SQL + private def register_matchups(winner_picks, loser_picks) @@ -43,12 +52,7 @@ def register_matchups(winner_picks, loser_picks) end def record_appearance(champion_a, champion_b) - AiChampionMatrix - .find_or_initialize_by(champion_a:, champion_b:) - .tap do |m| - m.total_games = m.total_games.to_i + 1 - m.updated_at = Time.current - m.save! - end + sql = AiChampionMatrix.sanitize_sql_array([RECORD_APPEARANCE_SQL, champion_a, champion_b]) + AiChampionMatrix.connection.execute(sql) end end diff --git a/app/modules/ai_intelligence/services/champion_vector_builder.rb b/app/modules/ai_intelligence/services/champion_vector_builder.rb index 33efcff..0e22ecd 100644 --- a/app/modules/ai_intelligence/services/champion_vector_builder.rb +++ b/app/modules/ai_intelligence/services/champion_vector_builder.rb @@ -17,20 +17,8 @@ def self.call(champion_name:, league: nil) end def self.rebuild_all! - champion_names = extract_all_champion_names - champion_names.each do |name| - vector = call(champion_name: name) - next if vector.nil? - - appearances_count = new(champion_name: name).send(:all_appearances).size - - AiChampionVector.find_or_initialize_by(champion_name: name).tap do |v| - v.vector_data = vector.to_a - v.games_count = appearances_count - v.updated_at = Time.current - v.save! - end - end + all_matches = CompetitiveMatch.unscoped.to_a + collect_champion_names(all_matches).each { |name| persist_vector(name, all_matches) } end def build @@ -47,15 +35,46 @@ def build normalize_vector(vector) end - def self.extract_all_champion_names - CompetitiveMatch.unscoped.flat_map do |m| - picks = (m.our_picks || []) + (m.opponent_picks || []) - picks.map { |p| p['champion'] } - end.compact.uniq + def appearances_from_preloaded(matches) + filtered = @league ? matches.select { |m| m.tournament_name == @league } : matches + filtered.flat_map { |match| extract_from_match(match) } + end + + def build_from_appearances(appearances) + arrays = extract_stat_arrays(appearances) + stats = build_stat_hash(appearances.size, arrays) + vector = Numo::DFloat[ + stats[:win_rate], stats[:avg_kda], stats[:avg_damage_share], + stats[:avg_gold_share], normalize(stats[:avg_cs], 0, 400) + ] + normalize_vector(vector) end private + def self.collect_champion_names(matches) + matches.flat_map do |m| + ((m.our_picks || []) + (m.opponent_picks || [])).map { |p| p['champion'] } + end.compact.uniq + end + private_class_method :collect_champion_names + + def self.persist_vector(champion_name, all_matches) + builder = new(champion_name: champion_name) + appearances = builder.appearances_from_preloaded(all_matches) + return if appearances.empty? + + vector = builder.build_from_appearances(appearances) + + AiChampionVector.find_or_initialize_by(champion_name: champion_name).tap do |v| + v.vector_data = vector.to_a + v.games_count = appearances.size + v.updated_at = Time.current + v.save! + end + end + private_class_method :persist_vector + def aggregate_stats appearances = all_appearances return { games: 0 } if appearances.empty? diff --git a/app/modules/ai_intelligence/services/champion_winrate_service.rb b/app/modules/ai_intelligence/services/champion_winrate_service.rb new file mode 100644 index 0000000..c375b55 --- /dev/null +++ b/app/modules/ai_intelligence/services/champion_winrate_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Loads champion patch win-rate data from champion_patch_winrate.json and +# exposes fast lookups cached in Rails.cache for 24 hours. +# +# Key format in JSON: "Azir_16" => 0.582 +# where the suffix is the major integer of the patch (e.g. "16.08" -> "16"). +# +# When patch is nil, the lookup falls back to the latest patch major version +# available in the data file so callers always receive a value when data exists. +class ChampionWinrateService + PRIMARY_FILE = Rails.root.join('data', 'champion_patch_winrate.json').freeze + FALLBACK_FILE = Pathname.new('/home/bullet/PROJETOS/prostaff-ml/data/champion_patch_winrate.json').freeze + CACHE_KEY = 'champion_winrates' + LATEST_PATCH_CACHE_KEY = 'champion_winrates_latest_patch' + CACHE_TTL = 24.hours + + # Returns the win rate (Float) for a given champion on a given patch. + # When patch is nil, falls back to the latest patch available in the data. + # Returns nil only when champion is blank or no data exists at all. + # + # @param champion [String] e.g. "Azir" + # @param patch [String, Integer, nil] e.g. "16.08", 16, or nil + # @return [Float, nil] + def self.win_rate_for(champion:, patch:) + return nil if champion.blank? + + effective_patch = patch.presence || latest_patch + return nil if effective_patch.nil? + + key = "#{champion}_#{effective_patch.to_s.split('.').first}" + data[key] + end + + # Returns a hash mapping each champion name to its win rate (or nil). + # + # @param champions [Array] + # @param patch [String, nil] + # @return [Hash{String => Float, nil}] + def self.bulk_lookup(champions, patch) + Array(champions).to_h { |c| [c, win_rate_for(champion: c, patch: patch)] } + end + + # Returns the highest patch major version present in the data, or nil if + # the data hash is empty. + # + # @return [String, nil] + def self.latest_patch + Rails.cache.fetch(LATEST_PATCH_CACHE_KEY, expires_in: CACHE_TTL) do + majors = data.keys.filter_map { |k| k.split('_').last.to_i if k.match?(/\A.+_\d+\z/) } + majors.max&.to_s + end + end + + # Loads (and caches) the win-rate JSON. Returns {} on any error. + # + # @return [Hash{String => Float}] + def self.data + Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) do + file_path = resolve_file_path + if file_path + JSON.parse(File.read(file_path)) + else + Rails.logger.warn '[WINRATE] ChampionWinrateService: champion_patch_winrate.json not found in any known path' + {} + end + rescue StandardError => e + Rails.logger.warn "[WINRATE] ChampionWinrateService: failed to load win-rate data β€” #{e.message}" + {} + end + end + + # @return [Pathname, nil] + def self.resolve_file_path + return PRIMARY_FILE if PRIMARY_FILE.exist? + return FALLBACK_FILE if FALLBACK_FILE.exist? + + nil + end + + private_class_method :resolve_file_path +end diff --git a/app/modules/ai_intelligence/services/draft_analyzer.rb b/app/modules/ai_intelligence/services/draft_analyzer.rb index aa8115e..280f985 100644 --- a/app/modules/ai_intelligence/services/draft_analyzer.rb +++ b/app/modules/ai_intelligence/services/draft_analyzer.rb @@ -4,29 +4,23 @@ # Orchestrates synergy, counter, and win probability calculations. class DraftAnalyzer Result = Struct.new(:win_probability, :confidence, :synergy_scores, - :counter_scores, :suggested_picks, :low_sample, keyword_init: true) + :counter_scores, :suggested_picks, :low_sample, :source, keyword_init: true) def self.call(team_a:, team_b:, patch: nil) new(team_a:, team_b:, patch:).analyze end def analyze - synergies = calculate_synergies - counters = calculate_counters - win_prob = WinProbabilityCalculator.call( - team_a: @team_a, team_b: @team_b, - synergies:, counters: - ) + synergies = calculate_synergies + counters = calculate_counters suggestions = DraftSuggester.call(team_a: @team_a, team_b: @team_b) if @team_a.size == 4 + ml_result = MlDraftService.call(team_a: @team_a, team_b: @team_b, patch: @patch, league: nil) - Result.new( - win_probability: win_prob[:score].round(4), - confidence: win_prob[:confidence].round(4), - synergy_scores: synergies, - counter_scores: counters, - suggested_picks: suggestions, - low_sample: win_prob[:confidence] < 0.5 - ) + if ml_result + build_ml_result(ml_result, synergies, counters, suggestions) + else + build_legacy_result(synergies, counters, suggestions) + end end private @@ -37,6 +31,34 @@ def initialize(team_a:, team_b:, patch:) @patch = patch # accepted but unused in MVP; v2 will use for patch filtering end + def build_ml_result(ml_result, synergies, counters, suggestions) + Result.new( + win_probability: ml_result[:win_probability].round(4), + confidence: ml_result[:confidence].round(4), + synergy_scores: synergies, + counter_scores: counters, + suggested_picks: suggestions, + low_sample: ml_result[:confidence] < 0.5, + source: 'ml_v2' + ) + end + + def build_legacy_result(synergies, counters, suggestions) + win_prob = WinProbabilityCalculator.call( + team_a: @team_a, team_b: @team_b, + synergies:, counters: + ) + Result.new( + win_probability: win_prob[:score].round(4), + confidence: win_prob[:confidence].round(4), + synergy_scores: synergies, + counter_scores: counters, + suggested_picks: suggestions, + low_sample: win_prob[:confidence] < 0.5, + source: 'legacy_ruby' + ) + end + def calculate_synergies pairs = @team_a.combination(2).to_a + @team_b.combination(2).to_a pairs.each_with_object({}) do |(a, b), h| diff --git a/app/modules/ai_intelligence/services/ml_draft_service.rb b/app/modules/ai_intelligence/services/ml_draft_service.rb new file mode 100644 index 0000000..e249a43 --- /dev/null +++ b/app/modules/ai_intelligence/services/ml_draft_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# HTTP client for the ProStaff ML AI Service (FastAPI) β€” win probability endpoint. +# +# Calls POST /win-probability on the ML service and returns win probability +# with confidence score. Returns nil if the ML service is unreachable, times +# out, returns an invalid response, is disabled via kill switch, or when the +# circuit breaker is open β€” allowing callers to fall back gracefully. +# +# Configuration: +# AI_SERVICE_URL β€” base URL of the FastAPI service, e.g. http://ai-service:8001 +# Defaults to http://localhost:8001 for local development. +# ML_SERVICE_ENABLED β€” set to 'false' to disable all ML calls (kill switch). +# +# @example +# result = MlDraftService.call( +# team_a: %w[Jinx Thresh Azir Gnar Renekton], +# team_b: %w[Caitlyn Nautilus Syndra Graves Camille], +# patch: "16.08", +# league: "LCK" +# ) +# result # => { win_probability: 0.6134, confidence: 0.81, source: "ml_v2" } +# # or nil if the ML service failed / is disabled / circuit is open +class MlDraftService + REQUEST_TIMEOUT = 3 + + def self.call(**) + new(**).call + end + + def initialize(team_a:, team_b:, patch: nil, league: nil, side: nil) + @team_a = team_a + @team_b = team_b + @patch = patch + @league = league + @side = side + end + + def call + body = MlServiceClient.post('/win-probability', build_payload, timeout: REQUEST_TIMEOUT) + + unless body.is_a?(Hash) && body[:win_probability] + Rails.logger.warn('[MlDraftService] Unexpected response shape from ML service') + return nil + end + + { + win_probability: body[:win_probability].to_f, + confidence: body[:confidence].to_f, + source: 'ml_v2' + } + rescue MlServiceClient::MlServiceDisabledError, MlServiceClient::MlCircuitOpenError + # Kill switch active or circuit open β€” return nil silently (no error-level log) + nil + rescue MlServiceClient::MlServiceError => e + Rails.logger.warn("[MlDraftService] ML service unavailable: #{e.message}") + nil + end + + private + + def build_payload + { + team_a_picks: @team_a, + team_b_picks: @team_b, + patch: @patch, + league: @league, + side: @side + } + end +end diff --git a/app/modules/ai_intelligence/services/ml_service_client.rb b/app/modules/ai_intelligence/services/ml_service_client.rb new file mode 100644 index 0000000..c9f1667 --- /dev/null +++ b/app/modules/ai_intelligence/services/ml_service_client.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# Shared HTTP client for all calls to the ProStaff ML service (FastAPI). +# +# Responsibilities: +# - Single Faraday connection pointed at AI_SERVICE_URL +# - Kill switch: ML_SERVICE_ENABLED=false raises MlServiceDisabledError immediately +# - Lightweight circuit breaker backed by Redis (via Sidekiq.redis β€” no extra gem): +# "ml_circuit:failures" β€” INCR counter with TTL, resets on success +# "ml_circuit:open_until" β€” Unix timestamp; while Time.now < value, circuit is open +# +# Circuit breaker behaviour: +# - Open check: if open_until > now β†’ raise MlCircuitOpenError (fast fail, no network call) +# - On success: DEL failures key +# - On network error (timeout / connection failed): INCR failures (TTL 60s) +# If failures >= ML_CIRCUIT_BREAK_THRESHOLD β†’ SET open_until = now + ML_CIRCUIT_BREAK_RESET_SECONDS +# +# ENV vars: +# AI_SERVICE_URL (default: 'http://localhost:8001') +# ML_SERVICE_ENABLED (default: 'true') β€” set to 'false' to kill-switch +# ML_SERVICE_TIMEOUT (default: '5') β€” seconds for .post() callers that omit timeout: +# ML_CIRCUIT_BREAK_THRESHOLD (default: '3') β€” consecutive failures before opening +# ML_CIRCUIT_BREAK_RESET_SECONDS (default: '120') β€” seconds the circuit stays open +# +# Usage: +# MlServiceClient.post('/recommend', payload, timeout: 5) +# # => parsed Hash (symbolized keys) or raises one of the errors below +# +# Errors (all subclass StandardError): +# MlServiceClient::MlServiceDisabledError β€” kill switch is active +# MlServiceClient::MlCircuitOpenError β€” circuit is open, request not attempted +# MlServiceClient::MlServiceError β€” upstream returned non-2xx or bad JSON +module MlServiceClient + # ── Error hierarchy ──────────────────────────────────────────────────────── + MlServiceDisabledError = Class.new(StandardError) + MlCircuitOpenError = Class.new(StandardError) + MlServiceError = Class.new(StandardError) + + # ── Redis keys ───────────────────────────────────────────────────────────── + CIRCUIT_FAILURES_KEY = 'ml_circuit:failures' + CIRCUIT_OPEN_UNTIL_KEY = 'ml_circuit:open_until' + CIRCUIT_FAILURES_TTL = 60 # seconds β€” window for counting consecutive failures + + # ── ENV helpers (read fresh each call so the values can change at runtime) ─ + def self.base_url + ENV.fetch('AI_SERVICE_URL', 'http://localhost:8001') + end + + def self.service_enabled? + ENV.fetch('ML_SERVICE_ENABLED', 'true') != 'false' + end + + def self.circuit_threshold + ENV.fetch('ML_CIRCUIT_BREAK_THRESHOLD', '3').to_i + end + + def self.circuit_reset_seconds + ENV.fetch('ML_CIRCUIT_BREAK_RESET_SECONDS', '120').to_i + end + + # ── Public interface ──────────────────────────────────────────────────────── + + # POST to the ML service. + # + # @param path [String] e.g. '/recommend' + # @param payload [Hash] request body (will be JSON-encoded) + # @param timeout [Integer] per-request timeout in seconds + # @return [Hash] parsed response body (symbolized keys) + # @raise [MlServiceDisabledError, MlCircuitOpenError, MlServiceError] + def self.post(path, payload, timeout: ENV.fetch('ML_SERVICE_TIMEOUT', '5').to_i) + raise MlServiceDisabledError, 'ML service is disabled (ML_SERVICE_ENABLED=false)' unless service_enabled? + + check_circuit! + execute_request(path, payload, timeout) + end + + def self.execute_request(path, payload, timeout) + response = send_http_request(path, payload, timeout) + raise MlServiceError, "ML service returned HTTP #{response.status} from #{path}" unless response.success? + + result = JSON.parse(response.body, symbolize_names: true) + record_success + result + rescue JSON::ParserError => e + raise MlServiceError, "invalid JSON response from #{path}: #{e.message}" + end + + def self.send_http_request(path, payload, timeout) + connection(timeout: timeout).post(path) do |req| + req.headers['Content-Type'] = 'application/json' + req.body = payload.to_json + end + rescue Faraday::TimeoutError => e + record_failure + raise MlServiceError, "timeout calling #{path}: #{e.message}" + rescue Faraday::ConnectionFailed => e + record_failure + raise MlServiceError, "connection failed calling #{path}: #{e.message}" + rescue Faraday::Error => e + record_failure + raise MlServiceError, "network error calling #{path}: #{e.message}" + end + + # ── Circuit breaker helpers ───────────────────────────────────────────────── + + # Raises MlCircuitOpenError when the circuit is open. + def self.check_circuit! + open_until = Sidekiq.redis { |r| r.call('GET', CIRCUIT_OPEN_UNTIL_KEY).to_i } + return unless open_until > Time.now.to_i + + remaining = open_until - Time.now.to_i + raise MlCircuitOpenError, "ML circuit breaker is open for #{remaining}s more" + end + + def self.record_success + Sidekiq.redis { |r| r.call('DEL', CIRCUIT_FAILURES_KEY) } + end + + def self.record_failure + failures = Sidekiq.redis do |r| + count = r.call('INCR', CIRCUIT_FAILURES_KEY) + # Reset TTL on every increment so the window is sliding + r.call('EXPIRE', CIRCUIT_FAILURES_KEY, CIRCUIT_FAILURES_TTL) + count + end + + return unless failures >= circuit_threshold + + reset = circuit_reset_seconds + Sidekiq.redis do |r| + r.call('SET', CIRCUIT_OPEN_UNTIL_KEY, (Time.now.to_i + reset).to_s) + end + Rails.logger.warn( + "[MlServiceClient] Circuit breaker OPEN after #{failures} consecutive failures β€” " \ + "will reset in #{reset}s" + ) + end + + # ── Faraday connection ────────────────────────────────────────────────────── + + def self.connection(timeout:) + Faraday.new(url: base_url) do |f| + f.options.timeout = timeout + f.options.open_timeout = timeout + f.adapter Faraday.default_adapter + end + end + + private_class_method :check_circuit!, :execute_request, :send_http_request, :record_success, :record_failure, + :connection +end diff --git a/app/modules/ai_intelligence/services/prediction_logger.rb b/app/modules/ai_intelligence/services/prediction_logger.rb new file mode 100644 index 0000000..d57cb47 --- /dev/null +++ b/app/modules/ai_intelligence/services/prediction_logger.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Persists ml_v2 draft predictions to the database and to a Redis list for +# the real-time admin dashboard. +# +# Both operations are fire-and-forget: failures are warned and swallowed so +# the logger never blocks or raises in the request cycle. +# +# Redis layout: +# ml:predictions β€” LPUSH/LTRIM list of the last 1 000 prediction summaries (JSON). +# Used by the admin widget for quick in-memory queries. +# +# @example Logging a prediction +# PredictionLogger.log( +# blue_picks: %w[Jinx Thresh Azir Gnar Renekton], +# red_picks: %w[Caitlyn Nautilus Syndra Graves Camille], +# predicted_win_prob: 0.6134, +# source: 'ml_v2', +# patch: '16.08', +# league: 'LCK', +# model_version: 'champion2vec-v2', +# match_id: 'match-uuid' +# ) +# +# @example Recording a match outcome +# PredictionLogger.record_outcome(match_id: 'match-uuid', blue_won: true) +module PredictionLogger + # Logs a single prediction. Only persists when source == 'ml_v2'. + # + # @param blue_picks [Array] + # @param red_picks [Array] + # @param predicted_win_prob [Float] win probability for the blue side + # @param patch [String, nil] + # @param league [String, nil] + # @param model_version [String, nil] + # @param source [String, nil] must be 'ml_v2' to persist + # @param match_id [String, nil] optional correlation key + def self.log(blue_picks:, red_picks:, predicted_win_prob:, + patch: nil, league: nil, model_version: nil, source: nil, match_id: nil) + return unless source == 'ml_v2' + + prob = predicted_win_prob.to_f.round(4) + persist_prediction(blue_picks:, red_picks:, prob:, patch:, league:, model_version:, source:, match_id:) + push_to_redis(prob: prob, source: source) + rescue StandardError => e + Rails.logger.warn("[PredictionLogger] log failed: #{e.message}") + end + + # Back-fills the outcome for all pending predictions tied to a match. + # Idempotent β€” only updates rows where blue_won is still NULL. + # + # @param match_id [String] + # @param blue_won [Boolean] + def self.record_outcome(match_id:, blue_won:) + MlPredictionLog.where(match_id: match_id, blue_won: nil) + .update_all(blue_won: blue_won, outcome_at: Time.current) + rescue StandardError => e + Rails.logger.warn("[PredictionLogger] record_outcome failed: #{e.message}") + end + + # --------------------------------------------------------------------------- + private_class_method def self.persist_prediction(blue_picks:, red_picks:, prob:, + patch:, league:, model_version:, source:, match_id:) + MlPredictionLog.create!( + blue_picks: blue_picks, + red_picks: red_picks, + predicted_win_prob: prob, + patch: patch, + league: league, + model_version: model_version, + source: source, + match_id: match_id, + predicted_at: Time.current + ) + end + + private_class_method def self.push_to_redis(prob:, source:) + payload = { prob: prob, at: Time.current.iso8601, source: source }.to_json + + Sidekiq.redis do |r| + r.call('LPUSH', 'ml:predictions', payload) + r.call('LTRIM', 'ml:predictions', 0, 999) + end + rescue StandardError => e + Rails.logger.warn("[PredictionLogger] Redis push failed: #{e.message}") + end +end diff --git a/app/modules/ai_intelligence/services/synergy_matrix_service.rb b/app/modules/ai_intelligence/services/synergy_matrix_service.rb new file mode 100644 index 0000000..12beff9 --- /dev/null +++ b/app/modules/ai_intelligence/services/synergy_matrix_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Calculates an NΓ—N cosine-similarity matrix from 64-dimensional champion embeddings. +# +# Embeddings are loaded once per 24h from champion_embeddings_64d.json via Rails.cache. +# Primary path: ai_service/data/champion_embeddings_64d.json +# Fallback path: models/champion_embeddings_64d.json (prostaff-ml artefact) +class SynergyMatrixService + EMBEDDINGS_FILE = Rails.root.join('ai_service', 'data', 'champion_embeddings_64d.json').freeze + FALLBACK_FILE = Rails.root.join('models', 'champion_embeddings_64d.json').freeze + CACHE_KEY = 'ai_intelligence/champion_embeddings_64d' + CACHE_TTL = 24.hours + + # @param champions [Array] 2–10 champion names + # @return [Hash] { champions:, matrix:, top_pairs:, weakest_pairs: } + def self.call(champions:) + resolved = resolve_embeddings(champions) + present = resolved.keys + return empty_result(present) if present.size < 2 + + matrix = build_matrix(present, resolved) + pairs = build_sorted_pairs(present, matrix) + + { + champions: present, + matrix: matrix.map { |row| row.map { |val| val.round(4) } }, + top_pairs: pairs.first(5), + weakest_pairs: pairs.last(3) + } + end + + # ── private ────────────────────────────────────────────────────────── + + def self.empty_result(present) + { champions: present, matrix: [], top_pairs: [], weakest_pairs: [] } + end + private_class_method :empty_result + + def self.resolve_embeddings(champions) + embs = embeddings + champions.filter_map do |champ| + vec = embs[champ] || embs[champ.downcase] + [champ, vec] if vec + end.to_h + end + private_class_method :resolve_embeddings + + def self.build_matrix(present, resolved) + present.map.with_index do |champ_a, idx_a| + present.map.with_index do |champ_b, idx_b| + idx_a == idx_b ? 1.0 : cosine_similarity(resolved[champ_a], resolved[champ_b]) + end + end + end + private_class_method :build_matrix + + def self.build_sorted_pairs(present, matrix) + pairs = present.combination(2).map do |champ_a, champ_b| + ia = present.index(champ_a) + ib = present.index(champ_b) + { pair: [champ_a, champ_b], score: matrix[ia][ib].round(4) } + end + pairs.sort_by { |entry| -entry[:score] } + end + private_class_method :build_sorted_pairs + + def self.embeddings + Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) { load_embeddings } + end + private_class_method :embeddings + + def self.load_embeddings + path = EMBEDDINGS_FILE.exist? ? EMBEDDINGS_FILE : FALLBACK_FILE + raise "Champion embeddings file not found (tried #{EMBEDDINGS_FILE} and #{FALLBACK_FILE})" unless path.exist? + + JSON.parse(File.read(path)) + end + private_class_method :load_embeddings + + def self.cosine_similarity(vec_a, vec_b) + dot = vec_a.zip(vec_b).sum { |x, y| x * y } + norm_a = Math.sqrt(vec_a.sum { |x| x**2 }) + norm_b = Math.sqrt(vec_b.sum { |x| x**2 }) + return 0.0 if norm_a < 1e-9 || norm_b < 1e-9 + + (dot / (norm_a * norm_b)).clamp(-1.0, 1.0) + end + private_class_method :cosine_similarity +end diff --git a/app/modules/analytics/controllers/champions_controller.rb b/app/modules/analytics/controllers/champions_controller.rb index 2a816ea..c8ff3ef 100644 --- a/app/modules/analytics/controllers/champions_controller.rb +++ b/app/modules/analytics/controllers/champions_controller.rb @@ -17,16 +17,16 @@ module Controllers # Main endpoints: # - GET show: Returns comprehensive champion statistics including mastery grades and diversity metrics class ChampionsController < Api::V1::BaseController + before_action :set_player, only: %i[show details] + def show - player = organization_scoped(Player).find(params[:player_id]) - stats = fetch_champion_stats(player) + stats = fetch_champion_stats(@player) champion_stats = build_champion_stats(stats) - render_success(build_champion_data(player, champion_stats)) + render_success(build_champion_data(@player, champion_stats)) end def details - player = organization_scoped(Player).find(params[:player_id]) champion = params[:champion] if champion.blank? @@ -34,7 +34,7 @@ def details status: :bad_request) end - matches = fetch_champion_matches(player, champion) + matches = fetch_champion_matches(@player, champion) if matches.empty? return render_error(message: "No matches found for champion #{champion}", code: 'NO_MATCHES', @@ -45,14 +45,12 @@ def details matches_array = matches.to_a render_success({ - player: PlayerSerializer.render_as_hash(player), + player: PlayerSerializer.render_as_hash(@player), champion: champion, icon_url: riot_service.champion_icon_url(champion), aggregate_stats: build_aggregate_stats(matches, matches_array), matches: serialize_champion_matches(matches_array, riot_service) }) - rescue ActiveRecord::RecordNotFound - render_error(message: 'Player not found', code: 'PLAYER_NOT_FOUND', status: :not_found) rescue StandardError => e Rails.logger.error("Error in champions#details: #{e.message}") Rails.logger.error(e.backtrace.join("\n")) @@ -142,7 +140,8 @@ def build_match_summary(stat) date: stat.match.game_start&.strftime('%Y-%m-%d %H:%M'), victory: stat.match.victory?, game_duration: stat.match.game_duration.to_i, - role: stat.role + role: stat.role, + opponent_champion: stat.opponent_champion } end @@ -253,6 +252,10 @@ def round_or_default(value, precision, default = 0) value&.round(precision) || default end + def set_player + @player = organization_scoped(Player).find(params[:player_id]) + end + def build_champion_data(player, champion_stats) { player: PlayerSerializer.render_as_hash(player), diff --git a/app/modules/analytics/controllers/competitive_controller.rb b/app/modules/analytics/controllers/competitive_controller.rb index ea409f3..924b28b 100644 --- a/app/modules/analytics/controllers/competitive_controller.rb +++ b/app/modules/analytics/controllers/competitive_controller.rb @@ -6,6 +6,7 @@ module Controllers # GET /api/v1/analytics/competitive/draft-performance # GET /api/v1/analytics/competitive/tournament-stats # GET /api/v1/analytics/competitive/opponents + # GET /api/v1/analytics/competitive/patch-meta # # All actions accept the same optional filter params: # tournament [String] filter by tournament name @@ -84,6 +85,29 @@ def opponents status: :internal_server_error) end + # ── Patch meta ──────────────────────────────────────────────── + # Returns win rate, pick and ban trends grouped by patch version. + # Useful for identifying which patches correlated with strong/weak performance. + # + # @return [JSON] { data: { patches: [...], total_matches: Integer } } + def patch_meta + matches = apply_filters(organization_scoped(CompetitiveMatch)) + + rows = matches.select(:patch_version, :victory, :side, :our_picks, :our_bans).to_a + patches = build_patch_meta(rows) + + render_success({ + patches: patches, + total_matches: rows.size + }) + rescue StandardError => e + Rails.logger.error("[CompetitiveAnalytics] patch_meta: #{e.message}\n#{e.backtrace.first(3).join("\n")}") + render_error(message: 'Failed to load patch meta', code: 'INTERNAL_ERROR', + status: :internal_server_error) + end + + PERFORMANCE_ROLES = %w[top jungle mid adc support].freeze + # ── Private helpers ──────────────────────────────────────────── private @@ -151,52 +175,70 @@ def build_ban_performance(rows, total_games) end.sort_by { |s| -s[:ban_count] } end + # Builds blue/red side win-rate stats from in-memory rows. + # + # Side values in the DB are validated as lowercase ('blue', 'red'), but records + # ingested from external sources may have nil or differently-cased values (e.g. + # 'Blue', 'RED'). Those records are normalised via downcase before matching. + # Records with nil or unrecognised side values are excluded from both side + # buckets and reported in the `unaccounted` key. The sum + # blue.games + red.games may therefore be less than total_matches β€” this is + # intentional and expected when incomplete data exists. def build_side_performance(rows) - %w[blue red].each_with_object({}) do |side, result| - side_rows = rows.select { |m| m.side == side } - games = side_rows.size - wins = side_rows.count(&:victory) - result[side] = { + valid_sides = %w[blue red] + result = valid_sides.each_with_object({}) do |side, hash| + side_rows = rows.select { |m| m.side&.downcase == side } + games = side_rows.size + wins = side_rows.count { |m| m.victory == true } + losses = side_rows.count { |m| m.victory == false } + hash[side] = { games: games, wins: wins, - losses: games - wins, + losses: losses, win_rate: games.positive? ? (wins.to_f / games * 100).round(1) : 0 } end + + accounted = result['blue'][:games] + result['red'][:games] + result['unaccounted'] = rows.size - accounted + result end - def build_role_performance(rows) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - roles = %w[top jungle mid adc support] - role_stats = roles.each_with_object({}) do |r, h| - h[r] = { games: 0, wins: 0, champions: Hash.new(0) } - end + def build_role_performance(rows) + role_stats = initial_role_stats + rows.each { |match| accumulate_match_picks(role_stats, match) } + role_stats.map { |role, stats| format_role_stat(role, stats) } + end - rows.each do |match| - won = match.victory - (match.our_picks || []).each do |pick| - role = pick['role']&.downcase - champ = pick['champion'] - next unless role_stats.key?(role) && champ.present? + def initial_role_stats + PERFORMANCE_ROLES.each_with_object({}) { |r, h| h[r] = { games: 0, wins: 0, champions: Hash.new(0) } } + end - role_stats[role][:games] += 1 - role_stats[role][:wins] += 1 if won - role_stats[role][:champions][champ] += 1 - end - end + def accumulate_match_picks(role_stats, match) + won = match.victory + (match.our_picks || []).each do |pick| + role = pick['role']&.downcase + champ = pick['champion'] + next unless role_stats.key?(role) && champ.present? - role_stats.map do |role, s| - most_played = s[:champions].max_by { |_, c| c }&.first || 'N/A' - { - role: role, - games: s[:games], - wins: s[:wins], - win_rate: s[:games].positive? ? (s[:wins].to_f / s[:games] * 100).round(1) : 0, - most_played_champion: most_played, - champion_pool_size: s[:champions].size - } + role_stats[role][:games] += 1 + role_stats[role][:wins] += 1 if won + role_stats[role][:champions][champ] += 1 end end + def format_role_stat(role, stats) + most_played = stats[:champions].max_by { |_, c| c }&.first || 'N/A' + { + role: role, + games: stats[:games], + wins: stats[:wins], + win_rate: stats[:games].positive? ? (stats[:wins].to_f / stats[:games] * 100).round(1) : 0, + most_played_champion: most_played, + champion_pool_size: stats[:champions].size + } + end + def extract_meta_champions(matches) matches.where.not(meta_champions: nil) .pluck(:meta_champions) @@ -230,7 +272,8 @@ def build_tournament_stats(matches) wins = t_matches.victories.count losses = games - wins - patches = t_matches.where.not(patch_version: nil).distinct.pluck(:patch_version).compact.sort + patches = t_matches.where.not(patch_version: [nil, '']).distinct.pluck(:patch_version).compact + .sort_by { |v| v.split('.').map(&:to_i) } t_dates = t_matches.where.not(match_date: nil) date_range = if t_dates.exists? @@ -292,13 +335,50 @@ def build_opponents_data(rows) end.sort_by { |o| -o[:matches] } end + # ── patch_meta helpers ───────────────────────────────────────── + + def build_patch_meta(rows) + rows.group_by { |m| m.patch_version.presence } + .filter_map { |patch, patch_rows| build_patch_entry(patch, patch_rows) } + .sort_by { |entry| entry[:patch].split('.').map(&:to_i) } + .reverse + end + + def build_patch_entry(patch, patch_rows) + return nil if patch.nil? + + games = patch_rows.size + wins = patch_rows.count(&:victory) + + { + patch: patch, + games: games, + wins: wins, + losses: games - wins, + win_rate: patch_win_rate(wins, games), + blue_games: patch_rows.count { |m| m.side&.downcase == 'blue' }, + red_games: patch_rows.count { |m| m.side&.downcase == 'red' }, + top_picks: top_n_from_jsonb(patch_rows, :our_picks, 5), + top_bans: top_n_from_jsonb(patch_rows, :our_bans, 5) + } + end + + def patch_win_rate(wins, games) + games.positive? ? (wins.to_f / games * 100).round(1) : 0 + end + + def top_n_from_jsonb(rows, field, limit) + tally = rows.flat_map { |m| Array(m.public_send(field)).filter_map { |e| e['champion'] } }.tally + tally.sort_by { |_, count| -count }.first(limit).map { |champion, count| { champion: champion, count: count } } + end + # ── empty state helpers ──────────────────────────────────────── def empty_draft_performance { pick_performance: [], ban_performance: [], - side_performance: { blue: side_zeros, red: side_zeros }, + side_performance: { 'blue' => side_zeros, 'red' => side_zeros, 'unaccounted' => 0 }, role_performance: [], meta_champions: [], total_matches: 0 diff --git a/app/modules/analytics/controllers/kda_trend_controller.rb b/app/modules/analytics/controllers/kda_trend_controller.rb index b571083..c8559af 100644 --- a/app/modules/analytics/controllers/kda_trend_controller.rb +++ b/app/modules/analytics/controllers/kda_trend_controller.rb @@ -16,12 +16,12 @@ module Controllers # Main endpoints: # - GET show: Returns KDA trends for the last 50 matches with rolling averages class KdaTrendController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) + before_action :set_player, only: %i[show] + def show # Get recent matches for the player stats = PlayerMatchStat.joins(:match) - .where(player: player, matches: { organization_id: current_organization.id }) + .where(player: @player, matches: { organization_id: current_organization.id }) .order('matches.game_start DESC') .limit(50) .includes(:match) @@ -29,7 +29,7 @@ def show stats_array = stats.to_a trend_data = { - player: PlayerSerializer.render_as_hash(player), + player: PlayerSerializer.render_as_hash(@player), kda_by_match: stats_array.map do |stat| kda = if stat.deaths.zero? (stat.kills + stat.assists).to_f @@ -59,6 +59,10 @@ def show private + def set_player + @player = organization_scoped(Player).find(params[:player_id]) + end + def calculate_kda_average(stats) return 0 if stats.empty? diff --git a/app/modules/analytics/controllers/laning_controller.rb b/app/modules/analytics/controllers/laning_controller.rb index 3f230d7..2ee3b8d 100644 --- a/app/modules/analytics/controllers/laning_controller.rb +++ b/app/modules/analytics/controllers/laning_controller.rb @@ -9,12 +9,12 @@ module Controllers # so those fields are omitted (nil) and the frontend falls back gracefully. # class LaningController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) + before_action :set_player, only: %i[show] + def show stats = PlayerMatchStat.joins(:match) .includes(:match) - .where(player: player, match: { organization: current_organization }) + .where(player: @player, match: { organization: current_organization }) .order('"match"."game_start" DESC') .limit(20) @@ -22,7 +22,7 @@ def show wins = stats.where(match: { victory: true }).count laning_data = { - player: PlayerSerializer.render_as_hash(player), + player: PlayerSerializer.render_as_hash(@player), avg_cs_per_min: stats.average(:cs_per_min)&.round(1) || calculate_avg_cs_per_min(stats), avg_cs_total: stats.average(:cs)&.round(1) || 0, lane_win_rate: games.zero? ? nil : ((wins.to_f / games) * 100).round(1), @@ -43,6 +43,10 @@ def show private + def set_player + @player = organization_scoped(Player).find(params[:player_id]) + end + def build_laning_trend(stats) stats.map do |stat| next unless stat.match.game_start diff --git a/app/modules/analytics/controllers/performance_controller.rb b/app/modules/analytics/controllers/performance_controller.rb index 26583d7..02c1534 100644 --- a/app/modules/analytics/controllers/performance_controller.rb +++ b/app/modules/analytics/controllers/performance_controller.rb @@ -30,6 +30,7 @@ module Controllers # GET /api/v1/analytics/performance?time_period=week class PerformanceController < Api::V1::BaseController include ::Analytics::Concerns::AnalyticsCalculations + include Cacheable # Returns performance analytics for the organization # @@ -43,15 +44,12 @@ class PerformanceController < Api::V1::BaseController # @param player_id [Integer] Player ID for individual stats (optional) # @return [JSON] Performance analytics data def index - matches = apply_date_filters(organization_scoped(Match)) - - # Use active players for team-wide stats (best performers, role breakdown, etc.) - # but validate player_id against ALL org players so that bench/trial/inactive - # players can still have their individual stats viewed. - active_players = organization_scoped(Player).includes(:organization).active + # Use all non-deleted org players for team-wide stats (best performers, role + # breakdown, etc.) so that bench/trial/inactive players who have match stats + # still appear in the leaderboard. Individual player stats use the same scope. all_org_players = organization_scoped(Player).includes(:organization) - player_id = params[:player_id].presence + if player_id.present? && !all_org_players.exists?(id: player_id) return render_error( message: 'Player not found', @@ -60,10 +58,14 @@ def index ) end - service = PerformanceAnalyticsService.new(matches, active_players) - performance_data = service.calculate_performance_data(player_id: player_id, all_players: all_org_players) + cache_key = performance_cache_key(player_id) + data = cache_response(cache_key, expires_in: 15.minutes) do + matches = apply_date_filters(organization_scoped(Match)) + service = PerformanceAnalyticsService.new(matches, all_org_players) + service.calculate_performance_data(player_id: player_id, all_players: all_org_players) + end - render_success(performance_data) + render_success(data) rescue StandardError => e Rails.logger.error("Error in performance#index: #{e.message}") Rails.logger.error(e.backtrace.join("\n")) @@ -91,6 +93,24 @@ def apply_date_filters(matches) end end + # Builds a cache key segment that distinguishes team vs player requests + # and incorporates active date-filter params so that different filter + # combinations never share a cached result. + # + # The key is intentionally short and URL-safe; the org-scoping prefix + # is added by the Cacheable concern's +build_cache_key+ method. + # + # @param player_id [String, nil] player_id param value (nil for team view) + # @return [String] cache key segment, e.g. + # "analytics/performance/team", + # "analytics/performance/team/month", + # "analytics/performance/player/42/2025-01-01-2025-01-31" + def performance_cache_key(player_id) + base = player_id ? "analytics/performance/player/#{player_id}" : 'analytics/performance/team' + suffix = [params[:time_period], params[:start_date], params[:end_date]].compact.join('-') + suffix.present? ? "#{base}/#{suffix}" : base + end + # Converts time period string to number of days # # @param period [String] Time period (week, month, season) diff --git a/app/modules/analytics/controllers/ping_profile_controller.rb b/app/modules/analytics/controllers/ping_profile_controller.rb index d50456b..e463b16 100644 --- a/app/modules/analytics/controllers/ping_profile_controller.rb +++ b/app/modules/analytics/controllers/ping_profile_controller.rb @@ -11,17 +11,24 @@ module Controllers # GET /api/v1/analytics/players/:player_id/ping-profile # GET /api/v1/analytics/players/:player_id/ping-profile?games=30 class PingProfileController < Api::V1::BaseController + before_action :set_player, only: %i[show] + def show - player = organization_scoped(Player).find(params[:player_id]) games = [params.fetch(:games, 20).to_i, 50].min - profile = PingProfileService.new(player, matches_limit: games).calculate + profile = PingProfileService.new(@player, matches_limit: games).calculate render_success({ - player: PlayerSerializer.render_as_hash(player), + player: PlayerSerializer.render_as_hash(@player), ping_profile: profile }) end + + private + + def set_player + @player = organization_scoped(Player).find(params[:player_id]) + end end end end diff --git a/app/modules/analytics/controllers/team_comparison_controller.rb b/app/modules/analytics/controllers/team_comparison_controller.rb index 5d63ba1..16673e5 100644 --- a/app/modules/analytics/controllers/team_comparison_controller.rb +++ b/app/modules/analytics/controllers/team_comparison_controller.rb @@ -7,7 +7,7 @@ module Controllers # with advanced filtering options class TeamComparisonController < Api::V1::BaseController def index - players = fetch_active_players + players = fetch_roster_players matches = build_matches_query comparison_data = build_comparison_data(players, matches) @@ -17,8 +17,8 @@ def index private - def fetch_active_players - organization_scoped(Player).includes(:organization).active + def fetch_roster_players + organization_scoped(Player).includes(:organization).where.not(status: 'removed') end def build_matches_query @@ -60,59 +60,81 @@ def build_comparison_data(players, matches) end # Single GROUP BY query replaces one query per player (N+1 β†’ 1) + # Players with no stats in the period appear with zero values def build_player_comparisons(players, matches) player_ids = players.pluck(:id) - match_ids = matches.pluck(:id) - return [] if player_ids.empty? || match_ids.empty? - - agg_rows = PlayerMatchStat - .where(player_id: player_ids, match_id: match_ids) - .group(:player_id) - .select( - 'player_id', - 'COUNT(*) AS games_played', - 'SUM(kills) AS total_kills', - 'SUM(deaths) AS total_deaths', - 'SUM(assists) AS total_assists', - 'AVG(damage_dealt_total) AS avg_damage', - 'AVG(gold_earned) AS avg_gold', - 'AVG(cs) AS avg_cs', - 'AVG(vision_score) AS avg_vision_score', - 'AVG(performance_score) AS avg_performance_score', - 'SUM(double_kills) AS double_kills', - 'SUM(triple_kills) AS triple_kills', - 'SUM(quadra_kills) AS quadra_kills', - 'SUM(penta_kills) AS penta_kills' - ) - - players_by_id = players.index_by(&:id) - - agg_rows.filter_map do |agg| - player = players_by_id[agg.player_id] - next unless player - - deaths = agg.total_deaths.to_i.zero? ? 1 : agg.total_deaths.to_i - kda = ((agg.total_kills.to_i + agg.total_assists.to_i).to_f / deaths).round(2) - - { - player: PlayerSerializer.render_as_hash(player), - games_played: agg.games_played.to_i, - kda: kda, - avg_damage: agg.avg_damage.to_f.round(0), - avg_gold: agg.avg_gold.to_f.round(0), - avg_cs: agg.avg_cs.to_f.round(1), - avg_vision_score: agg.avg_vision_score.to_f.round(1), - avg_performance_score: agg.avg_performance_score.to_f.round(1), - multikills: { - double: agg.double_kills.to_i, - triple: agg.triple_kills.to_i, - quadra: agg.quadra_kills.to_i, - penta: agg.penta_kills.to_i - } - } + return [] if player_ids.empty? + + match_ids = matches.pluck(:id) + + agg_by_player_id = if match_ids.empty? + {} + else + PlayerMatchStat + .where(player_id: player_ids, match_id: match_ids) + .group(:player_id) + .select( + 'player_id', + 'COUNT(*) AS games_played', + 'SUM(kills) AS total_kills', + 'SUM(deaths) AS total_deaths', + 'SUM(assists) AS total_assists', + 'AVG(damage_dealt_total) AS avg_damage', + 'AVG(gold_earned) AS avg_gold', + 'AVG(cs) AS avg_cs', + 'AVG(vision_score) AS avg_vision_score', + 'AVG(performance_score) AS avg_performance_score', + 'SUM(double_kills) AS double_kills', + 'SUM(triple_kills) AS triple_kills', + 'SUM(quadra_kills) AS quadra_kills', + 'SUM(penta_kills) AS penta_kills' + ).index_by(&:player_id) + end + + players.map do |player| + agg = agg_by_player_id[player.id] + build_player_entry(player, agg) end.sort_by { |p| -p[:avg_performance_score] } end + def build_player_entry(player, agg) + return zero_stats_entry(player) unless agg + + deaths = agg.total_deaths.to_i.zero? ? 1 : agg.total_deaths.to_i + kda = ((agg.total_kills.to_i + agg.total_assists.to_i).to_f / deaths).round(2) + + { + player: PlayerSerializer.render_as_hash(player), + games_played: agg.games_played.to_i, + kda: kda, + avg_damage: agg.avg_damage.to_f.round(0), + avg_gold: agg.avg_gold.to_f.round(0), + avg_cs: agg.avg_cs.to_f.round(1), + avg_vision_score: agg.avg_vision_score.to_f.round(1), + avg_performance_score: agg.avg_performance_score.to_f.round(1), + multikills: { + double: agg.double_kills.to_i, + triple: agg.triple_kills.to_i, + quadra: agg.quadra_kills.to_i, + penta: agg.penta_kills.to_i + } + } + end + + def zero_stats_entry(player) + { + player: PlayerSerializer.render_as_hash(player), + games_played: 0, + kda: 0.0, + avg_damage: 0, + avg_gold: 0, + avg_cs: 0.0, + avg_vision_score: 0.0, + avg_performance_score: 0.0, + multikills: { double: 0, triple: 0, quadra: 0, penta: 0 } + } + end + def calculate_average(stats, column, precision) stats.average(column)&.round(precision) || 0 end @@ -148,35 +170,50 @@ def calculate_team_averages(matches) end # Single GROUP BY across all roles β€” replaces 3N per-player queries + # Players with no stats appear in their role slot with 0 games def calculate_role_rankings(players, matches) player_ids = players.pluck(:id) - match_ids = matches.pluck(:id) - - rankings = { 'top' => [], 'jungle' => [], 'mid' => [], 'adc' => [], 'support' => [] } - return rankings if player_ids.empty? || match_ids.empty? - - agg_rows = PlayerMatchStat - .joins(:player) - .where(player_id: player_ids, match_id: match_ids) - .group('player_id, players.role, players.summoner_name') - .select( - 'player_id', - 'players.role AS role', - 'players.summoner_name AS summoner_name', - 'COUNT(*) AS games', - 'AVG(performance_score) AS avg_performance' - ) - - agg_rows.each do |agg| - role = agg.role + rankings = { 'top' => [], 'jungle' => [], 'mid' => [], 'adc' => [], 'support' => [] } + return rankings if player_ids.empty? + + match_ids = matches.pluck(:id) + + agg_by_player_id = if match_ids.empty? + {} + else + PlayerMatchStat + .joins(:player) + .where(player_id: player_ids, match_id: match_ids) + .group('player_id, players.role, players.summoner_name') + .select( + 'player_id', + 'players.role AS role', + 'players.summoner_name AS summoner_name', + 'COUNT(*) AS games', + 'AVG(performance_score) AS avg_performance' + ).index_by(&:player_id) + end + + players.each do |player| + role = player.role next unless rankings.key?(role) - rankings[role] << { - player_id: agg.player_id, - summoner_name: agg.summoner_name, - avg_performance: agg.avg_performance.to_f.round(1), - games: agg.games.to_i - } + agg = agg_by_player_id[player.id] + rankings[role] << if agg + { + player_id: player.id, + summoner_name: player.summoner_name, + avg_performance: agg.avg_performance.to_f.round(1), + games: agg.games.to_i + } + else + { + player_id: player.id, + summoner_name: player.summoner_name, + avg_performance: 0.0, + games: 0 + } + end end rankings.transform_values { |list| list.sort_by { |p| -p[:avg_performance] } } diff --git a/app/modules/analytics/controllers/teamfights_controller.rb b/app/modules/analytics/controllers/teamfights_controller.rb index 7563a45..9315192 100644 --- a/app/modules/analytics/controllers/teamfights_controller.rb +++ b/app/modules/analytics/controllers/teamfights_controller.rb @@ -16,18 +16,18 @@ module Controllers # Main endpoints: # - GET show: Returns teamfight statistics for the last 20 matches including damage and multikills class TeamfightsController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) + before_action :set_player, only: %i[show] + def show stats = PlayerMatchStat.joins(:match) - .where(player: player) + .where(player: @player) .where('matches.organization_id = ?', current_organization.id) .order('matches.game_start DESC') .preload(:match) .limit(20) teamfight_data = { - player: PlayerSerializer.render_as_hash(player), + player: PlayerSerializer.render_as_hash(@player), damage_performance: { avg_damage_dealt: stats.average(:damage_dealt_total)&.round(0), avg_damage_taken: stats.average(:damage_taken)&.round(0), @@ -68,6 +68,10 @@ def show private + def set_player + @player = organization_scoped(Player).find(params[:player_id]) + end + def calculate_avg_damage_per_min(stats) total_damage = 0 total_minutes = 0 diff --git a/app/modules/analytics/controllers/vision_controller.rb b/app/modules/analytics/controllers/vision_controller.rb index 162c62a..d834225 100644 --- a/app/modules/analytics/controllers/vision_controller.rb +++ b/app/modules/analytics/controllers/vision_controller.rb @@ -8,17 +8,17 @@ module Controllers # without unpacking nested keys. # class VisionController < Api::V1::BaseController - def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - player = organization_scoped(Player).find(params[:player_id]) + before_action :set_player, only: %i[show] + def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity stats = PlayerMatchStat.joins(:match) .includes(:match) - .where(player: player, match: { organization: current_organization }) + .where(player: @player, match: { organization: current_organization }) .order('"match"."game_start" DESC') .limit(20) vision_data = { - player: PlayerSerializer.render_as_hash(player), + player: PlayerSerializer.render_as_hash(@player), avg_vision_score: stats.average(:vision_score)&.round(1) || 0, avg_wards_placed: stats.average(:wards_placed)&.round(1) || 0, avg_wards_destroyed: stats.average(:wards_destroyed)&.round(1) || 0, @@ -27,7 +27,7 @@ def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple total_wards_placed: stats.sum(:wards_placed) || 0, total_wards_destroyed: stats.sum(:wards_destroyed) || 0, vision_per_min: calculate_avg_vision_per_min(stats), - role_comparison: calculate_role_comparison(player), + role_comparison: calculate_role_comparison(@player), vision_trend: build_vision_trend(stats) } @@ -36,6 +36,10 @@ def show # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple private + def set_player + @player = organization_scoped(Player).find(params[:player_id]) + end + def build_vision_trend(stats) stats.map do |stat| next unless stat.match.game_start diff --git a/app/modules/analytics/services/performance_analytics_service.rb b/app/modules/analytics/services/performance_analytics_service.rb index 724a9c1..4723baf 100644 --- a/app/modules/analytics/services/performance_analytics_service.rb +++ b/app/modules/analytics/services/performance_analytics_service.rb @@ -28,8 +28,8 @@ def initialize(matches, players) # # @param player_id [Integer, nil] Optional player ID for individual stats # @param all_players [ActiveRecord::Relation, nil] Scope to resolve the individual player - # from. Defaults to @players (active only). Pass the full org scope when you want to - # allow individual stats for inactive/bench/trial players too. + # from. Defaults to @players. Pass a different scope when you want to restrict or + # expand the set of players eligible for individual stat lookup. # @return [Hash] Performance analytics data def calculate_performance_data(player_id: nil, all_players: nil) if player_id diff --git a/app/modules/authentication/controllers/auth_controller.rb b/app/modules/authentication/controllers/auth_controller.rb index 1079f3c..a3c50bc 100644 --- a/app/modules/authentication/controllers/auth_controller.rb +++ b/app/modules/authentication/controllers/auth_controller.rb @@ -145,19 +145,13 @@ def login # @param player_email [String] The player's individual access email # @param password [String] The player's individual access password # @return [JSON] Player info and JWT tokens - def player_login # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def player_login player_email = params[:player_email]&.downcase&.strip password = params[:password] - if player_email.blank? || password.blank? - return render_error( - message: 'Email e senha sΓ£o obrigatΓ³rios', - code: 'MISSING_CREDENTIALS', - status: :bad_request - ) - end + return render_missing_credentials if player_email.blank? || password.blank? - player = Player.find_by(player_email: player_email) + player = Player.unscoped.find_by(player_email: player_email) unless player&.has_player_access? && player.authenticate_player_password(password) return render_error( @@ -176,40 +170,7 @@ def player_login # rubocop:disable Metrics/CyclomaticComplexity, Metrics/Perceiv ) render_success( - { - player: { - id: player.id, - name: player.real_name.presence || player.summoner_name, - professional_name: player.professional_name, - summoner_name: player.summoner_name, - role: player.role, - status: player.status, - country: player.country, - profile_icon_id: player.profile_icon_id, - avatar_url: player.avatar_url.presence, - organization_id: player.organization_id, - organization_name: player.organization&.name, - # Rank - solo_queue_tier: player.solo_queue_tier, - solo_queue_rank: player.solo_queue_rank, - solo_queue_lp: player.solo_queue_lp, - solo_queue_wins: player.solo_queue_wins, - solo_queue_losses: player.solo_queue_losses, - flex_queue_tier: player.flex_queue_tier, - flex_queue_rank: player.flex_queue_rank, - flex_queue_lp: player.flex_queue_lp, - peak_tier: player.peak_tier, - peak_rank: player.peak_rank, - peak_season: player.peak_season, - # Performance - win_rate: player.win_rate, - # Champions - main_champions: player.main_champions, - # Social - twitter_handle: player.twitter_handle, - twitch_channel: player.twitch_channel - } - }.merge(tokens), + { player: serialize_player_login(player) }.merge(tokens), message: 'Login realizado com sucesso' ) rescue StandardError => e @@ -237,67 +198,22 @@ def player_login # rubocop:disable Metrics/CyclomaticComplexity, Metrics/Perceiv # @param summoner_name [String] Riot summoner name (e.g. "GameName#TAG") # @param discord_user_id [String] Discord username (optional) # - def player_register # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def player_register player_email = params[:player_email]&.downcase&.strip summoner_name = params[:summoner_name]&.strip password = params[:password] - password_conf = params[:password_confirmation] discord = params[:discord_user_id]&.strip - # ── Validate required fields ───────────────────────────────────────── - missing = [] - missing << 'player_email' if player_email.blank? - missing << 'password' if password.blank? - missing << 'summoner_name' if summoner_name.blank? + error = validate_player_register_params(player_email, summoner_name, password) + return error if error - if missing.any? - return render_error( - message: "Campos obrigatΓ³rios faltando: #{missing.join(', ')}", - code: 'MISSING_FIELDS', - status: :bad_request - ) - end + player = build_free_agent_player(player_email, summoner_name, password, discord) - # ── Password confirmation ───────────────────────────────────────────── - if password != password_conf - return render_error( - message: 'Senhas nΓ£o coincidem', - code: 'PASSWORD_MISMATCH', - status: :unprocessable_entity - ) - end - - # ── Duplicate email check ───────────────────────────────────────────── - if Player.exists?(player_email: player_email) - return render_error( - message: 'JΓ‘ existe uma conta de jogador com este email', - code: 'DUPLICATE_EMAIL', - status: :unprocessable_entity - ) - end + Current.skip_organization_scope = true + saved = player.save + Current.skip_organization_scope = false - # ── Duplicate summoner name check ────────────────────────────────────── - if Player.exists?(['LOWER(summoner_name) = ?', summoner_name.downcase]) - return render_error( - message: 'Summoner name jΓ‘ cadastrado na plataforma', - code: 'DUPLICATE_SUMMONER', - status: :unprocessable_entity - ) - end - - # ── Create player β€” SECURITY: organization_id always nil (free agent) ── - player = Player.new( - player_email: player_email, - player_password: password, - summoner_name: summoner_name, - discord_user_id: discord.presence, - player_access_enabled: true, - status: 'active', - role: 'top' # placeholder β€” player updates via profile - # organization_id intentionally omitted (nil) β€” free agent - ) - - unless player.save + unless saved return render_error( message: 'Erro ao criar conta', code: 'VALIDATION_ERROR', @@ -311,25 +227,15 @@ def player_register # rubocop:disable Metrics/CyclomaticComplexity, Metrics/Perc tokens = JwtService.generate_player_tokens(player) render_created( - { - player: { - id: player.id, - summoner_name: player.summoner_name, - player_email: player.player_email, - discord_user_id: player.discord_user_id, - role: player.role, - status: player.status, - organization_id: nil, - organization_name: nil, - is_free_agent: true, - solo_queue_tier: nil, - solo_queue_rank: nil, - solo_queue_lp: nil, - current_rank: nil - } - }.merge(tokens), + { player: serialize_new_free_agent(player) }.merge(tokens), message: 'Conta criada! VocΓͺ estΓ‘ no pool de Free Agents do ArenaBR Season 1.' ) + rescue ActiveRecord::RecordNotUnique + render_error( + message: 'Discord jΓ‘ vinculado a outra conta', + code: 'DUPLICATE_DISCORD', + status: :unprocessable_entity + ) rescue StandardError => e Rails.logger.error("Player register error: #{e.class} - #{e.message}") render_error(message: 'Erro interno ao criar conta', code: 'INTERNAL_ERROR', status: :internal_server_error) @@ -369,15 +275,26 @@ def refresh # Logs out the current user # # Blacklists the current access token to prevent further use. - # The user must login again to obtain new tokens. + # Optionally blacklists the refresh token if sent in the request body, so that + # an attacker who obtained the refresh token cannot create new sessions after + # the user has explicitly logged out. + # + # The client SHOULD send the refresh token in the body for full session + # invalidation. Omitting it is not an error, but leaves the refresh token valid + # until its natural expiry. # # POST /api/v1/auth/logout # + # @param refresh_token [String] (optional) The refresh token to also invalidate # @return [JSON] Success message def logout # Blacklist the current access token - token = request.headers['Authorization']&.split&.last - JwtService.blacklist_token(token) if token + access_token = request.headers['Authorization']&.split&.last + JwtService.blacklist_token(access_token) if access_token + + # Also blacklist the refresh token when the client supplies it + refresh_token = params[:refresh_token] + JwtService.blacklist_token(refresh_token) if refresh_token.present? log_user_action( action: 'logout', @@ -408,25 +325,13 @@ def forgot_password ) end - user = User.find_by(email: email) + user = User.unscoped.find_by(email: email) + player = Player.unscoped.find_by(player_email: email) unless user if user - reset_token = user.password_reset_tokens.create!( - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - deliver_email(UserMailer.password_reset(user, reset_token)) - - AuditLog.create!( - organization: user.organization, - user: user, - action: 'password_reset_requested', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) + handle_user_password_reset(user) + elsif player + handle_player_password_reset(player) end render_success( @@ -469,24 +374,11 @@ def reset_password reset_token = PasswordResetToken.valid.find_by(token: token) - if reset_token - user = reset_token.user - user.update!(password: new_password) - - reset_token.mark_as_used! - - deliver_email(UserMailer.password_reset_confirmation(user)) - - AuditLog.create!( - organization: user.organization, - user: user, - action: 'password_reset_completed', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - + if reset_token&.user + complete_user_password_reset(reset_token, new_password) + render_success({}, message: 'Password reset successful') + elsif reset_token&.player + complete_player_password_reset(reset_token, new_password) render_success({}, message: 'Password reset successful') else render_error( @@ -529,10 +421,192 @@ def create_organization! def create_user!(organization) User.create!(user_params.merge( organization: organization, - role: 'owner' # First user is always the owner + role: 'owner', + source_app: source_app_from_origin )) end + def render_missing_credentials + render_error( + message: 'Email e senha sΓ£o obrigatΓ³rios', + code: 'MISSING_CREDENTIALS', + status: :bad_request + ) + end + + def serialize_player_login(player) + { + id: player.id, + name: player.real_name.presence || player.summoner_name, + professional_name: player.professional_name, + summoner_name: player.summoner_name, + role: player.role, + status: player.status, + country: player.country, + profile_icon_id: player.profile_icon_id, + avatar_url: player.avatar_url.presence, + organization_id: player.organization_id, + organization_name: player.organization&.name, + solo_queue_tier: player.solo_queue_tier, + solo_queue_rank: player.solo_queue_rank, + solo_queue_lp: player.solo_queue_lp, + solo_queue_wins: player.solo_queue_wins, + solo_queue_losses: player.solo_queue_losses, + flex_queue_tier: player.flex_queue_tier, + flex_queue_rank: player.flex_queue_rank, + flex_queue_lp: player.flex_queue_lp, + peak_tier: player.peak_tier, + peak_rank: player.peak_rank, + peak_season: player.peak_season, + win_rate: player.win_rate, + main_champions: player.main_champions, + twitter_handle: player.twitter_handle, + twitch_channel: player.twitch_channel + } + end + + def validate_player_register_params(player_email, summoner_name, password) + missing = [] + missing << 'player_email' if player_email.blank? + missing << 'password' if password.blank? + missing << 'summoner_name' if summoner_name.blank? + + if missing.any? + return render_error( + message: "Campos obrigatΓ³rios faltando: #{missing.join(', ')}", + code: 'MISSING_FIELDS', + status: :bad_request + ) + end + + if params[:password] != params[:password_confirmation] + return render_error( + message: 'Senhas nΓ£o coincidem', + code: 'PASSWORD_MISMATCH', + status: :unprocessable_entity + ) + end + + if Player.unscoped.exists?(player_email: player_email) + return render_error( + message: 'JΓ‘ existe uma conta de jogador com este email', + code: 'DUPLICATE_EMAIL', + status: :unprocessable_entity + ) + end + + if Player.unscoped.exists?(['LOWER(summoner_name) = ?', summoner_name.downcase]) + return render_error( + message: 'Summoner name jΓ‘ cadastrado na plataforma', + code: 'DUPLICATE_SUMMONER', + status: :unprocessable_entity + ) + end + + nil + end + + def build_free_agent_player(player_email, summoner_name, password, discord) + Player.new( + player_email: player_email, + player_password: password, + summoner_name: summoner_name, + discord_user_id: discord.presence, + player_access_enabled: true, + status: 'active', + role: 'top', + source_app: 'arena_br' + # organization_id intentionally omitted (nil) β€” free agent + ) + end + + def serialize_new_free_agent(player) + { + id: player.id, + summoner_name: player.summoner_name, + player_email: player.player_email, + discord_user_id: player.discord_user_id, + role: player.role, + status: player.status, + organization_id: nil, + organization_name: nil, + is_free_agent: true, + solo_queue_tier: nil, + solo_queue_rank: nil, + solo_queue_lp: nil, + current_rank: nil + } + end + + def handle_user_password_reset(user) + reset_token = user.password_reset_tokens.create!( + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + frontend_url = frontend_url_from_origin || frontend_base_for(user) + deliver_email(UserMailer.password_reset(user, reset_token, frontend_url)) + AuditLog.create!( + organization: user.organization, + user: user, + action: 'password_reset_requested', + entity_type: 'User', + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + end + + def handle_player_password_reset(player) + reset_token = player.password_reset_tokens.create!( + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + frontend_url = frontend_url_from_origin || frontend_base_for(player) + deliver_email(PlayerMailer.password_reset(player, reset_token, frontend_url)) + end + + def complete_user_password_reset(reset_token, new_password) + user = reset_token.user + user.update!(password: new_password) + reset_token.mark_as_used! + deliver_email(UserMailer.password_reset_confirmation(user)) + AuditLog.create!( + organization: user.organization, + user: user, + action: 'password_reset_completed', + entity_type: 'User', + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + end + + def complete_player_password_reset(reset_token, new_password) + player = reset_token.player + player.update!(player_password: new_password) + reset_token.mark_as_used! + deliver_email(PlayerMailer.password_reset_confirmation(player)) + end + + def source_app_from_origin + origin = request.headers['Origin']&.strip&.chomp('/') + return 'prostaff' unless origin.present? + + Constants::SOURCE_APP_URLS.find { |_src, url| url.chomp('/') == origin }&.first || 'prostaff' + end + + def frontend_url_from_origin + origin = request.headers['Origin']&.strip&.chomp('/') + return nil unless origin.present? + + Constants::SOURCE_APP_URLS.values.find { |url| url.chomp('/') == origin } + end + + def frontend_base_for(record) + source = record.source_app.presence || 'prostaff' + Constants::SOURCE_APP_URLS.fetch(source, ENV.fetch('PROSTAFF_URL', 'https://prostaff.gg')) + end + def authenticate_user! email = params[:email]&.downcase&.strip password = params[:password] diff --git a/app/modules/authentication/services/jwt_service.rb b/app/modules/authentication/services/jwt_service.rb index dd837ef..22753fd 100644 --- a/app/modules/authentication/services/jwt_service.rb +++ b/app/modules/authentication/services/jwt_service.rb @@ -7,7 +7,11 @@ # - Requires TokenBlacklist model with methods: blacklisted?(jti), add_to_blacklist(jti, expires_at) # - Requires User model with attributes: id, organization_id, role, email class JwtService - SECRET_KEY = ENV.fetch('JWT_SECRET_KEY') { Rails.application.secret_key_base } + # jwt >= 3.2.0 rejects nil/empty HMAC keys (CVE-2026-45363). + # Raise at boot time so a missing env var is caught immediately, not at first request. + SECRET_KEY = ENV.fetch('JWT_SECRET_KEY') { Rails.application.secret_key_base }.tap do |key| + raise 'JWT_SECRET_KEY / secret_key_base must not be blank' if key.blank? + end EXPIRATION_HOURS = ENV.fetch('JWT_EXPIRATION_HOURS', 24).to_i REFRESH_EXPIRATION_DAYS = ENV.fetch('JWT_REFRESH_EXPIRATION_DAYS', 7).to_i @@ -103,21 +107,51 @@ def generate_tokens(user) end # Refreshes the access token using a valid refresh token + # + # Uses a Redis SET NX claim (via TokenBlacklist.claim_for_rotation) before any + # state mutation to prevent TOCTOU race conditions. Concurrent requests carrying + # the same refresh token will be rejected after the first one successfully claims + # the jti. The database blacklist (add_to_blacklist) is the durable record that + # survives beyond the Redis TTL. + # + # Flow: + # 1. JWT.decode (signature + expiry) β€” no blacklist DB check yet + # 2. Redis SET NX claim on jti β€” atomic gate against concurrent replays + # 3. type == 'refresh' assertion + # 4. User lookup + # 5. Persist DB blacklist entry + # 6. Generate new token pair + # # @param refresh_token [String] The refresh token # @return [Hash] New access and refresh tokens # @raise [TokenInvalidError, TokenExpiredError, TokenRevokedError, UserNotFoundError] def refresh_access_token(refresh_token) - # Use decode() to leverage centralized validation logic - payload = decode(refresh_token) + raw = JWT.decode(refresh_token, SECRET_KEY, true, { algorithm: 'HS256' }) + payload = HashWithIndifferentAccess.new(raw[0]) + + # Reject already-blacklisted tokens (DB check β€” covers post-TTL replays) + if payload[:jti].present? && TokenBlacklist.blacklisted?(payload[:jti]) + raise TokenRevokedError, 'Refresh token has been revoked' + end + + # Atomic Redis gate β€” first caller wins; concurrent replays are rejected here + jti = payload[:jti] + unless jti.present? && TokenBlacklist.claim_for_rotation(jti) + raise TokenRevokedError, 'Refresh token already used' + end raise TokenInvalidError, 'Invalid refresh token' unless payload[:type] == 'refresh' user = User.find(payload[:user_id]) - # Blacklist the old refresh token (passing payload to avoid re-decoding) + # Persist durable blacklist entry so the token is rejected after Redis TTL too blacklist_token(refresh_token, payload: payload) generate_tokens(user) + rescue JWT::ExpiredSignature + raise TokenExpiredError, 'Refresh token has expired' + rescue JWT::DecodeError => e + raise TokenInvalidError, "Invalid token: #{e.message}" rescue ActiveRecord::RecordNotFound raise UserNotFoundError, 'User not found' end diff --git a/app/modules/competitive/concerns/match_fingerprint.rb b/app/modules/competitive/concerns/match_fingerprint.rb new file mode 100644 index 0000000..46ac036 --- /dev/null +++ b/app/modules/competitive/concerns/match_fingerprint.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Shared fingerprinting logic for deduplicating competitive match imports. +# +# Two import pipelines (Riot API numeric IDs and Leaguepedia textual IDs) can +# produce different external_match_id values for the same physical game. This +# module derives a source-agnostic fingerprint so duplicates are caught before +# they are persisted. +module Competitive + module Concerns + # Shared fingerprinting logic for deduplicating competitive match imports. + module MatchFingerprint + # Generates a stable fingerprint for a physical game based on attributes that + # are source-agnostic. + # + # @param org_id [String] organization UUID + # @param match_date [DateTime, nil] + # @param game_number [Integer, nil] game within the series (1-5) + # @param opponent_name [String, nil] + # @return [String, nil] SHA-256 hex string, or nil when inputs are insufficient + def generate_fingerprint(org_id, match_date, game_number, opponent_name) + return nil if match_date.nil? || opponent_name.nil? || opponent_name.strip.empty? + + day = match_date.to_date.to_s + normalized = opponent_name.strip.downcase + Digest::SHA256.hexdigest("#{org_id}|#{day}|#{game_number || 1}|#{normalized}") + end + + # Returns true if a record with this fingerprint already exists for the org. + # Skips the check when the fingerprint cannot be computed (missing inputs). + # + # @param organization [Organization] + # @param match_date [DateTime, nil] + # @param game_number [Integer, nil] + # @param opponent_name [String, nil] + # @return [Boolean] + def duplicate_by_fingerprint?(organization, match_date, game_number, opponent_name) + fp = generate_fingerprint(organization.id, match_date, game_number, opponent_name) + return false if fp.nil? + + organization.competitive_matches.where(game_fingerprint: fp).exists? + end + end + end +end diff --git a/app/modules/competitive/controllers/pro_matches_controller.rb b/app/modules/competitive/controllers/pro_matches_controller.rb index 1ee251b..0eb4a37 100644 --- a/app/modules/competitive/controllers/pro_matches_controller.rb +++ b/app/modules/competitive/controllers/pro_matches_controller.rb @@ -13,6 +13,7 @@ class ProMatchesController < Api::V1::BaseController # List recent professional matches from database def index matches = current_organization.competitive_matches + .includes(:opponent_team, :organization) .ordered_by_date .page(params[:page] || 1) .per(params[:per_page] || 20) @@ -59,59 +60,56 @@ def show end # GET /api/v1/competitive/pro-matches/upcoming - # Fetch upcoming matches from PandaScore API def upcoming - league = params[:league] - per_page = params[:per_page]&.to_i || 10 + league = params[:league] + per_page = params[:per_page]&.to_i || 20 + page = params[:page]&.to_i || 1 - matches = @pandascore_service.fetch_upcoming_matches( - league: league, - per_page: per_page - ) + result = @pandascore_service.fetch_upcoming_matches(league: league, per_page: per_page, page: page, + search: params[:search]) + + total_pages = build_total_pages(result, page) render json: { - message: 'Upcoming matches retrieved successfully', data: { - matches: matches, + matches: result[:data], + pagination: pagination_for(result, total_pages), source: 'pandascore', cached: true } } + rescue PandascoreService::RateLimitError => e + Rails.logger.warn "[ProMatches#upcoming] Rate limit: #{e.message}" + render json: { error: { code: 'PANDASCORE_RATE_LIMITED', message: e.message } }, status: :too_many_requests rescue PandascoreService::PandascoreError => e - render json: { - error: { - code: 'PANDASCORE_ERROR', - message: e.message - } - }, status: :service_unavailable + Rails.logger.error "[ProMatches#upcoming] #{e.class}: #{e.message}" + render json: { error: { code: 'PANDASCORE_ERROR', message: e.message } }, status: :service_unavailable end # GET /api/v1/competitive/pro-matches/past - # Fetch past matches from PandaScore API def past - league = params[:league] + league = params[:league] per_page = params[:per_page]&.to_i || 20 + page = params[:page]&.to_i || 1 - matches = @pandascore_service.fetch_past_matches( - league: league, - per_page: per_page - ) + result = @pandascore_service.fetch_past_matches(league: league, per_page: per_page, page: page, + search: params[:search]) + total_pages = build_total_pages(result, page) render json: { - message: 'Past matches retrieved successfully', data: { - matches: matches, + matches: result[:data], + pagination: pagination_for(result, total_pages), source: 'pandascore', cached: true } } + rescue PandascoreService::RateLimitError => e + Rails.logger.warn "[ProMatches#past] Rate limit: #{e.message}" + render json: { error: { code: 'PANDASCORE_RATE_LIMITED', message: e.message } }, status: :too_many_requests rescue PandascoreService::PandascoreError => e - render json: { - error: { - code: 'PANDASCORE_ERROR', - message: e.message - } - }, status: :service_unavailable + Rails.logger.error "[ProMatches#past] #{e.class}: #{e.message}" + render json: { error: { code: 'PANDASCORE_ERROR', message: e.message } }, status: :service_unavailable end # POST /api/v1/competitive/pro-matches/refresh @@ -387,13 +385,145 @@ def import }, status: :unprocessable_entity end + # GET /api/v1/competitive/pro-matches/match-preview + # Aggregate preview data for a head-to-head matchup between two pro teams. + # Params: team1_id (integer), team2_id (integer), team1_name (string), team2_name (string) + def match_preview + team1_id = params[:team1_id] + team2_id = params[:team2_id] + team1_name = params[:team1_name].to_s.strip + team2_name = params[:team2_name].to_s.strip + + if team1_id.blank? || team2_id.blank? + return render json: { + error: { code: 'MISSING_PARAMS', message: 'team1_id and team2_id are required' } + }, status: :unprocessable_entity + end + + team1_data, team2_data, team1_recent, team2_recent = + fetch_pandascore_preview_data(team1_id, team2_id) + + h2h_wins_t1, h2h_wins_t2 = fetch_h2h_wins(team1_name, team2_name) + + render json: { + data: { + team1: serialize_team(team1_data, team1_recent), + team2: serialize_team(team2_data, team2_recent), + h2h_wins_team1: h2h_wins_t1, + h2h_wins_team2: h2h_wins_t2, + h2h_total: h2h_wins_t1 + h2h_wins_t2 + } + } + rescue StandardError => e + Rails.logger.error "[ProMatches#match_preview] #{e.class}: #{e.message}" + render json: { error: { code: 'MATCH_PREVIEW_ERROR', message: 'Failed to build match preview' } }, + status: :service_unavailable + end + + # GET /api/v1/competitive/pro-matches/es-series + # Search Elasticsearch for games between two teams. + # Params: team1, team2, league (optional), after (ISO date), before (ISO date), limit (default 20) + def es_series + team1 = params[:team1].to_s.strip + team2 = params[:team2].to_s.strip + limit = (params[:limit] || 5).to_i.clamp(1, 50) + + raise ArgumentError, 'team1 and team2 are required' if team1.blank? || team2.blank? + + es_body = build_series_query(team1, team2, limit) + result = ElasticsearchClient.new.search(index: 'lol_pro_matches', body: es_body) + games = result.dig('hits', 'hits')&.map { |h| h['_source'] } || [] + + render json: { data: { games: games, total: games.size } } + rescue ArgumentError => e + render json: { error: { code: 'INVALID_PARAMS', message: e.message } }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error("[ES Series] #{e.class}: #{e.message}") + render json: { error: { code: 'ES_ERROR', message: 'Failed to fetch series data' } }, + status: :internal_server_error + end + private def set_pandascore_service @pandascore_service = PandascoreService.instance end + def fetch_pandascore_preview_data(team1_id, team2_id) + t1_data = Thread.new { @pandascore_service.fetch_team(team1_id) } + t2_data = Thread.new { @pandascore_service.fetch_team(team2_id) } + t1_recent = Thread.new { @pandascore_service.fetch_team_recent_matches(team1_id) } + t2_recent = Thread.new { @pandascore_service.fetch_team_recent_matches(team2_id) } + [t1_data.value, t2_data.value, t1_recent.value, t2_recent.value] + end + + def fetch_h2h_wins(team1_name, team2_name) + must_clauses = [h2h_matchup_clause(team1_name, team2_name)] + es_body = { + query: { bool: { must: must_clauses } }, + size: 0, + aggs: { + team1_wins: { filter: win_team_clause(team1_name) }, + team2_wins: { filter: win_team_clause(team2_name) } + } + } + result = ElasticsearchClient.new.search(index: 'lol_pro_matches', body: es_body) + wins_t1 = result.dig('aggregations', 'team1_wins', 'doc_count') || 0 + wins_t2 = result.dig('aggregations', 'team2_wins', 'doc_count') || 0 + [wins_t1, wins_t2] + end + + def h2h_matchup_clause(team1_name, team2_name) + { + bool: { + should: [ + { bool: { must: [team_clause(team1_name, 'team1'), team_clause(team2_name, 'team2')] } }, + { bool: { must: [team_clause(team2_name, 'team1'), team_clause(team1_name, 'team2')] } } + ], + minimum_should_match: 1 + } + } + end + + def build_series_query(team1, team2, limit) + must_clauses = [h2h_matchup_clause(team1, team2)] + must_clauses << { range: { start_time: { gte: params[:after], lte: params[:before] } } } if date_filter? + { query: { bool: { must: must_clauses } }, sort: [{ start_time: { order: 'desc' } }], size: limit } + end + + def date_filter? + params[:after].present? && params[:before].present? + end + + # Builds an ES should clause that matches a team name using: + # 1. Exact term match (handles perfect name equality) + # 2. Prefix wildcard on first word, case-insensitive (handles suffix differences + # between sources, e.g. PandaScore "RED Academy" vs Leaguepedia "RED Kalunga Academy") + def team_clause(name, field) + clauses = [{ term: { "#{field}.name" => name } }] + + # Wildcard only for multi-word names to handle sponsor suffixes (e.g. "FlyQuest NZXT"). + # Uses the full name as prefix to avoid false matches ("Team" would hit "Team WE"). + if name.split.length > 1 + clauses << { wildcard: { "#{field}.name" => { value: "#{name}*", case_insensitive: true } } } + end + + { bool: { should: clauses, minimum_should_match: 1 } } + end + + # Matches win_team using the same prefix-wildcard logic as team_clause. + # Needed because PandaScore names have sponsor suffixes (e.g. "FlyQuest NZXT") + # while Oracle's Elixir stores the base name ("FlyQuest"). + def win_team_clause(name) + clauses = [{ term: { win_team: name } }] + + clauses << { wildcard: { win_team: { value: "#{name}*", case_insensitive: true } } } if name.split.length > 1 + + { bool: { should: clauses, minimum_should_match: 1 } } + end + def apply_filters(matches) + matches = apply_search(matches) matches = matches.by_tournament(params[:tournament]) if params[:tournament].present? matches = matches.by_region(params[:region]) if params[:region].present? matches = matches.by_patch(params[:patch]) if params[:patch].present? @@ -410,6 +540,89 @@ def apply_filters(matches) matches end + def apply_search(matches) + return matches unless params[:search].present? + + term = ActiveRecord::Base.sanitize_sql_like(params[:search]) + norm_term = ActiveRecord::Base.sanitize_sql_like(normalize_search_term(params[:search])) + + # Search by original term (case-insensitive) OR by normalized term + # translate() maps special chars (Γ˜β†’O, Γ¦β†’a, etc.) directly in PostgreSQL. + matches.where( + 'lower(opponent_team_name) LIKE lower(:t) OR lower(our_team_name) LIKE lower(:t) ' \ + 'OR lower(tournament_name) LIKE lower(:t) OR lower(tournament_region) LIKE lower(:t) ' \ + 'OR translate(lower(opponent_team_name), :from, :to) LIKE :n ' \ + 'OR translate(lower(our_team_name), :from, :to) LIKE :n ' \ + 'OR translate(lower(tournament_name), :from, :to) LIKE :n ' \ + 'OR translate(lower(tournament_region), :from, :to) LIKE :n', + t: "%#{term}%", + n: "%#{norm_term}%", + from: 'ΓΈΓ¦Γ₯ðþ', + to: 'oaadt' + ) + end + + def normalize_search_term(term) + term.downcase + .tr('ΓΈΓ₯ðþ', 'oadt') + .gsub('Γ¦', 'ae') + .gsub('ß', 'ss') + .unicode_normalize(:nfkd) + .gsub(/\p{Mn}/, '') + end + + def build_total_pages(result, page) + pages = result[:per_page].positive? ? [(result[:total].to_f / result[:per_page]).ceil, 1].max : 1 + result[:data].length >= result[:per_page] ? [pages, page].max : pages + end + + def pagination_for(result, total_pages) + { + current_page: result[:page], + per_page: result[:per_page], + total_count: result[:total], + total_pages: total_pages + } + end + + def serialize_team(team_data, recent_matches) + { + id: team_data['id'], + name: team_data['name'], + acronym: team_data['acronym'], + image_url: team_data['image_url'], + players: (team_data['players'] || []).map do |p| + { + id: p['id'], + name: p['name'], + role: p['role'], + image_url: p['image_url'], + nationality: p['nationality'] + } + end.select { |p| %w[top jun mid adc sup].include?(p[:role]) } + .sort_by { |p| %w[top jun mid adc sup].index(p[:role]) }, + recent: (recent_matches || []).first(5).map { |m| serialize_recent_match(m, team_data['id']) } + } + end + + def serialize_recent_match(match, our_team_id) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + opponents = match['opponents'] || [] + other_side = opponents.find { |o| o.dig('opponent', 'id') != our_team_id } + result = (match['results'] || []).find { |r| r['team_id'] == our_team_id } + other_result = (match['results'] || []).find { |r| r['team_id'] != our_team_id } + our_score = result&.dig('score') || 0 + opp_score = other_result&.dig('score') || 0 + + { + opponent_name: other_side&.dig('opponent', 'name'), + opponent_acronym: other_side&.dig('opponent', 'acronym'), + opponent_image_url: other_side&.dig('opponent', 'image_url'), + won: our_score > opp_score, + score: "#{our_score}-#{opp_score}", + date: match['begin_at']&.to_s&.first(10) + } + end + def import_match_to_database(match_data) # TODO: Implement match import logic # This would parse PandaScore match data and create a CompetitiveMatch record diff --git a/app/modules/competitive/jobs/historical_backfill_job.rb b/app/modules/competitive/jobs/historical_backfill_job.rb index 8a2ab17..36a01a8 100644 --- a/app/modules/competitive/jobs/historical_backfill_job.rb +++ b/app/modules/competitive/jobs/historical_backfill_job.rb @@ -39,96 +39,93 @@ class HistoricalBackfillJob < ApplicationJob # resumable, so the next scheduled run will pick up where it left off. MAX_WAIT_TIME = 6.hours - def perform - league = ENV.fetch('BACKFILL_LEAGUE', 'CBLOL') - min_year = ENV.fetch('BACKFILL_MIN_YEAR', '2013').to_i - our_team = ENV.fetch('BACKFILL_OUR_TEAM', 'paiN Gaming') + # @param options [Hash] optional β€” supports :league key. + # Handles sidekiq-scheduler kwargs wrapper format for backward compat. + def perform(options = {}) + opts = options[:kwargs] || options['kwargs'] || options + league = (opts[:league] || opts['league']).presence || ENV.fetch('BACKFILL_LEAGUE', 'CBLOL') + min_year = ENV.fetch('BACKFILL_MIN_YEAR', '2013').to_i sync_limit = ENV.fetch('BACKFILL_SYNC_LIMIT', '500').to_i scraper = ProStaffScraperService.new - # Step 1: Trigger the backfill on the scraper (returns immediately). + trigger_backfill(scraper, league, min_year) + poll_until_complete(scraper, league) + dispatch_sync_jobs(league, sync_limit) + + record_job_heartbeat + Rails.logger.info("[HistoricalBackfillJob] Done β€” league=#{league}") + end + + private + + def trigger_backfill(scraper, league, min_year) Rails.logger.info( '[HistoricalBackfillJob] Triggering backfill on scraper: ' \ "league=#{league} min_year=#{min_year}" ) + result = scraper.trigger_historical_backfill(league: league, min_year: min_year) + Rails.logger.info("[HistoricalBackfillJob] Scraper responded: #{result.inspect}") + rescue ProStaffScraperService::ScraperError => e + Rails.logger.warn( + "[HistoricalBackfillJob] Scraper trigger failed: #{e.message}. " \ + 'Proceeding to sync step (scraper may already be running).' + ) + end - begin - trigger_result = scraper.trigger_historical_backfill( - league: league, - min_year: min_year - ) - Rails.logger.info( - "[HistoricalBackfillJob] Scraper responded: #{trigger_result.inspect}" - ) - rescue ProStaffScraperService::ScraperError => e - Rails.logger.warn( - "[HistoricalBackfillJob] Scraper trigger failed: #{e.message}. " \ - 'Proceeding to sync step (scraper may already be running).' - ) - end - - # Step 2: Poll backfill status until completion or timeout. + def poll_until_complete(scraper, league) Rails.logger.info( "[HistoricalBackfillJob] Polling backfill status (max #{MAX_WAIT_TIME / 60}min)..." ) - - started_at = Time.current + started_at = Time.current last_status = nil loop do - elapsed = Time.current - started_at - if elapsed > MAX_WAIT_TIME - Rails.logger.warn( - "[HistoricalBackfillJob] Max wait time exceeded (#{MAX_WAIT_TIME / 3600}h). " \ - "Proceeding to sync step. Last status: #{last_status&.inspect}" - ) - break - end - - begin - last_status = scraper.historical_backfill_status(league: league) - remaining = last_status['remaining'] || 0 - completed = last_status['completed'] || 0 - total = last_status['total_tournaments'] || 0 - - Rails.logger.info( - "[HistoricalBackfillJob] Progress: #{completed}/#{total} tournaments " \ - "(#{remaining} remaining)" - ) - - # If nothing is pending/in-progress, the backfill is done. - break if remaining.zero? - rescue ProStaffScraperService::ScraperError => e - Rails.logger.warn( - "[HistoricalBackfillJob] Status poll failed: #{e.message}" - ) - end + break if Time.current - started_at > MAX_WAIT_TIME && log_timeout_warning(last_status) + + last_status = fetch_backfill_status(scraper, league) + break if last_status && (last_status['remaining'] || 0).zero? sleep POLL_INTERVAL end + end - # Step 3: Sync matches from ES into Rails DB for all organizations. + def fetch_backfill_status(scraper, league) + status = scraper.historical_backfill_status(league: league) + remaining = status['remaining'] || 0 + completed = status['completed'] || 0 + total = status['total_tournaments'] || 0 Rails.logger.info( - '[HistoricalBackfillJob] Starting sync step: ' \ - "league=#{league} our_team=#{our_team} limit=#{sync_limit}" + "[HistoricalBackfillJob] Progress: #{completed}/#{total} tournaments " \ + "(#{remaining} remaining)" + ) + status + rescue ProStaffScraperService::ScraperError => e + Rails.logger.warn("[HistoricalBackfillJob] Status poll failed: #{e.message}") + nil + end + + def log_timeout_warning(last_status) + Rails.logger.warn( + "[HistoricalBackfillJob] Max wait time exceeded (#{MAX_WAIT_TIME / 3600}h). " \ + "Proceeding to sync step. Last status: #{last_status&.inspect}" ) + true + end - Organization.find_each do |org| + def dispatch_sync_jobs(league, sync_limit) + Rails.logger.info("[HistoricalBackfillJob] Starting sync step: league=#{league} limit=#{sync_limit}") + Organization.where.not(competitive_team_name: [nil, '']).find_each do |org| Rails.logger.info( - "[HistoricalBackfillJob] Syncing for org=#{org.id} (#{org.name})" + "[HistoricalBackfillJob] Syncing org=#{org.id} (#{org.name}) team=#{org.competitive_team_name}" ) SyncScraperMatchesJob.perform_later( org.id, league: league, - our_team: our_team, + our_team: org.competitive_team_name, limit: sync_limit ) end - - record_job_heartbeat - - Rails.logger.info('[HistoricalBackfillJob] Done.') end end end diff --git a/app/modules/competitive/serializers/pro_match_serializer.rb b/app/modules/competitive/serializers/pro_match_serializer.rb index 3846d0b..ebf7e3b 100644 --- a/app/modules/competitive/serializers/pro_match_serializer.rb +++ b/app/modules/competitive/serializers/pro_match_serializer.rb @@ -43,6 +43,21 @@ class ProMatchSerializer < Blueprinter::Base match.tournament_display end + field :our_team_logo do |match| + match.organization&.logo_url + end + + field :opponent_team_logo do |match| + # Prefer the linked OpponentTeam record if populated + explicit = match.opponent_team&.logo_url + return explicit if explicit.present? + + # Fall back to image stored in game_stats during ES import + stats = match.game_stats || {} + our_is_team1 = stats['team1_name'].to_s.strip.downcase == match.our_team_name.to_s.strip.downcase + our_is_team1 ? stats['team2_image'] : stats['team1_image'] + end + field :game_label do |match| match.game_label end diff --git a/app/modules/competitive/services/leaguepedia_recovery_service.rb b/app/modules/competitive/services/leaguepedia_recovery_service.rb index e1de357..f2d112c 100644 --- a/app/modules/competitive/services/leaguepedia_recovery_service.rb +++ b/app/modules/competitive/services/leaguepedia_recovery_service.rb @@ -20,6 +20,8 @@ # # => { recovered: 1, already_present: 12, errors: 0, skipped_no_players: 0 } # class LeaguepediaRecoveryService + include Competitive::Concerns::MatchFingerprint + CARGO_BASE_URL = 'https://lol.fandom.com/api.php' CACHE_TTL = 30.minutes MAX_RETRIES = 3 @@ -114,6 +116,18 @@ def process_game(game, our_team, stats) game_id = game['GameId'] game_in_match = game['GameInMatch'].to_i ext_id = "#{game_id}_#{game_in_match}" + parsed_date = parse_leaguepedia_date(game['DateTime UTC']) + + opp_name = if teams_match?(game['Team1'].to_s, our_team) + game['Team2'].to_s + else + game['Team1'].to_s + end + + if duplicate_by_fingerprint?(@organization, parsed_date, game_in_match, opp_name) + stats[:already_present] += 1 + return + end if @organization.competitive_matches.exists?(external_match_id: ext_id) stats[:already_present] += 1 @@ -337,4 +351,12 @@ def fetch_with_ua(uri) http.request(req) end end + + def parse_leaguepedia_date(raw) + return nil if raw.blank? + + Time.zone.parse(raw) + rescue ArgumentError, TypeError + nil + end end diff --git a/app/modules/competitive/services/pandascore_service.rb b/app/modules/competitive/services/pandascore_service.rb index 20f221c..1797a55 100644 --- a/app/modules/competitive/services/pandascore_service.rb +++ b/app/modules/competitive/services/pandascore_service.rb @@ -5,7 +5,6 @@ class PandascoreService include Singleton BASE_URL = ENV.fetch('PANDASCORE_BASE_URL', 'https://api.pandascore.co') - API_KEY = ENV['PANDASCORE_API_KEY'] CACHE_TTL = ENV.fetch('PANDASCORE_CACHE_TTL', 3600).to_i class PandascoreError < StandardError; end @@ -16,33 +15,38 @@ class NotFoundError < PandascoreError; end # @param league [String] Filter by league (e.g., 'cblol', 'lcs', 'lck') # @param per_page [Integer] Number of results per page (default: 10) # @return [Array] Array of match data - def fetch_upcoming_matches(league: nil, per_page: 10) + def fetch_upcoming_matches(league: nil, per_page: 20, page: 1, search: nil) params = { 'filter[videogame]': 'lol', sort: 'begin_at', - per_page: per_page + per_page: per_page, + page: page } params['filter[league_id]'] = league if league.present? + params['search[name]'] = search if search.present? - cached_get('matches/upcoming', params) + paginated_get('matches/upcoming', params) end # Fetch past LoL matches # @param league [String] Filter by league # @param per_page [Integer] Number of results per page (default: 20) - # @return [Array] Array of match data - def fetch_past_matches(league: nil, per_page: 20) + # @param page [Integer] Page number (default: 1) + # @return [Hash] { data: Array, total: Integer, page: Integer, per_page: Integer } + def fetch_past_matches(league: nil, per_page: 20, page: 1, search: nil) params = { 'filter[videogame]': 'lol', 'filter[finished]': true, sort: '-begin_at', - per_page: per_page + per_page: per_page, + page: page } params['filter[league_id]'] = league if league.present? + params['search[name]'] = search if search.present? - cached_get('matches/past', params) + paginated_get('matches/past', params) end # Fetch detailed information about a specific match @@ -94,6 +98,32 @@ def fetch_champions_stats(patch: nil) cached_get('lol/champions', params) end + # Fetch a LoL team by PandaScore team ID + # @param team_id [Integer, String] PandaScore team ID + # @return [Hash] Team data including players + def fetch_team(team_id) + raise ArgumentError, 'Team ID cannot be blank' if team_id.blank? + + cached_get("teams/#{team_id}", {}, ttl: 86_400) + end + + # Fetch recent past matches for a LoL team + # @param team_id [Integer, String] PandaScore team ID + # @param limit [Integer] Number of matches to return (default: 5) + # @return [Array] Array of match hashes + def fetch_team_recent_matches(team_id, limit: 5) + raise ArgumentError, 'Team ID cannot be blank' if team_id.blank? + + params = { + 'filter[videogame]': 'lol', + 'filter[opponent_id]': team_id, + sort: '-begin_at', + per_page: limit + } + + cached_get('lol/matches/past', params, ttl: 1_800) + end + # Clear cache for PandaScore data # @param pattern [String] Cache key pattern to clear (default: all) def clear_cache(pattern: 'pandascore:*') @@ -103,15 +133,19 @@ def clear_cache(pattern: 'pandascore:*') private + def api_key + ENV['PANDASCORE_API_KEY'] + end + # Make HTTP request to PandaScore API # @param endpoint [String] API endpoint (without base URL) # @param params [Hash] Query parameters # @return [Hash, Array] Parsed JSON response def make_request(endpoint, params = {}) - raise PandascoreError, 'PANDASCORE_API_KEY not configured' if API_KEY.blank? + raise PandascoreError, 'PANDASCORE_API_KEY not configured' if api_key.blank? url = "#{BASE_URL}/#{endpoint}" - params[:token] = API_KEY + params[:token] = api_key Rails.logger.info "[PandaScore] GET #{endpoint} - Params: #{params.inspect}" @@ -158,11 +192,6 @@ def cache_key(endpoint, params) "pandascore:#{normalized_endpoint}:#{param_hash}" end - # Cached GET request with TTL - # @param endpoint [String] API endpoint - # @param params [Hash] Query parameters - # @param ttl [Integer] Cache time-to-live in seconds - # @return [Hash, Array] API response data def cached_get(endpoint, params = {}, ttl: CACHE_TTL) key = cache_key(endpoint, params) @@ -171,4 +200,49 @@ def cached_get(endpoint, params = {}, ttl: CACHE_TTL) make_request(endpoint, params) end end + + def paginated_get(endpoint, params = {}, ttl: CACHE_TTL) + key = cache_key(endpoint, params) + + Rails.cache.fetch(key, expires_in: ttl) do + Rails.logger.info "[PandaScore] Cache miss (paginated): #{key}" + make_paginated_request(endpoint, params) + end + end + + def make_paginated_request(endpoint, params = {}) + raise PandascoreError, 'PANDASCORE_API_KEY not configured' if api_key.blank? + + url = "#{BASE_URL}/#{endpoint}" + params[:token] = api_key + + Rails.logger.info "[PandaScore] GET #{endpoint} (paginated) - Params: #{params.inspect}" + + response = Faraday.get(url, params) do |req| + req.options.timeout = 10 + req.options.open_timeout = 5 + end + + case response.status + when 200 + total = response.headers['x-total'].to_i + { + data: JSON.parse(response.body), + total: total, + page: params[:page] || 1, + per_page: params[:per_page] || 20 + } + when 429 + raise RateLimitError, 'Rate limit exceeded. Try again later.' + when 401, 403 + raise PandascoreError, 'API key invalid or unauthorized' + else + Rails.logger.error "[PandaScore] Error #{response.status}: #{response.body}" + raise PandascoreError, "API error: #{response.status}" + end + rescue Faraday::TimeoutError + raise PandascoreError, 'Request timed out' + rescue Faraday::Error => e + raise PandascoreError, "Failed to connect to PandaScore API: #{e.message}" + end end diff --git a/app/modules/competitive/services/scraper_importer_service.rb b/app/modules/competitive/services/scraper_importer_service.rb index 1959bd9..704efc2 100644 --- a/app/modules/competitive/services/scraper_importer_service.rb +++ b/app/modules/competitive/services/scraper_importer_service.rb @@ -12,6 +12,8 @@ # # => { imported: 5, skipped_duplicate: 3, skipped_unenriched: 2, errors: 0 } # class ScraperImporterService + include Competitive::Concerns::MatchFingerprint + # Leaguepedia role values mapped to our internal lowercase convention ROLE_MAP = { 'Top' => 'top', @@ -83,7 +85,17 @@ def import_one(match, our_team, stats) end end - ext_id = build_external_match_id(match) + ext_id = build_external_match_id(match) + parsed_date = parse_date(match['start_time']) + game_number = match['game_number'] + team1_name = match.dig('team1', 'name').to_s + team2_name = match.dig('team2', 'name').to_s + _, opp_resolved = resolve_teams(team1_name, team2_name, match['win_team'].to_s, our_team) + + if duplicate_by_fingerprint?(@organization, parsed_date, game_number, opp_resolved) + stats[:skipped_duplicate] += 1 + return + end if @organization.competitive_matches.exists?(external_match_id: ext_id) stats[:skipped_duplicate] += 1 @@ -107,6 +119,7 @@ def build_attributes(match, ext_id, our_team) league = match['league'].to_s our_resolved, opp_resolved = resolve_teams(team1_name, team2_name, win_team, our_team) + date = parse_date(match['start_time']) { organization: @organization, @@ -114,7 +127,7 @@ def build_attributes(match, ext_id, our_team) tournament_stage: match['stage'], tournament_region: LEAGUE_REGION[league], external_match_id: ext_id, - match_date: parse_date(match['start_time']), + match_date: date, game_number: match['game_number'], patch_version: match['patch'], vod_url: build_vod_url(match['vod_youtube_id']), @@ -125,7 +138,8 @@ def build_attributes(match, ext_id, our_team) side: derive_side(our_resolved, team1_name), our_picks: build_picks(match['participants'], our_resolved), opponent_picks: build_picks(match['participants'], opp_resolved), - game_stats: build_game_stats(match, team1_name, team2_name) + game_stats: build_game_stats(match, team1_name, team2_name), + game_fingerprint: generate_fingerprint(@organization.id, date, match['game_number'], opp_resolved) } end @@ -228,6 +242,8 @@ def build_game_stats(match, team1_name = nil, team2_name = nil) 'win_team' => match['win_team'], 'team1_name' => team1_name.presence || match.dig('team1', 'name'), 'team2_name' => team2_name.presence || match.dig('team2', 'name'), + 'team1_image' => match.dig('team1', 'image'), + 'team2_image' => match.dig('team2', 'image'), 'participants' => match['participants'] || [] }.compact end diff --git a/app/modules/core/controllers/team_members_controller.rb b/app/modules/core/controllers/team_members_controller.rb index d1c2053..41d90ad 100644 --- a/app/modules/core/controllers/team_members_controller.rb +++ b/app/modules/core/controllers/team_members_controller.rb @@ -2,26 +2,32 @@ module Core module Controllers - # TeamMembersController β€” lists users in the same organization. + # TeamMembersController β€” lists all messageable members in the same organization. # - # Used by the frontend to populate the team member list in the chat widget. - # Returns all users except the current user. - # Player tokens are rejected β€” this endpoint is for staff only. + # Returns staff users and players with player access enabled, used by + # the frontend to populate the DM recipient list in the chat widget. + # Player tokens are rejected β€” this endpoint requires a user token. # # GET /api/v1/team-members class TeamMembersController < Api::V1::BaseController before_action :require_user_auth! def index - members = current_organization - .users - .where.not(id: current_user.id) - .order(:full_name) - .select(:id, :full_name, :role, :last_login_at) - - render_success( - { members: members.map { |u| serialize_member(u) } } - ) + users = current_organization + .users + .where.not(id: current_user.id) + .order(:full_name) + .select(:id, :full_name, :role, :last_login_at, :avatar_url) + .map { |u| serialize_member(u) } + + players = current_organization + .players + .where(player_access_enabled: true) + .order(:professional_name, :real_name) + .select(:id, :professional_name, :real_name, :role, :last_login_at, :avatar_url) + .map { |p| serialize_player(p) } + + render_success({ members: users + players }) end private @@ -31,9 +37,26 @@ def serialize_member(user) id: user.id, full_name: user.full_name, role: user.role, - online: user.last_login_at.present? && user.last_login_at > 15.minutes.ago + online: active_recently?(user.last_login_at), + member_type: 'user', + avatar_url: user.avatar_url.presence + } + end + + def serialize_player(player) + { + id: player.id, + full_name: player.professional_name.presence || player.real_name || 'Player', + role: player.role || 'player', + online: active_recently?(player.last_login_at), + member_type: 'player', + avatar_url: player.avatar_url.presence } end + + def active_recently?(last_login_at) + last_login_at.present? && last_login_at > 15.minutes.ago + end end end end diff --git a/app/modules/core/controllers/waitlist_controller.rb b/app/modules/core/controllers/waitlist_controller.rb deleted file mode 100644 index e6ad10b..0000000 --- a/app/modules/core/controllers/waitlist_controller.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module Core - module Controllers - # Handles email sign-ups for the Fantasy feature early-access waitlist. - class WaitlistController < ApplicationController - # POST /api/v1/fantasy/waitlist - def create - email = params[:email]&.strip&.downcase - - if email.blank? - render json: { - error: { - code: 'VALIDATION_ERROR', - message: 'Email is required' - } - }, status: :unprocessable_entity - return - end - - # Check if email already exists - existing = FantasyWaitlist.find_by(email: email) - if existing - render json: { - message: 'You are already on the waitlist!', - data: { email: existing.email, subscribed_at: existing.subscribed_at } - }, status: :ok - return - end - - # Create new waitlist entry - waitlist = FantasyWaitlist.new(email: email) - - if waitlist.save - render json: { - message: 'Successfully joined the waitlist!', - data: { - email: waitlist.email, - subscribed_at: waitlist.subscribed_at - } - }, status: :created - else - render json: { - error: { - code: 'VALIDATION_ERROR', - message: waitlist.errors.full_messages.join(', ') - } - }, status: :unprocessable_entity - end - rescue StandardError => e - Rails.logger.error("Fantasy Waitlist Error: #{e.message}") - render json: { - error: { - code: 'SERVER_ERROR', - message: 'Failed to join waitlist. Please try again.' - } - }, status: :internal_server_error - end - - # GET /api/v1/fantasy/waitlist/stats (Public stats) - def stats - total = FantasyWaitlist.count - recent = FantasyWaitlist.where('created_at > ?', 7.days.ago).count - - render json: { - data: { - total: total, - last_7_days: recent - } - } - end - end - end -end diff --git a/app/modules/core/models/fantasy_waitlist.rb b/app/modules/core/models/fantasy_waitlist.rb deleted file mode 100644 index b322cb5..0000000 --- a/app/modules/core/models/fantasy_waitlist.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -# Email sign-up record for the Fantasy feature early-access waitlist. -class FantasyWaitlist < ApplicationRecord - # Associations - belongs_to :organization, optional: true - - # Validations - validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :email, uniqueness: { case_sensitive: false } - - # Callbacks - before_save :downcase_email - before_create :set_subscribed_at - - # Scopes - scope :notified, -> { where(notified: true) } - scope :not_notified, -> { where(notified: false) } - scope :recent, -> { order(created_at: :desc) } - - private - - def downcase_email - self.email = email.downcase.strip if email.present? - end - - def set_subscribed_at - self.subscribed_at ||= Time.current - end -end diff --git a/app/modules/core/serializers/organization_serializer.rb b/app/modules/core/serializers/organization_serializer.rb index 3dff18b..1f6f85d 100644 --- a/app/modules/core/serializers/organization_serializer.rb +++ b/app/modules/core/serializers/organization_serializer.rb @@ -5,8 +5,8 @@ class OrganizationSerializer < Blueprinter::Base identifier :id - fields :name, :slug, :region, :tier, :subscription_plan, :subscription_status, - :logo_url, :settings, :created_at, :updated_at, + fields :name, :slug, :team_tag, :region, :tier, :subscription_plan, :subscription_status, + :logo_url, :settings, :enabled_lines, :created_at, :updated_at, :trial_expires_at, :trial_started_at field :region_display do |org| diff --git a/app/modules/inhouses/controllers/inhouse_queues_controller.rb b/app/modules/inhouses/controllers/inhouse_queues_controller.rb index 4d2d6b9..3ec39c4 100644 --- a/app/modules/inhouses/controllers/inhouse_queues_controller.rb +++ b/app/modules/inhouses/controllers/inhouse_queues_controller.rb @@ -65,23 +65,25 @@ def join queue = active_queue return unless queue - error = validate_join(queue, params[:role].to_s.downcase, params[:player_id].to_s) - return render_error(**error) if error - - role = params[:role].to_s.downcase - player = current_organization.players.find(params[:player_id]) - entry = queue.inhouse_queue_entries.new( - player: player, - role: role, - tier_snapshot: player.solo_queue_tier.presence || 'IRON' - ) + queue.with_lock do + error = validate_join(queue, params[:role].to_s.downcase, params[:player_id].to_s) + return render_error(**error) if error + + role = params[:role].to_s.downcase + player = current_organization.players.find(params[:player_id]) + entry = queue.inhouse_queue_entries.new( + player: player, + role: role, + tier_snapshot: player.solo_queue_tier.presence || 'IRON' + ) - if entry.save - render_success({ queue: queue.reload.serialize(detailed: true) }, - message: "#{player.summoner_name} joined the queue as #{role}") - else - render_error(message: 'Failed to join queue', code: 'VALIDATION_ERROR', - status: :unprocessable_entity, details: entry.errors.as_json) + if entry.save + render_success({ queue: queue.reload.serialize(detailed: true) }, + message: "#{player.summoner_name} joined the queue as #{role}") + else + render_error(message: 'Failed to join queue', code: 'VALIDATION_ERROR', + status: :unprocessable_entity, details: entry.errors.as_json) + end end end @@ -124,6 +126,7 @@ def start_checkin deadline = Time.current + CHECK_IN_DURATION_SECONDS.seconds queue.update!(status: 'check_in', check_in_deadline: deadline) + InhouseCheckInDeadlineJob.set(wait_until: deadline).perform_later(queue.id) render_success({ queue: queue.reload.serialize(detailed: true) }, message: 'Check-in started') end @@ -164,67 +167,31 @@ def start_session return unless queue formation_mode = params[:formation_mode].to_s - unless %w[auto captain_draft].include?(formation_mode) - return render_error( - message: "formation_mode must be 'auto' or 'captain_draft'", - code: 'INVALID_FORMATION_MODE', - status: :unprocessable_entity - ) - end - - entries = queue.checked_in_entries.includes(:player).to_a - - if entries.size < 2 - return render_error( - message: 'Need at least 2 checked-in players to start a session', - code: 'NOT_ENOUGH_PLAYERS', - status: :unprocessable_entity - ) - end - - if current_organization.inhouses.active.exists? - return render_error( - message: 'There is already an active inhouse session', - code: 'ACTIVE_INHOUSE_EXISTS', - status: :unprocessable_entity + return render_invalid_formation_mode unless %w[auto captain_draft].include?(formation_mode) + + queue.with_lock do + entries = queue.checked_in_entries.includes(:player).to_a + return render_not_enough_players if entries.size < 2 + return render_active_inhouse_exists if current_organization.inhouses.active.exists? + + inhouse = create_inhouse_from_queue!(queue, entries, formation_mode) + + Events::EventPublisher.publish( + user_id: current_user.id, + org_id: current_organization.id, + type: 'inhouse.session_started', + payload: { + inhouse_id: inhouse.id, + queue_id: queue.id, + formation_mode: formation_mode, + player_count: entries.size + } ) - end - - inhouse = nil - - ActiveRecord::Base.transaction do - # Create inhouse - inhouse = current_organization.inhouses.create!( - status: 'waiting', - created_by: current_user, - formation_mode: formation_mode + render_success( + { inhouse: serialize_inhouse(inhouse.reload, detailed: true) }, + message: 'Inhouse session started from queue' ) - - # Join all checked-in players, preserving their queued role - entries.each do |entry| - inhouse.inhouse_participations.create!( - player: entry.player, - team: 'none', - tier_snapshot: entry.tier_snapshot, - role: entry.role, - is_captain: false - ) - end - - if formation_mode == 'auto' - apply_auto_balance(inhouse) - else - apply_captain_draft(inhouse, entries) - end - - # Close the queue - queue.update!(status: 'closed') end - - render_success( - { inhouse: serialize_inhouse(inhouse.reload, detailed: true) }, - message: 'Inhouse session started from queue' - ) rescue ActiveRecord::RecordInvalid => e render_error(message: e.message, code: 'VALIDATION_ERROR', status: :unprocessable_entity) end @@ -240,6 +207,12 @@ def close render_success({ queue: nil }, message: 'Queue closed') end + TIER_SCORES = { + 'CHALLENGER' => 9, 'GRANDMASTER' => 8, 'MASTER' => 7, + 'DIAMOND' => 6, 'EMERALD' => 5, 'PLATINUM' => 4, + 'GOLD' => 3, 'SILVER' => 2, 'BRONZE' => 1 + }.freeze + private # Returns an error hash if the join cannot proceed, nil if valid. @@ -271,6 +244,57 @@ def validate_join(queue, role, player_id) nil end + def render_invalid_formation_mode + render_error( + message: "formation_mode must be 'auto' or 'captain_draft'", + code: 'INVALID_FORMATION_MODE', + status: :unprocessable_entity + ) + end + + def render_not_enough_players + render_error( + message: 'Need at least 2 checked-in players to start a session', + code: 'NOT_ENOUGH_PLAYERS', + status: :unprocessable_entity + ) + end + + def render_active_inhouse_exists + render_error( + message: 'There is already an active inhouse session', + code: 'ACTIVE_INHOUSE_EXISTS', + status: :unprocessable_entity + ) + end + + def create_inhouse_from_queue!(queue, entries, formation_mode) + inhouse = nil + ActiveRecord::Base.transaction do + inhouse = current_organization.inhouses.create!( + status: 'waiting', + created_by: current_user, + formation_mode: formation_mode + ) + entries.each do |entry| + inhouse.inhouse_participations.create!( + player: entry.player, + team: 'none', + tier_snapshot: entry.tier_snapshot, + role: entry.role, + is_captain: false + ) + end + if formation_mode == 'auto' + apply_auto_balance(inhouse) + else + apply_captain_draft(inhouse, entries) + end + queue.update!(status: 'closed') + end + inhouse + end + def active_queue queue = current_organization.inhouse_queues.active.includes(inhouse_queue_entries: :player).first unless queue @@ -365,18 +389,7 @@ def tier_to_points(tier) end def tier_score(tier_snapshot) - case tier_snapshot.to_s.upcase - when 'CHALLENGER' then 9 - when 'GRANDMASTER' then 8 - when 'MASTER' then 7 - when 'DIAMOND' then 6 - when 'EMERALD' then 5 - when 'PLATINUM' then 4 - when 'GOLD' then 3 - when 'SILVER' then 2 - when 'BRONZE' then 1 - else 0 - end + TIER_SCORES.fetch(tier_snapshot.to_s.upcase, 0) end # Reuse serializer from InhousesController via delegation diff --git a/app/modules/inhouses/controllers/inhouses_controller.rb b/app/modules/inhouses/controllers/inhouses_controller.rb index 22c306f..6e89f3f 100644 --- a/app/modules/inhouses/controllers/inhouses_controller.rb +++ b/app/modules/inhouses/controllers/inhouses_controller.rb @@ -298,62 +298,20 @@ def balance_teams def start_draft authorize @inhouse - unless @inhouse.waiting? - return render_error( - message: 'Can only start draft from a waiting session', - code: 'INVALID_STATE', - status: :unprocessable_entity - ) - end + return render_waiting_state_required unless @inhouse.waiting? blue_id = params[:blue_captain_id].to_s red_id = params[:red_captain_id].to_s - if blue_id.blank? || red_id.blank? - return render_error( - message: 'blue_captain_id and red_captain_id are required', - code: 'MISSING_PARAMS', - status: :unprocessable_entity - ) - end - - if blue_id == red_id - return render_error( - message: 'Blue and red captains must be different players', - code: 'DUPLICATE_CAPTAIN', - status: :unprocessable_entity - ) - end + return render_captain_ids_required if blue_id.blank? || red_id.blank? + return render_duplicate_captain if blue_id == red_id blue_participation = @inhouse.inhouse_participations.find_by(player_id: blue_id) red_participation = @inhouse.inhouse_participations.find_by(player_id: red_id) - unless blue_participation && red_participation - return render_error( - message: 'Both captains must already be in the session', - code: 'CAPTAIN_NOT_IN_SESSION', - status: :unprocessable_entity - ) - end + return render_captains_not_in_session unless blue_participation && red_participation - ActiveRecord::Base.transaction do - # Mark captains and assign teams - blue_participation.update!(team: 'blue', is_captain: true) - red_participation.update!(team: 'red', is_captain: true) - - # All other players reset to 'none' (unassigned) so draft picks them - @inhouse.inhouse_participations - .where.not(player_id: [blue_id, red_id]) - .update_all(team: 'none', is_captain: false) - - @inhouse.update!( - status: 'draft', - formation_mode: 'captain_draft', - blue_captain_id: blue_id, - red_captain_id: red_id, - draft_pick_number: 0 - ) - end + apply_draft_setup(blue_id, red_id, blue_participation, red_participation) render_success( { inhouse: serialize_inhouse(@inhouse.reload, detailed: true) }, @@ -367,55 +325,16 @@ def start_draft def captain_pick authorize @inhouse - unless @inhouse.draft? - return render_error( - message: 'Captain picks can only be made during the draft phase', - code: 'INVALID_STATE', - status: :unprocessable_entity - ) - end - - if @inhouse.draft_complete? - return render_error( - message: 'All picks have already been made', - code: 'DRAFT_COMPLETE', - status: :unprocessable_entity - ) - end + return render_draft_phase_required unless @inhouse.draft? + return render_draft_already_complete if @inhouse.draft_complete? player_id = params[:player_id].to_s - if player_id.blank? - return render_error( - message: 'player_id is required', - code: 'MISSING_PARAMS', - status: :unprocessable_entity - ) - end + return render_missing_player_id if player_id.blank? participation = @inhouse.inhouse_participations.find_by(player_id: player_id) - unless participation - return render_error( - message: 'Player is not in this inhouse session', - code: 'PLAYER_NOT_IN_SESSION', - status: :not_found - ) - end - - if participation.is_captain? - return render_error( - message: 'Captains cannot be picked β€” they are already on their teams', - code: 'PLAYER_IS_CAPTAIN', - status: :unprocessable_entity - ) - end - - if participation.team != 'none' - return render_error( - message: 'Player has already been picked', - code: 'ALREADY_PICKED', - status: :unprocessable_entity - ) - end + return render_player_not_in_session unless participation + return render_captain_cannot_be_picked if participation.is_captain? + return render_player_already_picked if participation.team != 'none' picking_team = @inhouse.current_pick_team @@ -528,6 +447,12 @@ def close end end + TIER_SCORES = { + 'CHALLENGER' => 9, 'GRANDMASTER' => 8, 'MASTER' => 7, + 'DIAMOND' => 6, 'EMERALD' => 5, 'PLATINUM' => 4, + 'GOLD' => 3, 'SILVER' => 2, 'BRONZE' => 1 + }.freeze + private # Snake draft: sort by tier desc, alternate teams pair by pair. @@ -546,6 +471,72 @@ def apply_snake_draft(participations) end end + def render_waiting_state_required + render_error(message: 'Can only start draft from a waiting session', code: 'INVALID_STATE', + status: :unprocessable_entity) + end + + def render_captain_ids_required + render_error(message: 'blue_captain_id and red_captain_id are required', code: 'MISSING_PARAMS', + status: :unprocessable_entity) + end + + def render_duplicate_captain + render_error(message: 'Blue and red captains must be different players', code: 'DUPLICATE_CAPTAIN', + status: :unprocessable_entity) + end + + def render_captains_not_in_session + render_error(message: 'Both captains must already be in the session', code: 'CAPTAIN_NOT_IN_SESSION', + status: :unprocessable_entity) + end + + def apply_draft_setup(blue_id, red_id, blue_participation, red_participation) + ActiveRecord::Base.transaction do + blue_participation.update!(team: 'blue', is_captain: true) + red_participation.update!(team: 'red', is_captain: true) + @inhouse.inhouse_participations + .where.not(player_id: [blue_id, red_id]) + .update_all(team: 'none', is_captain: false) + @inhouse.update!( + status: 'draft', + formation_mode: 'captain_draft', + blue_captain_id: blue_id, + red_captain_id: red_id, + draft_pick_number: 0 + ) + end + end + + def render_draft_phase_required + render_error(message: 'Captain picks can only be made during the draft phase', code: 'INVALID_STATE', + status: :unprocessable_entity) + end + + def render_draft_already_complete + render_error(message: 'All picks have already been made', code: 'DRAFT_COMPLETE', + status: :unprocessable_entity) + end + + def render_missing_player_id + render_error(message: 'player_id is required', code: 'MISSING_PARAMS', status: :unprocessable_entity) + end + + def render_player_not_in_session + render_error(message: 'Player is not in this inhouse session', code: 'PLAYER_NOT_IN_SESSION', + status: :not_found) + end + + def render_captain_cannot_be_picked + render_error(message: 'Captains cannot be picked β€” they are already on their teams', + code: 'PLAYER_IS_CAPTAIN', status: :unprocessable_entity) + end + + def render_player_already_picked + render_error(message: 'Player has already been picked', code: 'ALREADY_PICKED', + status: :unprocessable_entity) + end + def set_inhouse @inhouse = current_organization.inhouses.find(params[:id]) rescue ActiveRecord::RecordNotFound @@ -555,18 +546,7 @@ def set_inhouse # Returns a tier score (0–9) for snake draft balancing. # Uses LoL solo queue tiers. Higher = stronger player. def tier_score(tier_snapshot) - case tier_snapshot.to_s.upcase - when 'CHALLENGER' then 9 - when 'GRANDMASTER' then 8 - when 'MASTER' then 7 - when 'DIAMOND' then 6 - when 'EMERALD' then 5 - when 'PLATINUM' then 4 - when 'GOLD' then 3 - when 'SILVER' then 2 - when 'BRONZE' then 1 - else 0 # IRON or unknown - end + TIER_SCORES.fetch(tier_snapshot.to_s.upcase, 0) end # Serializes an inhouse to a hash. diff --git a/app/modules/inhouses/controllers/internal/inhouse_queues_controller.rb b/app/modules/inhouses/controllers/internal/inhouse_queues_controller.rb new file mode 100644 index 0000000..0446f39 --- /dev/null +++ b/app/modules/inhouses/controllers/internal/inhouse_queues_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Inhouses + module Controllers + module Internal + # Internal endpoint for prostaff-events startup reconciliation. + # Returns all InhouseQueues in check_in state with a future deadline. + # Authenticated via INTERNAL_JWT_SECRET β€” not user JWT. + class InhouseQueuesController < ApplicationController + before_action :verify_internal_token + + def active + queues = InhouseQueue.check_in + .where('check_in_deadline > ?', Time.current) + .includes(inhouse_queue_entries: :player) + + render json: { + queues: queues.map { |q| serialize_queue(q) } + } + end + + private + + def verify_internal_token + auth_header = request.headers['Authorization'].to_s + token = auth_header.sub('Bearer ', '') + + render json: { error: 'unauthorized' }, status: :unauthorized and return unless token.present? + + secret = ENV.fetch('INTERNAL_JWT_SECRET', nil) + render json: { error: 'unauthorized' }, status: :unauthorized and return unless secret.present? + + decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' }) + payload = HashWithIndifferentAccess.new(decoded[0]) + + render json: { error: 'forbidden' }, status: :forbidden and return unless payload[:type] == 'internal' + rescue JWT::DecodeError, JWT::ExpiredSignature + render json: { error: 'unauthorized' }, status: :unauthorized + end + + def serialize_queue(queue) + { + id: queue.id, + organization_id: queue.organization_id, + status: queue.status, + check_in_deadline: queue.check_in_deadline&.iso8601, + entries: queue.inhouse_queue_entries.map do |e| + { + player_id: e.player_id, + role: e.role, + checked_in: e.checked_in + } + end + } + end + end + end + end +end diff --git a/app/modules/matches/controllers/matches_controller.rb b/app/modules/matches/controllers/matches_controller.rb index e46e17b..2cd19bc 100644 --- a/app/modules/matches/controllers/matches_controller.rb +++ b/app/modules/matches/controllers/matches_controller.rb @@ -7,35 +7,45 @@ module Controllers class MatchesController < Api::V1::BaseController include Analytics::Concerns::AnalyticsCalculations include ParameterValidation + include Cacheable before_action :set_match, only: %i[show update destroy stats] + after_action -> { invalidate_cache('matches') }, only: %i[update destroy] + after_action -> { invalidate_cache("matches/#{@match&.id}") }, only: %i[update destroy] + def index matches = organization_scoped(Match).includes(:player_match_stats, :players) - matches = apply_match_filters(matches) - matches = apply_match_sorting(matches) - - result = paginate(matches) + matches = MatchFilterQuery.new(matches, params).call + + data = cache_response('matches', expires_in: 5.minutes) do + result = paginate(matches) + { + matches: MatchSerializer.render_as_hash(result[:data]), + pagination: result[:pagination], + summary: calculate_matches_summary(matches) + } + end - render_success({ - matches: MatchSerializer.render_as_hash(result[:data]), - pagination: result[:pagination], - summary: calculate_matches_summary(matches) - }) + render_success(data) end def show - match_data = MatchSerializer.render_as_hash(@match) - player_stats = PlayerMatchStatSerializer.render_as_hash( - @match.player_match_stats.includes(:player) - ) + data = cache_response("matches/#{@match.id}", expires_in: 5.minutes) do + match_data = MatchSerializer.render_as_hash(@match) + player_stats = PlayerMatchStatSerializer.render_as_hash( + @match.player_match_stats.includes(:player) + ) + + { + match: match_data, + player_stats: player_stats, + team_composition: @match.team_composition, + mvp: @match.mvp_player ? PlayerSerializer.render_as_hash(@match.mvp_player) : nil + } + end - render_success({ - match: match_data, - player_stats: player_stats, - team_composition: @match.team_composition, - mvp: @match.mvp_player ? PlayerSerializer.render_as_hash(@match.mvp_player) : nil - }) + render_success(data) end def create @@ -130,8 +140,9 @@ def stats end def import - player_id = validate_required_param!(:player_id) - count = integer_param(:count, default: 20, min: 1, max: 100) + player_id = validate_required_param!(:player_id) + count = integer_param(:count, default: 20, min: 1, max: 100) + force_update = params[:force_update].in?([true, 'true', '1']) player = organization_scoped(Player).find(player_id) @@ -143,65 +154,24 @@ def import ) end - job_id = ImportPlayerMatchesJob.perform_later( - player.id, - current_organization.id, - count - ).job_id - - render_success({ - job_id: job_id, - player_id: player.id.to_s, - count: count - }, message: 'Match import queued successfully') + result = ImportMatchesService.new( + player: player, + organization: current_organization, + count: count, + force_update: force_update + ).call + + render_success(result, message: 'Matches import started successfully') + rescue RiotApiService::RiotApiError => e + render_error( + message: "Riot API error: #{e.message}", + code: 'RIOT_API_ERROR', + status: :service_unavailable + ) end private - def apply_match_filters(matches) - matches = apply_basic_match_filters(matches) - matches = apply_date_filters_to_matches(matches) - matches = apply_opponent_filter(matches) - apply_tournament_filter(matches) - end - - def apply_basic_match_filters(matches) - matches = matches.by_type(params[:match_type]) if params[:match_type].present? - matches = matches.victories if params[:result] == 'victory' - matches = matches.defeats if params[:result] == 'defeat' - matches - end - - def apply_date_filters_to_matches(matches) - if params[:start_date].present? && params[:end_date].present? - matches.in_date_range(params[:start_date], params[:end_date]) - elsif params[:days].present? - matches.recent(params[:days].to_i) - else - matches - end - end - - def apply_opponent_filter(matches) - params[:opponent].present? ? matches.with_opponent(params[:opponent]) : matches - end - - def apply_tournament_filter(matches) - return matches unless params[:tournament].present? - - matches.where('tournament_name ILIKE ?', "%#{params[:tournament]}%") - end - - def apply_match_sorting(matches) - allowed_sort_fields = %w[game_start game_duration match_type victory created_at] - allowed_sort_orders = %w[asc desc] - - sort_by = allowed_sort_fields.include?(params[:sort_by]) ? params[:sort_by] : 'game_start' - sort_order = allowed_sort_orders.include?(params[:sort_order]) ? params[:sort_order] : 'desc' - - matches.order(sort_by => sort_order) - end - def set_match @match = organization_scoped(Match).find(params[:id]) end diff --git a/app/modules/matches/jobs/sync_match_job.rb b/app/modules/matches/jobs/sync_match_job.rb index f6dfd82..5404768 100644 --- a/app/modules/matches/jobs/sync_match_job.rb +++ b/app/modules/matches/jobs/sync_match_job.rb @@ -115,15 +115,41 @@ def create_player_match_stats(match, participants, organization) puts "SyncMatchJob: Our players in match: #{our_participants.size}" team_totals = calculate_team_totals(participants, our_participants, is_competitive) + opponent_map = build_opponent_map(participants) participants.each do |participant_data| player = organization.players.find_by(riot_puuid: participant_data[:puuid]) next unless player - create_stat_for_participant(match, player, participant_data, team_totals) + create_stat_for_participant(match, player, participant_data, team_totals, opponent_map) end end + # Builds a hash mapping each participant's puuid to the champion name of their + # lane opponent (same teamPosition on the opposing team). + # Returns an empty hash when the match has an unexpected team structure. + def build_opponent_map(participants) + by_team = participants.group_by { |p| p[:team_id] } + teams = by_team.keys + return {} unless teams.size == 2 + + result = {} + teams.each do |team_id| + other_team_id = teams.find { |t| t != team_id } + other_team = by_team[other_team_id] || [] + + by_team[team_id].each do |participant| + role = participant[:role] + next if role.blank? + + opponent = other_team.find { |o| o[:role] == role } + result[participant[:puuid]] = opponent&.dig(:champion_name) + end + end + + result + end + def calculate_team_totals(participants, our_participants, is_competitive) source = is_competitive ? our_participants : participants source.group_by { |p| p[:team_id] }.transform_values do |team_participants| @@ -137,17 +163,31 @@ def calculate_team_totals(participants, our_participants, is_competitive) end end - def create_stat_for_participant(match, player, participant_data, team_totals) - team_stats = team_totals[participant_data[:team_id]] + def create_stat_for_participant(match, player, participant_data, team_totals, opponent_map = {}) + PlayerMatchStat.create!( + build_stat_attributes(match, player, participant_data, team_totals, opponent_map) + ) + end + + def build_stat_attributes(match, player, participant_data, team_totals, opponent_map) + team_stats = team_totals[participant_data[:team_id]] damage_share = calc_share(participant_data[:total_damage_dealt], team_stats&.dig(:total_damage)) - gold_share = calc_share(participant_data[:gold_earned], team_stats&.dig(:total_gold)) - cs_total = (participant_data[:minions_killed] || 0) + (participant_data[:neutral_minions_killed] || 0) + gold_share = calc_share(participant_data[:gold_earned], team_stats&.dig(:total_gold)) + cs_total = (participant_data[:minions_killed] || 0) + (participant_data[:neutral_minions_killed] || 0) - PlayerMatchStat.create!( + base_stat_fields(match, player, participant_data, opponent_map, cs_total) + .merge(combat_stat_fields(participant_data)) + .merge(vision_and_objective_fields(participant_data)) + .merge(share_and_spell_fields(participant_data, damage_share, gold_share)) + end + + def base_stat_fields(match, player, participant_data, opponent_map, cs_total) + { match: match, player: player, role: normalize_role(participant_data[:role]), champion: participant_data[:champion_name], + opponent_champion: opponent_map[participant_data[:puuid]], kills: participant_data[:kills], deaths: participant_data[:deaths], assists: participant_data[:assists], @@ -156,37 +196,54 @@ def create_stat_for_participant(match, player, participant_data, team_totals) damage_taken: participant_data[:total_damage_taken], cs: cs_total, neutral_minions_killed: participant_data[:neutral_minions_killed], - vision_score: participant_data[:vision_score], - wards_placed: participant_data[:wards_placed], - wards_destroyed: participant_data[:wards_killed], - first_blood: participant_data[:first_blood_kill], + performance_score: calculate_performance_score(participant_data), + items: participant_data[:items], + runes: participant_data[:runes] + } + end + + def combat_stat_fields(participant_data) + { double_kills: participant_data[:double_kills], triple_kills: participant_data[:triple_kills], quadra_kills: participant_data[:quadra_kills], penta_kills: participant_data[:penta_kills], - performance_score: calculate_performance_score(participant_data), - items: participant_data[:items], - runes: participant_data[:runes], - summoner_spell_1: participant_data[:summoner_spell_1], - summoner_spell_2: participant_data[:summoner_spell_2], - damage_share: damage_share, - gold_share: gold_share, + first_blood: participant_data[:first_blood_kill], + first_tower: participant_data[:first_tower_kill], objectives_stolen: participant_data[:objectives_stolen], crowd_control_score: participant_data[:crowd_control_score], total_time_dead: participant_data[:total_time_dead], damage_to_turrets: participant_data[:damage_to_turrets], damage_shielded_teammates: participant_data[:damage_shielded_teammates], - healing_to_teammates: participant_data[:healing_to_teammates], + healing_to_teammates: participant_data[:healing_to_teammates] + } + end + + def vision_and_objective_fields(participant_data) + { + vision_score: participant_data[:vision_score], + wards_placed: participant_data[:wards_placed], + wards_destroyed: participant_data[:wards_killed], + control_wards_purchased: participant_data[:control_wards_purchased], + cs_at_10: participant_data[:cs_at_10], + turret_plates_destroyed: participant_data[:turret_plates_destroyed], + pings: participant_data[:pings] || {} + } + end + + def share_and_spell_fields(participant_data, damage_share, gold_share) + { + summoner_spell_1: participant_data[:summoner_spell_1], + summoner_spell_2: participant_data[:summoner_spell_2], + damage_share: damage_share, + gold_share: gold_share, spell_q_casts: participant_data[:spell_q_casts], spell_w_casts: participant_data[:spell_w_casts], spell_e_casts: participant_data[:spell_e_casts], spell_r_casts: participant_data[:spell_r_casts], summoner_spell_1_casts: participant_data[:summoner_spell_1_casts], - summoner_spell_2_casts: participant_data[:summoner_spell_2_casts], - cs_at_10: participant_data[:cs_at_10], - turret_plates_destroyed: participant_data[:turret_plates_destroyed], - pings: participant_data[:pings] || {} - ) + summoner_spell_2_casts: participant_data[:summoner_spell_2_casts] + } end def calc_share(value, total) diff --git a/app/modules/matches/models/match.rb b/app/modules/matches/models/match.rb index 8aa8f94..938556e 100644 --- a/app/modules/matches/models/match.rb +++ b/app/modules/matches/models/match.rb @@ -49,7 +49,7 @@ class Match < ApplicationRecord validates :game_duration, numericality: { greater_than: 0 }, allow_blank: true # Callbacks - after_update :log_audit_trail, if: :saved_changes? + after_update_commit :enqueue_audit_log, if: :saved_changes? after_create :clear_organization_cache after_destroy :clear_organization_cache @@ -142,10 +142,9 @@ def has_vod? private - def log_audit_trail - AuditLog.create!( - organization: organization, - action: 'update', + def enqueue_audit_log + AuditLogJob.perform_later( + organization_id: organization_id, entity_type: 'Match', entity_id: id, old_values: saved_changes.transform_values(&:first), @@ -154,6 +153,10 @@ def log_audit_trail end def clear_organization_cache - organization.clear_matches_cache if organization.present? + return unless organization.present? + + organization.clear_matches_cache + Rails.cache.delete("v1:#{organization_id}:matches") + Rails.cache.delete("v1:#{organization_id}:matches/#{id}") end end diff --git a/app/modules/matches/services/import_matches_service.rb b/app/modules/matches/services/import_matches_service.rb new file mode 100644 index 0000000..7f40a91 --- /dev/null +++ b/app/modules/matches/services/import_matches_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Fetches match IDs from Riot API for a player and enqueues SyncMatchJob +# for each new match. Returns counts synchronously so the caller can +# respond with meaningful feedback without waiting for individual syncs. +class ImportMatchesService + def initialize(player:, organization:, count: 20, force_update: false) + @player = player + @organization = organization + @count = count + @force_update = force_update + end + + # @return [Hash] counts: total_matches_found, imported, already_imported, updated + def call + match_ids = fetch_match_ids + tally = { imported: 0, already_imported: 0, updated: 0 } + + match_ids.each { |id| process_match(id, tally) } + + tally.merge(total_matches_found: match_ids.size) + end + + private + + def fetch_match_ids + RiotApiService.new.get_match_history( + puuid: @player.riot_puuid, + region: region, + count: @count + ) + end + + def process_match(match_id, tally) + if Match.exists?(riot_match_id: match_id) + handle_existing_match(match_id, tally) + else + Matches::SyncMatchJob.perform_later(match_id, @organization.id, region) + tally[:imported] += 1 + end + end + + def handle_existing_match(match_id, tally) + if @force_update + Matches::SyncMatchJob.perform_later(match_id, @organization.id, region, force_update: true) + tally[:updated] += 1 + else + tally[:already_imported] += 1 + end + end + + def region + @player.region || 'BR' + end +end diff --git a/app/modules/matchmaking/controllers/scrim_requests_controller.rb b/app/modules/matchmaking/controllers/scrim_requests_controller.rb index 546950d..3565d10 100644 --- a/app/modules/matchmaking/controllers/scrim_requests_controller.rb +++ b/app/modules/matchmaking/controllers/scrim_requests_controller.rb @@ -101,6 +101,12 @@ def accept if @scrim_request.accept!(accepting_org: current_organization) notify_discord(:accepted, @scrim_request) + Events::EventPublisher.publish( + user_id: current_user.id, + org_id: current_organization.id, + type: 'scrim_request.accepted', + payload: { scrim_request_id: @scrim_request.id, scrim_id: @scrim_request.scrim_id } + ) render_success({ scrim_request: ScrimRequestSerializer.render_as_hash(@scrim_request.reload) }, message: 'Scrim request accepted! Scrim added to your schedule.') else @@ -123,6 +129,12 @@ def decline if @scrim_request.decline!(declining_org: current_organization) notify_discord(:declined, @scrim_request) + Events::EventPublisher.publish( + user_id: current_user.id, + org_id: current_organization.id, + type: 'scrim_request.declined', + payload: { scrim_request_id: @scrim_request.id } + ) render_success({ scrim_request: ScrimRequestSerializer.render_as_hash(@scrim_request.reload) }, message: 'Scrim request declined') else diff --git a/app/modules/messaging/channels/direct_message_channel.rb b/app/modules/messaging/channels/direct_message_channel.rb index a538a56..1b64c21 100644 --- a/app/modules/messaging/channels/direct_message_channel.rb +++ b/app/modules/messaging/channels/direct_message_channel.rb @@ -1,36 +1,43 @@ # frozen_string_literal: true -# DirectMessageChannel β€” real-time private messaging between two team members. +# DirectMessageChannel β€” real-time private messaging between staff and players. # -# The frontend subscribes passing the recipient_id: +# The frontend subscribes passing recipient_id and optionally recipient_type: # consumer.subscriptions.create( -# { channel: 'DirectMessageChannel', recipient_id: '' }, +# { channel: 'DirectMessageChannel', recipient_id: '', recipient_type: 'Player' }, # { received(data) { ... } } # ) # # Security guarantees: -# 1. Sender identity comes from the verified JWT (current_user) β€” cannot be spoofed. +# 1. Sender identity comes from the verified JWT (current_user or current_player) β€” cannot be spoofed. # 2. Recipient must belong to the same organization as the sender. -# 3. Stream key is derived from sorted user IDs + org_id β€” impossible to subscribe -# to a conversation you're not a party to. +# 3. Stream key is derived from sorted participant IDs + org_id β€” impossible to subscribe +# to a conversation you are not a party to. class DirectMessageChannel < ApplicationCable::Channel MAX_CONTENT_LENGTH = 2000 def subscribed + # ActionCable channels do not go through authenticate_request!, so + # Current.organization_id must be set manually for OrganizationScoped models. + Current.organization_id = current_org_id + recipient = find_and_validate_recipient return unless recipient - @recipient_id = recipient.id - stream_from stream_key_for(recipient) - logger.info "[DM] #{current_user.id} subscribed to DM with #{recipient.id}" + @recipient_id = recipient[:record].id + @recipient_type = recipient[:type] + stream_from Message.dm_stream_key(current_sender_id, @recipient_id, current_org_id) + logger.info "[DM] #{current_sender_id} subscribed to DM with #{@recipient_id}" end def unsubscribed stop_all_streams end - # Receives { "content" => "...", "recipient_id" => "..." } from the frontend. + # Receives { "content" => "...", "recipient_id" => "...", "recipient_type" => "..." } from client. def speak(data) # rubocop:disable Metrics/MethodLength + Current.organization_id = current_org_id + content = data['content'].to_s.strip recipient_id = data['recipient_id'].to_s @@ -47,12 +54,7 @@ def speak(data) # rubocop:disable Metrics/MethodLength recipient = find_recipient_by_id(recipient_id) return unless recipient - Message.create!( - content: content, - user: current_user, - recipient: recipient, - organization_id: current_org_id - ) + create_message(content: content, recipient: recipient) rescue ActiveRecord::RecordInvalid => e logger.error "[DM] Failed to create message: #{e.message}" transmit({ error: 'Failed to send message' }) @@ -73,24 +75,54 @@ def find_and_validate_recipient end def find_recipient_by_id(recipient_id) - recipient = User.find_by(id: recipient_id, organization_id: current_org_id) + recipient_type = resolve_recipient_type(params[:recipient_type]) + record = locate_recipient(recipient_id, recipient_type) - unless recipient - logger.warn "[DM] Recipient #{recipient_id} not found in org #{current_org_id}" + unless record + logger.warn "[DM] Recipient #{recipient_id} (#{recipient_type}) not found in org #{current_org_id}" reject return nil end - if recipient.id == current_user.id + if record.id == current_sender_id logger.warn '[DM] Cannot DM yourself' reject return nil end - recipient + { record: record, type: recipient_type } + end + + def locate_recipient(recipient_id, recipient_type) + if recipient_type == 'Player' + Player.find_by(id: recipient_id, organization_id: current_org_id, player_access_enabled: true) + else + User.find_by(id: recipient_id, organization_id: current_org_id) + end + end + + def resolve_recipient_type(raw_type) + Message::PARTICIPANT_TYPES.include?(raw_type.to_s) ? raw_type.to_s : 'User' + end + + def create_message(content:, recipient:) + Message.create!( + user_id: current_sender_id, + sender_type: current_sender_type, + recipient_id: recipient[:record].id, + recipient_type: recipient[:type], + organization_id: current_org_id, + content: content + ) + end + + def current_sender_id + return current_player.id if current_player.present? + + current_user.id end - def stream_key_for(recipient) - Message.dm_stream_key(current_user.id, recipient.id, current_org_id) + def current_sender_type + current_player.present? ? 'Player' : 'User' end end diff --git a/app/modules/messaging/channels/team_channel.rb b/app/modules/messaging/channels/team_channel.rb index 60ea589..e5ad2f0 100644 --- a/app/modules/messaging/channels/team_channel.rb +++ b/app/modules/messaging/channels/team_channel.rb @@ -2,9 +2,9 @@ # TeamChannel β€” Real-time messaging channel for team communication. # -# Each user subscribes to the stream of their own organization. -# The stream key is derived from `current_org_id` (set in Connection), -# so a user cannot subscribe to another organization's stream even by +# Each member subscribes to the stream of their own organization. +# The stream key is derived from current_org_id (set in Connection), +# so a member cannot subscribe to another organization's stream even by # manually crafting a subscription request. # # Actions: @@ -16,24 +16,23 @@ # Broadcasting is done by the Message model's after_create callback, # not directly in this channel, to keep the channel thin and testable. class TeamChannel < ApplicationCable::Channel - # Maximum message length β€” enforced at channel level before hitting the DB MAX_CONTENT_LENGTH = 2000 def subscribed if current_org_id.blank? - logger.warn "[TeamChannel] Rejected subscription β€” no org_id for user #{current_user.id}" + logger.warn "[TeamChannel] Rejected subscription β€” no org_id for sender #{current_sender_id}" reject return end stream_name = "team_room_#{current_org_id}" stream_from stream_name - logger.info "[TeamChannel] user=#{current_user.id} subscribed to #{stream_name}" + logger.info "[TeamChannel] sender=#{current_sender_id} subscribed to #{stream_name}" end def unsubscribed stop_all_streams - logger.info "[TeamChannel] user=#{current_user.id} disconnected" + logger.info "[TeamChannel] sender=#{current_sender_id} disconnected" end # Receives a message sent by the frontend via cable. @@ -52,14 +51,26 @@ def speak(data) # rubocop:disable Metrics/MethodLength return end - # Persist the message β€” broadcasting is triggered by after_create callback Message.create!( - content: content, - user: current_user, - organization_id: current_org_id + user_id: current_sender_id, + sender_type: current_sender_type, + organization_id: current_org_id, + content: content ) rescue ActiveRecord::RecordInvalid => e logger.error "[TeamChannel] Failed to create message: #{e.message}" transmit({ error: 'Failed to send message' }) end + + private + + def current_sender_id + return current_player.id if current_player.present? + + current_user.id + end + + def current_sender_type + current_player.present? ? 'Player' : 'User' + end end diff --git a/app/modules/messaging/controllers/messages_controller.rb b/app/modules/messaging/controllers/messages_controller.rb index a4b7b1b..e5abcd5 100644 --- a/app/modules/messaging/controllers/messages_controller.rb +++ b/app/modules/messaging/controllers/messages_controller.rb @@ -6,29 +6,21 @@ module Controllers # # GET /api/v1/messages?recipient_id= β†’ conversation history (paginated) # DELETE /api/v1/messages/:id β†’ soft-delete own message + # + # Supports both staff (User token) and player (Player token) senders. + # Recipients can be Users or Players with player_access_enabled. class MessagesController < Api::V1::BaseController before_action :set_message, only: [:destroy] # GET /api/v1/messages?recipient_id= - # Returns the conversation history between current_user and recipient, + # Returns the conversation history between the current sender and recipient, # paginated newest-first (use `before` param as cursor for "load more"). def index recipient_id = params.require(:recipient_id) - recipient = find_org_member!(recipient_id) - return unless recipient - - messages = current_organization - .messages - .active - .conversation_between(current_user.id, recipient.id) - .includes(:user) - .recent_first - - if params[:before].present? - before_time = Time.parse(params[:before]) - messages = messages.where('created_at < ?', before_time) - end + recipient_info = find_org_member!(recipient_id) + return unless recipient_info + messages = fetch_conversation(recipient_info[:record].id) result = paginate(messages, per_page: 50) render_success({ @@ -71,36 +63,78 @@ def set_message render_not_found end - def find_org_member!(user_id) - member = current_organization.users.find_by(id: user_id) - unless member - render_error( - message: 'Recipient not found in your organization', - code: 'NOT_FOUND', - status: :not_found - ) - return nil - end - member + def find_org_member!(recipient_id) + user = current_organization.users.find_by(id: recipient_id) + return { record: user, type: 'User' } if user + + player = current_organization.players.find_by(id: recipient_id, player_access_enabled: true) + return { record: player, type: 'Player' } if player + + render_error(message: 'Recipient not found', code: 'NOT_FOUND', status: :not_found) + nil + end + + def fetch_conversation(recipient_id) + sender_id = current_sender_id + messages = current_organization + .messages + .active + .conversation_between(sender_id, recipient_id) + .recent_first + + return messages unless params[:before].present? + + before_time = Time.parse(params[:before]) + messages.where('created_at < ?', before_time) + end + + def current_sender_id + return current_player.id if player_authenticated? + + current_user.id end def can_delete?(message) + return message.user_id == current_player.id if player_authenticated? + message.user_id == current_user.id || current_user.admin_or_owner? end def serialize_messages(messages) - messages.map do |msg| - { - id: msg.id, - content: msg.content, - created_at: msg.created_at.iso8601, - recipient_id: msg.recipient_id, - user: { - id: msg.user.id, - full_name: msg.user.full_name, - role: msg.user.role - } - } + sender_cache = build_sender_cache(messages) + messages.map { |msg| serialize_message(msg, sender_cache) } + end + + def build_sender_cache(messages) + user_ids = messages.select { |m| m.sender_type == 'User' }.map(&:user_id).uniq + player_ids = messages.select { |m| m.sender_type == 'Player' }.map(&:user_id).uniq + + users = user_ids.any? ? User.where(id: user_ids).index_by(&:id) : {} + players = player_ids.any? ? Player.where(id: player_ids).index_by(&:id) : {} + + { 'User' => users, 'Player' => players } + end + + def serialize_message(msg, sender_cache) + sender = sender_cache.dig(msg.sender_type, msg.user_id) + { + id: msg.id, + content: msg.content, + created_at: msg.created_at.iso8601, + recipient_id: msg.recipient_id, + recipient_type: msg.recipient_type, + sender_type: msg.sender_type, + sender: serialize_sender(sender, msg.sender_type) + } + end + + def serialize_sender(sender, sender_type) + return {} unless sender + + if sender_type == 'Player' + { id: sender.id, full_name: sender.professional_name.presence || sender.real_name, role: sender.role } + else + { id: sender.id, full_name: sender.full_name, role: sender.role } end end end diff --git a/app/modules/messaging/models/message.rb b/app/modules/messaging/models/message.rb index 02b09da..a70f004 100644 --- a/app/modules/messaging/models/message.rb +++ b/app/modules/messaging/models/message.rb @@ -5,8 +5,10 @@ # Table name: messages # # id :uuid not null, primary key -# user_id :uuid not null (sender) -# recipient_id :uuid (nil = not used; present = DM) +# user_id :uuid not null (sender ID β€” User or Player) +# sender_type :string default("User"), not null +# recipient_id :uuid (nil = broadcast; present = DM) +# recipient_type :string default("User"), not null # organization_id :uuid not null # content :text not null # deleted :boolean default(false), not null @@ -15,13 +17,18 @@ # updated_at :datetime not null # class Message < ApplicationRecord + PARTICIPANT_TYPES = %w[User Player].freeze + # Associations - belongs_to :user # sender - belongs_to :recipient, class_name: 'User', optional: true # DM target + # user_id stores the sender ID regardless of whether sender is a User or Player. + # FK to users table was removed in migration RemoveMessagesUserForeignKeys. belongs_to :organization # Validations + validates :user_id, presence: true validates :content, presence: true, length: { minimum: 1, maximum: 2000 } + validates :sender_type, inclusion: { in: PARTICIPANT_TYPES } + validates :recipient_type, inclusion: { in: PARTICIPANT_TYPES }, allow_nil: true validate :recipient_belongs_to_same_org, if: -> { recipient_id.present? } # Scopes @@ -30,11 +37,11 @@ class Message < ApplicationRecord scope :chronological, -> { order(created_at: :asc) } scope :recent_first, -> { order(created_at: :desc) } - # Returns the full conversation between two users (both directions) - scope :conversation_between, lambda { |user_a_id, user_b_id| + # Returns the full conversation between two participants (both directions) + scope :conversation_between, lambda { |participant_a_id, participant_b_id| where( '(user_id = ? AND recipient_id = ?) OR (user_id = ? AND recipient_id = ?)', - user_a_id, user_b_id, user_b_id, user_a_id + participant_a_id, participant_b_id, participant_b_id, participant_a_id ) } @@ -42,9 +49,9 @@ class Message < ApplicationRecord after_create_commit :broadcast_to_participants # Returns a deterministic, symmetric stream key for a DM conversation. - # Sorting the two IDs ensures user Aβ†’B and Bβ†’A share the same stream. - def self.dm_stream_key(user_a_id, user_b_id, org_id) - pair = [user_a_id.to_s, user_b_id.to_s].sort.join('_') + # Sorting the two IDs ensures Aβ†’B and Bβ†’A share the same stream. + def self.dm_stream_key(participant_a_id, participant_b_id, org_id) + pair = [participant_a_id.to_s, participant_b_id.to_s].sort.join('_') "dm_#{pair}_org_#{org_id}" end @@ -54,16 +61,41 @@ def soft_delete! broadcast_deletion end + # Returns the sender record (User or Player) + def sender_record + find_sender_record + end + + # Returns the recipient record (User or Player) + def recipient_record + find_recipient_record + end + private def recipient_belongs_to_same_org - return unless recipient - - return if recipient.organization_id == organization_id + record = find_recipient_record + return if record&.organization_id == organization_id errors.add(:recipient, 'must belong to the same organization') end + def find_sender_record + if sender_type == 'Player' + Player.find_by(id: user_id) + else + User.find_by(id: user_id) + end + end + + def find_recipient_record + if recipient_type == 'Player' + Player.find_by(id: recipient_id) + else + User.find_by(id: recipient_id) + end + end + def broadcast_to_participants return unless recipient_id.present? @@ -85,16 +117,25 @@ def broadcast_deletion end def serialize_for_broadcast + sender = find_sender_record { id: id, content: content, created_at: created_at.iso8601, recipient_id: recipient_id, - user: { - id: user.id, - full_name: user.full_name, - role: user.role - } + recipient_type: recipient_type, + sender_type: sender_type, + sender: serialize_sender_for_broadcast(sender) } end + + def serialize_sender_for_broadcast(sender) + return {} unless sender + + if sender_type == 'Player' + { id: sender.id, full_name: sender.professional_name.presence || sender.real_name, role: sender.role } + else + { id: sender.id, full_name: sender.full_name, role: sender.role } + end + end end diff --git a/app/modules/notifications/models/notification.rb b/app/modules/notifications/models/notification.rb index 479d29c..966049b 100644 --- a/app/modules/notifications/models/notification.rb +++ b/app/modules/notifications/models/notification.rb @@ -45,6 +45,7 @@ class Notification < ApplicationRecord # Callbacks before_create :set_default_channels + after_create_commit :broadcast_push # Instance methods def mark_as_read! @@ -60,4 +61,27 @@ def unread? def set_default_channels self.channels ||= ['in_app'] end + + def broadcast_push + ActionCable.server.broadcast( + "notifications_user_#{user_id}", + { event: 'notification.created', notification: notification_push_payload } + ) + rescue StandardError => e + Rails.logger.warn(event: 'notification_broadcast_error', user_id: user_id, error: e.message) + end + + def notification_push_payload + { + id: id, + title: title, + message: message, + type: type, + link_url: link_url, + link_type: link_type, + link_id: link_id, + is_read: is_read, + created_at: created_at.iso8601 + } + end end diff --git a/app/modules/players/controllers/players_controller.rb b/app/modules/players/controllers/players_controller.rb index 42c28b0..4dffcb2 100644 --- a/app/modules/players/controllers/players_controller.rb +++ b/app/modules/players/controllers/players_controller.rb @@ -5,19 +5,22 @@ module Controllers # Controller for managing players within an organization # Business logic extracted to Services for better organization class PlayersController < Api::V1::BaseController + include Cacheable + before_action :set_player, only: %i[show update destroy stats matches sync_from_riot] + after_action -> { invalidate_cache('players') }, only: %i[update destroy] + after_action -> { invalidate_cache("players/#{@player&.id}") }, only: %i[update destroy] + # GET /api/v1/players def index - # Optimized query to prevent timeout during bulk sync operations - # PostgreSQL will allow concurrent reads even during updates (MVCC) - # Set a reasonable timeout to prevent 504s - ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = '5000'") # 5 seconds + ActiveRecord::Base.connection.execute("SET statement_timeout = '5000'") players = organization_scoped(Player).includes(:organization) players = players.by_role(params[:role]) if params[:role].present? players = players.by_status(params[:status]) if params[:status].present? + players = players.by_line(params[:line]) if params[:line].present? if params[:search].present? search_term = "%#{params[:search]}%" @@ -26,10 +29,14 @@ def index result = paginate(players.ordered_by_role.order(:summoner_name)) - render_success({ - players: PlayerSerializer.render_as_hash(result[:data]), - pagination: result[:pagination] - }) + data = cache_response('players', expires_in: 5.minutes) do + { + players: PlayerSerializer.render_as_hash(result[:data]), + pagination: result[:pagination] + } + end + + render_success(data) rescue ActiveRecord::QueryCanceled => e Rails.logger.error "Players index query timeout: #{e.message}" render_error( @@ -37,13 +44,20 @@ def index code: 'QUERY_TIMEOUT', status: :request_timeout ) + ensure + begin + ActiveRecord::Base.connection.execute('RESET statement_timeout') + rescue StandardError + nil + end end # GET /api/v1/players/:id def show - render_success({ - player: PlayerSerializer.render_as_hash(@player) - }) + data = cache_response("players/#{@player.id}", expires_in: 5.minutes) do + { player: PlayerSerializer.render_as_hash(@player) } + end + render_success(data) end # POST /api/v1/players @@ -171,13 +185,14 @@ def import summoner_name = params[:summoner_name]&.strip role = params[:role] region = params[:region] || 'br1' + line = params[:line].presence_in(Constants::Player::LINES) || 'main' # Validations return unless validate_import_params(summoner_name, role) return unless validate_player_uniqueness(summoner_name) # Import from Riot API - result = import_player_from_riot(summoner_name, role, region) + result = import_player_from_riot(summoner_name, role, region, line) # Handle result result[:success] ? handle_import_success(result) : handle_import_error(result) @@ -419,12 +434,13 @@ def validate_player_uniqueness(summoner_name) end # Import player from Riot API - def import_player_from_riot(summoner_name, role, region) + def import_player_from_riot(summoner_name, role, region, line = 'main') RiotSyncService.import( summoner_name: summoner_name, role: role, region: region, - organization: current_organization + organization: current_organization, + line: line ) end diff --git a/app/modules/players/controllers/stats_export_controller.rb b/app/modules/players/controllers/stats_export_controller.rb index cdeeaf4..f638f2a 100644 --- a/app/modules/players/controllers/stats_export_controller.rb +++ b/app/modules/players/controllers/stats_export_controller.rb @@ -28,6 +28,16 @@ class StatsExportController < Api::V1::BaseController performance_score result ].freeze + COMPUTED_FIELDS = { + 'match_date' => ->(stat) { stat.match&.game_start&.strftime('%Y-%m-%d') }, + 'patch_version' => ->(stat) { stat.match&.game_version }, + 'opponent' => ->(stat) { stat.match&.opponent_name }, + 'result' => ->(stat) { stat.match&.victory? ? 'W' : 'L' }, + 'kda_display' => ->(stat) { stat.kda_display }, + 'cs_per_min' => ->(stat) { stat.cs_per_min&.round(2) }, + 'gold_per_min' => ->(stat) { stat.gold_per_min&.round(0) } + }.freeze + def show player = organization_scoped(Player).find(params[:id]) stats = filtered_stats(player) @@ -85,17 +95,11 @@ def build_row_array(stat) EXPORT_FIELDS.map { |field| export_field_value(stat, field) } end - def export_field_value(stat, field) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - case field - when 'match_date' then stat.match&.game_start&.strftime('%Y-%m-%d') - when 'patch_version' then stat.match&.game_version - when 'opponent' then stat.match&.opponent_name - when 'result' then stat.match&.victory? ? 'W' : 'L' - when 'kda_display' then stat.kda_display - when 'cs_per_min' then stat.cs_per_min&.round(2) - when 'gold_per_min' then stat.gold_per_min&.round(0) - else stat.public_send(field) - end + def export_field_value(stat, field) + resolver = COMPUTED_FIELDS[field] + return resolver.call(stat) if resolver + + stat.public_send(field) end end end diff --git a/app/modules/players/jobs/sync_player_from_riot_job.rb b/app/modules/players/jobs/sync_player_from_riot_job.rb index 84f6091..d157947 100644 --- a/app/modules/players/jobs/sync_player_from_riot_job.rb +++ b/app/modules/players/jobs/sync_player_from_riot_job.rb @@ -35,6 +35,7 @@ def sync_player_from_riot!(player, riot_api_key) apply_ranked_data!(update_data, ranked_data) player.update!(update_data) Rails.logger.info "Successfully synced player #{player.id} from Riot API" + record_job_heartbeat rescue StandardError => e Rails.logger.error "Failed to sync player #{player.id}: #{e.message}" Rails.logger.error e.backtrace.join("\n") diff --git a/app/modules/players/models/player.rb b/app/modules/players/models/player.rb index e81e37f..6058fd4 100644 --- a/app/modules/players/models/player.rb +++ b/app/modules/players/models/player.rb @@ -30,31 +30,47 @@ # @example Finding active players by role # mid_laners = Player.active.by_role("mid") # -class Player < ApplicationRecord - # Concerns +class Player < ApplicationRecord # rubocop:disable Metrics/ClassLength include Constants include OrganizationScoped include SoftDeletable include Searchable + include UpgradeablePassword # Associations - # optional: true β€” self-registered free agents (ArenaBR) can exist without an org belongs_to :organization, optional: true + belongs_to :scouted_from, class_name: 'ScoutingTarget', optional: true has_many :player_match_stats, dependent: :destroy has_many :matches, through: :player_match_stats has_many :champion_pools, dependent: :destroy has_many :team_goals, dependent: :destroy has_many :vod_timestamps, foreign_key: 'target_player_id', dependent: :nullify + has_many :password_reset_tokens, dependent: :destroy - # Password authentication for individual player access - has_secure_password :player_password, validations: false + # Virtual attribute for the player password β€” has_secure_password is not used; + # hashing is handled by Authentication::PasswordHasher. + attr_reader :player_password + + def player_password=(plain_password) + @player_password = plain_password.blank? ? nil : plain_password + end + + def authenticate_player_password(plain_password) + authenticate_with_upgrade( + plain_password, + digest_attr: :player_password_digest, + digest_setter: :player_password_digest + ) + end # Validations + validates :source_app, inclusion: { in: Constants::SOURCE_APPS } validates :summoner_name, presence: true, length: { maximum: 100 } validates :real_name, length: { maximum: 255 } validates :role, presence: true, inclusion: { in: Constants::Player::ROLES } validates :country, length: { maximum: 2 } validates :status, inclusion: { in: Constants::Player::STATUSES } + validates :line, inclusion: { in: Constants::Player::LINES } validates :riot_puuid, uniqueness: true, allow_blank: true validates :riot_summoner_id, uniqueness: true, allow_blank: true validates :jersey_number, uniqueness: { scope: :organization_id }, allow_blank: true @@ -63,18 +79,25 @@ class Player < ApplicationRecord validates :flex_queue_tier, inclusion: { in: Constants::Player::QUEUE_TIERS }, allow_blank: true validates :flex_queue_rank, inclusion: { in: Constants::Player::QUEUE_RANKS }, allow_blank: true validates :player_email, uniqueness: true, allow_blank: true, format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true } - validates :player_password, length: { minimum: 8 }, if: -> { player_password.present? } + validates :player_password, + length: { minimum: 8, message: 'must be at least 8 characters' }, + format: { + with: /\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*\z/, + message: 'must contain at least one uppercase letter, one lowercase letter, and one number' + }, + if: -> { player_password.present? } # Callbacks + before_validation :hash_player_password, if: -> { player_password.present? } before_save :normalize_summoner_name - after_update :log_audit_trail, if: :saved_changes? - after_create :clear_organization_cache - after_destroy :clear_organization_cache + after_update_commit :enqueue_audit_log, if: :saved_changes? + after_commit :clear_organization_cache, on: %i[create destroy] after_update :clear_organization_cache, if: :saved_change_to_deleted_at? # Scopes scope :by_role, ->(role) { where(role: role) } scope :by_status, ->(status) { where(status: status) } + scope :by_line, ->(line) { where(line: line) } scope :active, -> { where(status: 'active') } scope :with_contracts, -> { where.not(contract_start_date: nil) } scope :contracts_expiring_soon, lambda { |days = 30| @@ -220,10 +243,13 @@ def normalize_summoner_name self.summoner_name = summoner_name.strip if summoner_name.present? end - def log_audit_trail - AuditLog.create!( - organization: organization, - action: 'update', + def hash_player_password + self.player_password_digest = Authentication::PasswordHasher.hash(player_password) + end + + def enqueue_audit_log + AuditLogJob.perform_later( + organization_id: organization_id, entity_type: 'Player', entity_id: id, old_values: saved_changes.transform_values(&:first), @@ -232,6 +258,10 @@ def log_audit_trail end def clear_organization_cache - organization.clear_players_cache if organization.present? + return unless organization.present? + + organization.clear_players_cache + Rails.cache.delete("v1:#{organization_id}:players") + Rails.cache.delete("v1:#{organization_id}:players/#{id}") end end diff --git a/app/modules/players/policies/player_policy.rb b/app/modules/players/policies/player_policy.rb index f0652f8..9117f50 100644 --- a/app/modules/players/policies/player_policy.rb +++ b/app/modules/players/policies/player_policy.rb @@ -11,11 +11,11 @@ def show? end def create? - admin? + coach? end def update? - admin? && same_organization? + coach? && same_organization? end def destroy? @@ -31,7 +31,7 @@ def matches? end def import? - admin? + coach? end def sync_from_riot? diff --git a/app/modules/players/serializers/player_serializer.rb b/app/modules/players/serializers/player_serializer.rb index 822ae3a..5ef7cb8 100644 --- a/app/modules/players/serializers/player_serializer.rb +++ b/app/modules/players/serializers/player_serializer.rb @@ -5,7 +5,7 @@ class PlayerSerializer < Blueprinter::Base identifier :id - fields :summoner_name, :real_name, :role, :status, + fields :summoner_name, :real_name, :role, :status, :line, :jersey_number, :birth_date, :country, :contract_start_date, :contract_end_date, :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, diff --git a/app/modules/players/services/riot_sync_service.rb b/app/modules/players/services/riot_sync_service.rb index 1fecb08..d06780e 100644 --- a/app/modules/players/services/riot_sync_service.rb +++ b/app/modules/players/services/riot_sync_service.rb @@ -55,13 +55,13 @@ def initialize(organization, region = nil) end # Class method to import a new player from Riot API - def self.import(summoner_name:, role:, region:, organization:) + def self.import(summoner_name:, role:, region:, organization:, line: 'main') service = new(organization, region) - service.import_player(summoner_name, role) + service.import_player(summoner_name, role, line: line) end # Import a new player from Riot API - def import_player(summoner_name, role) + def import_player(summoner_name, role, line: 'main') parsed_name = parse_summoner_name(summoner_name) return parsed_name unless parsed_name[:success] @@ -85,7 +85,8 @@ def import_player(summoner_name, role) solo_queue_losses: riot_data[:rank_data]['losses'] || 0, last_sync_at: Time.current, sync_status: 'success', - region: @region + region: @region, + line: line ) { @@ -381,14 +382,25 @@ def fetch_match_details(match_id) end # Make HTTP request to Riot API + # + # Wrapped with CircuitBreakerService so that consecutive failures open the + # circuit and prevent thundering-herd pressure on the Riot API during an + # outage or rate-limit window. def make_request(url) + CircuitBreakerService.call('riot_api') do + perform_http_request(url) + end + end + + # Execute the raw HTTP call (called inside the circuit breaker) + def perform_http_request(url) uri = URI(url) request = Net::HTTP::Get.new(uri) request['X-Riot-Token'] = api_key # Debug logging - Rails.logger.info(" Making Riot API request to: #{uri}") - Rails.logger.info(" API Key present: #{api_key.present?} (length: #{api_key&.length || 0})") + Rails.logger.info("[RIOT] Making Riot API request to: #{uri}") + Rails.logger.info("[RIOT] API Key present: #{api_key.present?} (length: #{api_key&.length || 0})") response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) @@ -396,7 +408,7 @@ def make_request(url) unless response.is_a?(Net::HTTPSuccess) error_message = "Riot API Error: #{response.code} - #{response.body}" - Rails.logger.error("Riot API Error - URL: #{uri} - Status: #{response.code} - Body: #{response.body}") + Rails.logger.error("[RIOT] Riot API Error - URL: #{uri} - Status: #{response.code} - Body: #{response.body}") # Create custom exception with status code for better error handling error = RiotApiError.new(error_message) @@ -405,7 +417,7 @@ def make_request(url) raise error end - Rails.logger.info(" Riot API request successful: #{response.code}") + Rails.logger.info("[RIOT] Riot API request successful: #{response.code}") response end diff --git a/app/modules/players/services/roster_management_service.rb b/app/modules/players/services/roster_management_service.rb index 84c2882..ee7548e 100644 --- a/app/modules/players/services/roster_management_service.rb +++ b/app/modules/players/services/roster_management_service.rb @@ -39,6 +39,12 @@ def remove_from_roster(reason:) # Log the action log_roster_removal(previous_org_id, reason) + Events::EventPublisher.publish( + user_id: current_user&.id || 'system', + org_id: previous_org_id, + type: 'roster.player_removed', + payload: { player_id: player.id, player_name: player.summoner_name, reason: reason } + ) { success: true, player: player, @@ -62,15 +68,14 @@ def remove_from_roster(reason:) # @param jersey_number [Integer] Jersey number (optional) # @return [Hash] Result with success status and player def self.hire_from_scouting(scouting_target:, organization:, contract_start:, contract_end:, - salary: nil, jersey_number: nil, current_user: nil) + salary: nil, jersey_number: nil, line: 'main', current_user: nil) ActiveRecord::Base.transaction do - # Check if this is a free agent or needs to be restored player = find_or_restore_player(scouting_target, organization) - # Update player with new contract details player.update!( organization: organization, status: 'active', + line: line.presence_in(Constants::Player::LINES) || 'main', contract_start_date: contract_start, contract_end_date: contract_end, salary: salary, @@ -84,12 +89,25 @@ def self.hire_from_scouting(scouting_target:, organization:, contract_start:, co watchlist = scouting_target.scouting_watchlists.find_by(organization: organization) watchlist&.destroy - # Clean up the global target if no other org is watching it - scouting_target.destroy if scouting_target.scouting_watchlists.none? + # Mark the global target as signed (never destroy β€” it is permanent scouting history) + scouting_target.update_columns(status: 'signed') if scouting_target.scouting_watchlists.none? + + # Link the player back to the scouting record and store a snapshot of the data + # that informed the hiring decision, so coaches can audit it later. + player.update_columns( + scouted_from_id: scouting_target.id, + scouting_data_snapshot: build_scouting_snapshot(scouting_target) + ) # Log the action log_roster_addition(player, scouting_target, current_user) + Events::EventPublisher.publish( + user_id: current_user&.id || 'system', + org_id: organization.id, + type: 'roster.player_hired', + payload: { player_id: player.id, player_name: player.summoner_name, org_id: organization.id } + ) { success: true, player: player, @@ -197,19 +215,23 @@ def find_or_build_scouting_target def assign_scouting_target_attributes(target) recent_perf = calculate_recent_performance(player) recent_perf[:champion_pool_stats] = calculate_champion_stats(player) + pool = calculate_champion_pool_from_stats(player) + tier = player.solo_queue_tier target.assign_attributes( summoner_name: player.summoner_name, region: normalize_region(player.region), riot_puuid: player.riot_puuid, role: player.role, - current_tier: player.solo_queue_tier, + current_tier: tier, current_rank: player.solo_queue_rank, current_lp: player.solo_queue_lp, - champion_pool: calculate_champion_pool_from_stats(player), + champion_pool: pool, recent_performance: recent_perf, performance_trend: calculate_performance_trend(player), playstyle: extract_playstyle_from_notes(player.notes), + strengths: derive_strengths(recent_perf, pool, player.role, tier), + weaknesses: derive_weaknesses(recent_perf, pool, player.role, tier), twitter_handle: player.twitter_handle, status: 'free_agent', real_name: player.real_name, @@ -396,6 +418,99 @@ def last_game_date_for(stats) match.game_start&.to_date end + # Returns stat thresholds adjusted to the player's ranked tier. + # High elo players are held to a stricter standard β€” what is average + # at Platinum is a weakness at Challenger. + # + # @param tier [String, nil] e.g. "CHALLENGER", "DIAMOND", "GOLD" + # @return [Hash] threshold values for strengths and weaknesses + def tier_thresholds(tier) + case tier&.upcase + when 'CHALLENGER', 'GRANDMASTER', 'MASTER' + { wr_strength: 53, wr_weakness: 49, kda_strength: 4.5, kda_weakness: 3.0, + cs_strength: 9.0, cs_weakness: 7.5, vision_strength: 45, vision_weakness: 28 } + when 'DIAMOND', 'EMERALD' + { wr_strength: 54, wr_weakness: 47, kda_strength: 4.0, kda_weakness: 2.5, + cs_strength: 8.5, cs_weakness: 7.0, vision_strength: 42, vision_weakness: 24 } + else + { wr_strength: 55, wr_weakness: 45, kda_strength: 3.5, kda_weakness: 2.0, + cs_strength: 8.0, cs_weakness: 6.0, vision_strength: 40, vision_weakness: 20 } + end + end + + # Derive positive traits from performance stats, calibrated to the player's tier. + def derive_strengths(perf, pool, role, tier = nil) + return [] if perf.blank? + + t = tier_thresholds(tier) + strengths = [] + strengths << 'Consistency' if strong_win_rate?(perf, t) + strengths << 'Mechanical skill' if strong_kda?(perf, t) + strengths << 'CS discipline' if strong_cs?(perf, role, t) + strengths << 'Map awareness' if strong_vision?(perf, role, t) + strengths << 'Team fighting' if perf[:avg_kill_participation].to_f >= 65.0 + strengths << 'Champion pool depth' if pool.size >= 6 + strengths + end + + # Derive areas for improvement, calibrated to the player's tier. + def derive_weaknesses(perf, pool, role, tier = nil) + return [] if perf.blank? + + t = tier_thresholds(tier) + [ + ('Inconsistent performance' if inconsistent_performance?(perf, t)), + ('Death management' if poor_kda?(perf, t)), + ('CS discipline' if poor_cs?(perf, role, t)), + ('Vision control' if poor_vision?(perf, role, t)), + ('Limited champion pool' if pool.size < 3) + ].compact + end + + def non_support?(role) + role.to_s != 'support' + end + + def vision_role?(role) + %w[support jungle].include?(role.to_s) + end + + def inconsistent_performance?(perf, thresholds) + perf[:games_played].to_i >= 10 && perf[:win_rate].to_f < thresholds[:wr_weakness] + end + + def poor_kda?(perf, thresholds) + perf[:avg_kda].to_f.positive? && perf[:avg_kda].to_f < thresholds[:kda_weakness] + end + + def poor_cs?(perf, role, thresholds) + non_support?(role) && + perf[:avg_cs_per_min].to_f.positive? && + perf[:avg_cs_per_min].to_f < thresholds[:cs_weakness] + end + + def poor_vision?(perf, role, thresholds) + vision_role?(role) && + perf[:avg_vision_score].to_f.positive? && + perf[:avg_vision_score].to_f < thresholds[:vision_weakness] + end + + def strong_win_rate?(perf, thresholds) + perf[:win_rate].to_f >= thresholds[:wr_strength] + end + + def strong_kda?(perf, thresholds) + perf[:avg_kda].to_f >= thresholds[:kda_strength] + end + + def strong_cs?(perf, role, thresholds) + non_support?(role) && perf[:avg_cs_per_min].to_f >= thresholds[:cs_strength] + end + + def strong_vision?(perf, role, thresholds) + vision_role?(role) && perf[:avg_vision_score].to_f >= thresholds[:vision_strength] + end + # Extract playstyle from player notes def extract_playstyle_from_notes(notes) return nil if notes.blank? @@ -501,5 +616,28 @@ def self.addition_new_values(player, scouting_target) } end - private_class_method :find_or_restore_player, :log_roster_addition, :addition_old_values, :addition_new_values + # Snapshot of the scouting target at the moment of hiring. + # Stored in players.scouting_data_snapshot so the record is immutable even if + # the ScoutingTarget is later re-synced or its status changes. + def self.build_scouting_snapshot(target) + { + summoner_name: target.summoner_name, + role: target.role, + region: target.region, + current_tier: target.current_tier, + current_rank: target.current_rank, + current_lp: target.current_lp, + champion_pool: target.champion_pool, + recent_performance: target.recent_performance, + performance_trend: target.performance_trend, + strengths: target.strengths, + weaknesses: target.weaknesses, + playstyle: target.playstyle, + scouting_score: target.scouting_score, + snapshotted_at: Time.current.iso8601 + } + end + + private_class_method :find_or_restore_player, :log_roster_addition, :addition_old_values, + :addition_new_values, :build_scouting_snapshot end diff --git a/app/modules/riot_integration/services/data_dragon_service.rb b/app/modules/riot_integration/services/data_dragon_service.rb index 2639528..6d0a01a 100644 --- a/app/modules/riot_integration/services/data_dragon_service.rb +++ b/app/modules/riot_integration/services/data_dragon_service.rb @@ -94,8 +94,7 @@ def fetch_champion_data champion_map = {} data['data'].each_value do |champion| champion_id = champion['key'].to_i - champion_name = champion['id'] # This is the champion name like "Aatrox" - champion_map[champion_id] = champion_name + champion_map[champion_id] = champion['name'] # display name: "Wukong", "Lee Sin", etc. end champion_map diff --git a/app/modules/riot_integration/services/riot_api_service.rb b/app/modules/riot_integration/services/riot_api_service.rb index 4cc3ea4..fcc5ee4 100644 --- a/app/modules/riot_integration/services/riot_api_service.rb +++ b/app/modules/riot_integration/services/riot_api_service.rb @@ -1,25 +1,20 @@ # frozen_string_literal: true -# Wrapper around the Riot Games API with built-in rate limiting and regional routing. -# Provides methods for summoner, match, league, and champion mastery lookups. +# Proxy to the prostaff-riot-gateway Go service. +# Rate limiting, caching and circuit breaking are handled by the gateway. class RiotApiService - RATE_LIMITS = { - per_second: 20, - per_two_minutes: 100 - }.freeze - REGIONS = { - 'BR' => { platform: 'BR1', region: 'americas' }, - 'NA' => { platform: 'NA1', region: 'americas' }, - 'EUW' => { platform: 'EUW1', region: 'europe' }, - 'EUNE' => { platform: 'EUN1', region: 'europe' }, - 'KR' => { platform: 'KR', region: 'asia' }, - 'JP' => { platform: 'JP1', region: 'asia' }, - 'OCE' => { platform: 'OC1', region: 'sea' }, - 'LAN' => { platform: 'LA1', region: 'americas' }, - 'LAS' => { platform: 'LA2', region: 'americas' }, - 'RU' => { platform: 'RU', region: 'europe' }, - 'TR' => { platform: 'TR1', region: 'europe' } + 'BR' => { platform: 'br1', region: 'americas' }, + 'NA' => { platform: 'na1', region: 'americas' }, + 'EUW' => { platform: 'euw1', region: 'europe' }, + 'EUNE' => { platform: 'eun1', region: 'europe' }, + 'KR' => { platform: 'kr', region: 'asia' }, + 'JP' => { platform: 'jp1', region: 'asia' }, + 'OCE' => { platform: 'oc1', region: 'sea' }, + 'LAN' => { platform: 'la1', region: 'americas' }, + 'LAS' => { platform: 'la2', region: 'americas' }, + 'RU' => { platform: 'ru', region: 'europe' }, + 'TR' => { platform: 'tr1', region: 'europe' } }.freeze class RiotApiError < StandardError; end @@ -27,183 +22,125 @@ class RateLimitError < RiotApiError; end class NotFoundError < RiotApiError; end class UnauthorizedError < RiotApiError; end - def initialize(api_key: nil) - @api_key = api_key || ENV['RIOT_API_KEY'] - raise RiotApiError, 'Riot API key not configured' if @api_key.blank? + def initialize(_api_key: nil) + @gateway_url = ENV.fetch('RIOT_GATEWAY_URL', 'http://riot-gateway:4444') end def get_summoner_by_name(summoner_name:, region:) - platform = platform_for_region(region) - url = "https://#{platform}.api.riotgames.com/lol/summoner/v4/summoners/by-name/#{ERB::Util.url_encode(summoner_name)}" + platform = platform_for(region) + response = get("/riot/summoner/#{platform}/by-name/#{ERB::Util.url_encode(summoner_name)}") + parse_summoner_response(response) + end - response = make_request(url) + def get_summoner_by_puuid(puuid:, region:) + platform = platform_for(region) + response = get("/riot/summoner/#{platform}/by-puuid/#{puuid}") parse_summoner_response(response) end def get_account_by_puuid(puuid:, region:) - regional_route = regional_route_for_region(region) - url = "https://#{regional_route}.api.riotgames.com/riot/account/v1/accounts/by-puuid/#{puuid}" - - response = make_request(url) + routing = routing_for(region) + response = get("/riot/account/#{routing}/by-puuid/#{puuid}") parse_account_response(response) end - def get_summoner_by_puuid(puuid:, region:) - platform = platform_for_region(region) - url = "https://#{platform}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" - - response = make_request(url) - parse_summoner_response(response) - end - def get_league_entries(summoner_id:, region:) - platform = platform_for_region(region) - url = "https://#{platform}.api.riotgames.com/lol/league/v4/entries/by-summoner/#{summoner_id}" - - response = make_request(url) + platform = platform_for(region) + response = get("/riot/league/#{platform}/by-summoner/#{summoner_id}") parse_league_entries(response) end def get_league_entries_by_puuid(puuid:, region:) - platform = platform_for_region(region) - url = "https://#{platform}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}" - - response = make_request(url) + platform = platform_for(region) + response = get("/riot/league/#{platform}/by-puuid/#{puuid}") parse_league_entries(response) end def get_match_history(puuid:, region:, count: 20, start: 0) - regional_route = regional_route_for_region(region) - url = "https://#{regional_route}.api.riotgames.com/lol/match/v5/matches/by-puuid/#{puuid}/ids?start=#{start}&count=#{count}" - - response = make_request(url) + platform = platform_for(region) + response = get("/riot/matches/#{platform}/#{puuid}/ids?count=#{count}&start=#{start}") JSON.parse(response.body) end def get_match_details(match_id:, region:) - regional_route = regional_route_for_region(region) - url = "https://#{regional_route}.api.riotgames.com/lol/match/v5/matches/#{match_id}" - - response = make_request(url) + platform = platform_for(region) + response = get("/riot/match/#{platform}/#{match_id}") parse_match_details(response) end def get_champion_mastery(puuid:, region:) - platform = platform_for_region(region) - url = "https://#{platform}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/#{puuid}" - - response = make_request(url) + platform = platform_for(region) + response = get("/riot/mastery/#{platform}/#{puuid}/top?count=50") parse_champion_mastery(response) end private - def make_request(url) - check_rate_limit! - - conn = Faraday.new do |f| - f.request :retry, max: 3, interval: 0.5, backoff_factor: 2 + def get(path) + conn = Faraday.new(@gateway_url) do |f| + f.request :retry, max: 2, interval: 0.5, backoff_factor: 2 f.adapter Faraday.default_adapter end - response = conn.get(url) do |req| - req.headers['X-Riot-Token'] = @api_key + response = conn.get(path) do |req| + req.headers['Authorization'] = "Bearer #{internal_jwt}" req.options.timeout = 10 end handle_response(response) rescue Faraday::TimeoutError => e - raise RiotApiError, "Request timeout: #{e.message}" + raise RiotApiError, "Gateway timeout: #{e.message}" rescue Faraday::Error => e - raise RiotApiError, "Network error: #{e.message}" + raise RiotApiError, "Gateway error: #{e.message}" + end + + def internal_jwt + payload = { service: 'prostaff-api', aud: ['prostaff-riot-gateway'], exp: 1.hour.from_now.to_i } + JWT.encode(payload, ENV.fetch('INTERNAL_JWT_SECRET'), 'HS256') end def handle_response(response) case response.status - when 200 - response - when 404 - raise NotFoundError, 'Resource not found' - when 401, 403 - raise UnauthorizedError, 'Invalid API key or unauthorized' + when 200 then response + when 404 then raise NotFoundError, 'Resource not found' + when 401, 403 then raise UnauthorizedError, 'Gateway auth failed' when 429 - retry_after = response.headers['Retry-After']&.to_i || 120 + retry_after = response.headers['Retry-After']&.to_i || 60 raise RateLimitError, "Rate limit exceeded. Retry after #{retry_after} seconds" - when 500..599 - raise RiotApiError, "Riot API server error: #{response.status}" - else - raise RiotApiError, "Unexpected response: #{response.status}" + when 503 then raise RiotApiError, 'Riot API circuit breaker open' + when 500..599 then raise RiotApiError, "Gateway error: #{response.status}" + else raise RiotApiError, "Unexpected response: #{response.status}" end end - def check_rate_limit! - return unless Rails.cache - - current_second = Time.current.to_i - key_second = "riot_api:rate_limit:second:#{current_second}" - key_two_min = "riot_api:rate_limit:two_minutes:#{current_second / 120}" - - count_second = Rails.cache.increment(key_second, 1, expires_in: 1.second) || 0 - count_two_min = Rails.cache.increment(key_two_min, 1, expires_in: 2.minutes) || 0 - - if count_second > RATE_LIMITS[:per_second] - sleep(1 - (Time.current.to_f % 1)) # Sleep until next second - end - - return unless count_two_min > RATE_LIMITS[:per_two_minutes] - - raise RateLimitError, 'Rate limit exceeded for 2-minute window' - end - - def platform_for_region(region) + def platform_for(region) normalized = normalize_region(region) REGIONS.dig(normalized, :platform) || raise(RiotApiError, "Unknown region: #{region}") end - def regional_route_for_region(region) + def routing_for(region) normalized = normalize_region(region) REGIONS.dig(normalized, :region) || raise(RiotApiError, "Unknown region: #{region}") end - # Normalizes platform codes (br1, na1, euw1) to region codes (BR, NA, EUW) def normalize_region(region) return nil if region.nil? - # Convert to uppercase and remove trailing digit - normalized = region.to_s.upcase.sub(/\d+$/, '') - - # Map platform codes to region codes - platform_to_region = { - 'BR' => 'BR', - 'NA' => 'NA', - 'EUW' => 'EUW', - 'EUN' => 'EUNE', - 'KR' => 'KR', - 'JP' => 'JP', - 'OC' => 'OCE', - 'LA' => 'LAN', # LA1 -> LAN, LA2 -> LAS (handle separately) - 'RU' => 'RU', - 'TR' => 'TR' - } + upper = region.to_s.upcase + return 'LAN' if upper == 'LA1' + return 'LAS' if upper == 'LA2' - # Special case for LA1/LA2 - if region.to_s.upcase == 'LA1' - return 'LAN' - elsif region.to_s.upcase == 'LA2' - return 'LAS' - end - - # Return mapped region or the normalized value - platform_to_region[normalized] || normalized + stripped = upper.sub(/\d+$/, '') + { + 'BR' => 'BR', 'NA' => 'NA', 'EUW' => 'EUW', 'EUN' => 'EUNE', + 'KR' => 'KR', 'JP' => 'JP', 'OC' => 'OCE', 'LA' => 'LAN', + 'RU' => 'RU', 'TR' => 'TR' + }.fetch(stripped, stripped) end def parse_account_response(response) data = JSON.parse(response.body) - { - puuid: data['puuid'], - game_name: data['gameName'], - tag_line: data['tagLine'] - } + { puuid: data['puuid'], game_name: data['gameName'], tag_line: data['tagLine'] } end def parse_summoner_response(response) @@ -219,7 +156,6 @@ def parse_summoner_response(response) def parse_league_entries(response) entries = JSON.parse(response.body) - { solo_queue: find_queue_entry(entries, 'RANKED_SOLO_5x5'), flex_queue: find_queue_entry(entries, 'RANKED_FLEX_SR') @@ -240,8 +176,8 @@ def find_queue_entry(entries, queue_type) end def parse_match_details(response) - data = JSON.parse(response.body) - info = data['info'] + data = JSON.parse(response.body) + info = data['info'] metadata = data['metadata'] { @@ -255,8 +191,13 @@ def parse_match_details(response) end def parse_participant(participant) - challenges = participant['challenges'] || {} + core_participant_fields(participant) + .merge(combat_participant_fields(participant)) + .merge(vision_participant_fields(participant)) + .merge(challenge_participant_fields(participant)) + end + def core_participant_fields(participant) { puuid: participant['puuid'], summoner_name: participant['summonerName'], @@ -272,49 +213,77 @@ def parse_participant(participant) total_damage_taken: participant['totalDamageTaken'], minions_killed: participant['totalMinionsKilled'], neutral_minions_killed: participant['neutralMinionsKilled'], - vision_score: participant['visionScore'], - wards_placed: participant['wardsPlaced'], - wards_killed: participant['wardsKilled'], champion_level: participant['champLevel'], + win: participant['win'], + items: extract_items(participant), + item_build_order: extract_item_build_order(participant), + trinket: participant['item6'], + runes: extract_runes(participant) + } + end + + def combat_participant_fields(participant) + { first_blood_kill: participant['firstBloodKill'], + first_tower_kill: participant['firstTowerKill'], double_kills: participant['doubleKills'], triple_kills: participant['tripleKills'], quadra_kills: participant['quadraKills'], penta_kills: participant['pentaKills'], - win: participant['win'], - items: [ - participant['item0'], participant['item1'], participant['item2'], - participant['item3'], participant['item4'], participant['item5'], - participant['item6'] - ].compact.reject(&:zero?), - item_build_order: extract_item_build_order(participant), - trinket: participant['item6'], - summoner_spell_1: participant['summoner1Id'], - summoner_spell_2: participant['summoner2Id'], - runes: extract_runes(participant), objectives_stolen: participant['objectivesStolen'], crowd_control_score: participant['timeCCingOthers'], total_time_dead: participant['totalTimeSpentDead'], damage_to_turrets: participant['totalDamageDealtToTurrets'], damage_shielded_teammates: participant['totalDamageShieldedOnTeammates'], - healing_to_teammates: participant['totalHealsOnTeammates'], + healing_to_teammates: participant['totalHealsOnTeammates'] + } + end + + def vision_participant_fields(participant) + { + vision_score: participant['visionScore'], + wards_placed: participant['wardsPlaced'], + wards_killed: participant['wardsKilled'], + control_wards_purchased: participant['visionWardsBoughtInGame'], + summoner_spell_1: participant['summoner1Id'], + summoner_spell_2: participant['summoner2Id'], spell_q_casts: participant['spell1Casts'], spell_w_casts: participant['spell2Casts'], spell_e_casts: participant['spell3Casts'], spell_r_casts: participant['spell4Casts'], summoner_spell_1_casts: participant['summoner1Casts'], summoner_spell_2_casts: participant['summoner2Casts'], - cs_at_10: challenges['laneMinionsFirst10Minutes'], - turret_plates_destroyed: challenges['turretPlatesTaken'], pings: extract_pings(participant) } end + def challenge_participant_fields(participant) + challenges = participant['challenges'] || {} + { + cs_at_10: challenges['laneMinionsFirst10Minutes'], + turret_plates_destroyed: challenges['turretPlatesTaken'] + } + end + + def extract_items(participant) + [ + participant['item0'], participant['item1'], participant['item2'], + participant['item3'], participant['item4'], participant['item5'], + participant['item6'] + ].compact.reject(&:zero?) + end + + def extract_item_build_order(participant) + [ + participant['item0'], participant['item1'], participant['item2'], + participant['item3'], participant['item4'], participant['item5'] + ].compact.reject(&:zero?) + end + def extract_runes(participant) perks = participant.dig('perks', 'styles') return [] unless perks - # Extract primary and sub-style selections perks.flat_map { |style| style['selections'].map { |s| s['perk'] } } end @@ -338,20 +307,8 @@ def extract_pings(participant) } end - def extract_item_build_order(participant) - # Riot API doesn't provide item purchase order in match details - # We can only get the final items, so return them in the order they appear - # (item0-5 are main items, item6 is trinket) - [ - participant['item0'], participant['item1'], participant['item2'], - participant['item3'], participant['item4'], participant['item5'] - ].compact.reject(&:zero?) - end - def parse_champion_mastery(response) - masteries = JSON.parse(response.body) - - masteries.map do |mastery| + JSON.parse(response.body).map do |mastery| { champion_id: mastery['championId'], champion_level: mastery['championLevel'], diff --git a/app/modules/scouting/controllers/players_controller.rb b/app/modules/scouting/controllers/players_controller.rb index 4b502e0..42f314d 100644 --- a/app/modules/scouting/controllers/players_controller.rb +++ b/app/modules/scouting/controllers/players_controller.rb @@ -12,7 +12,7 @@ class PlayersController < Api::V1::BaseController # Returns global scouting targets with optional watchlist filtering def index # Start with global scouting targets - targets = ScoutingTarget.includes(:scouting_watchlists) + targets = ScoutingTarget.all # Filter by watchlist if requested if params[:my_watchlist] == 'true' @@ -26,9 +26,14 @@ def index result = paginate(targets) + # Load only this org's watchlists for the paginated targets in one query + org_watchlists = current_organization.scouting_watchlists + .where(scouting_target_id: result[:data].map(&:id)) + .index_by(&:scouting_target_id) + # Serialize with watchlist context players_data = result[:data].map do |target| - watchlist = target.scouting_watchlists.find { |w| w.organization_id == current_organization.id } + watchlist = org_watchlists[target.id] JSON.parse(ScoutingTargetSerializer.render(target, watchlist: watchlist)) end @@ -115,6 +120,7 @@ def import_to_roster contract_end: params[:contract_end].present? ? Date.parse(params[:contract_end]) : nil, salary: params[:salary]&.to_d, jersey_number: params[:jersey_number]&.to_i, + line: params[:line], current_user: current_user ) @@ -148,6 +154,9 @@ def sync status: :service_unavailable) end + # Ordered list of tiers from lowest to highest for peak comparison. + TIER_ORDER = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER].freeze + private def require_management! @@ -201,16 +210,39 @@ def perform_sync_from_riot league_data = riot_service.get_league_entries_by_puuid(puuid: @target.riot_puuid, region: region) mastery_data = riot_service.get_champion_mastery(puuid: @target.riot_puuid, region: region) + pool = extract_champion_pool(mastery_data) + perf = PerformanceAggregator.new(riot_service: riot_service) + .call(puuid: @target.riot_puuid, region: region) || + @target.recent_performance || {} + tier = league_data[:solo_queue]&.dig(:tier) || @target.current_tier + lp = league_data[:solo_queue]&.dig(:lp) + strengths = derive_strengths(perf, pool, @target.role, tier) + weaknesses = derive_weaknesses(perf, pool, @target.role, tier) + + new_peak_tier, new_peak_rank = resolve_peak( + current_tier: tier, + current_lp: lp, + stored_peak_tier: @target.peak_tier, + stored_peak_rank: @target.peak_rank + ) + @target.update!( - # riot_summoner_id is no longer returned by Riot API summoner_name: "#{account_data[:game_name]}##{account_data[:tag_line]}", - current_tier: league_data[:solo_queue]&.dig(:tier), + current_tier: tier, current_rank: league_data[:solo_queue]&.dig(:rank), - current_lp: league_data[:solo_queue]&.dig(:lp), - champion_pool: extract_champion_pool(mastery_data), - performance_trend: calculate_performance_trend(league_data) + current_lp: lp, + peak_tier: new_peak_tier, + peak_rank: new_peak_rank, + champion_pool: pool, + recent_performance: perf, + performance_trend: calculate_performance_trend(league_data), + strengths: strengths, + weaknesses: weaknesses, + last_api_sync_at: Time.current ) + SeasonHistoryUpdater.call(target: @target, league_data: league_data) + watchlist = @target.scouting_watchlists.find_by(organization: current_organization) render_success( { scouting_target: JSON.parse(ScoutingTargetSerializer.render(@target, watchlist: watchlist)) }, @@ -240,34 +272,54 @@ def apply_filters(targets) end def apply_basic_filters(targets) - targets = targets.by_role(params[:role]) if params[:role].present? - targets = targets.by_status(params[:status]) if params[:status].present? + targets = apply_role_filter(targets) + targets = apply_status_filter(targets) targets = targets.by_region(params[:region]) if params[:region].present? + apply_watchlist_filters(targets) + end - # Filter by watchlist fields if in watchlist mode - if params[:my_watchlist] == 'true' - targets = targets.where(scouting_watchlists: { priority: params[:priority] }) if params[:priority].present? - if params[:assigned_to_id].present? - targets = targets.where(scouting_watchlists: { assigned_to_id: params[:assigned_to_id] }) - end + def apply_role_filter(targets) + return targets unless params[:role].present? + + # role param is comma-separated lowercase: "mid,top" -> ["mid", "top"] + roles = params[:role].split(',').map(&:strip).reject(&:blank?) + roles.any? ? targets.by_role(roles) : targets + end + + def apply_status_filter(targets) + if params[:status].present? + targets.by_status(params[:status]) + else + targets.where.not(status: 'signed') end + end + + def apply_watchlist_filters(targets) + return targets unless params[:my_watchlist] == 'true' + targets = targets.where(scouting_watchlists: { priority: params[:priority] }) if params[:priority].present? + if params[:assigned_to_id].present? + targets = targets.where(scouting_watchlists: { assigned_to_id: params[:assigned_to_id] }) + end targets end def apply_age_range_filter(targets) - return targets unless params[:age_range].present? && params[:age_range].is_a?(Array) + min_age = params[:age_min].presence&.to_i + max_age = params[:age_max].presence&.to_i + return targets unless min_age && max_age - min_age, max_age = params[:age_range] - min_age && max_age ? targets.where(age: min_age..max_age) : targets + targets.where(age: min_age..max_age) end def apply_rank_range_filter(targets) - return targets unless params[:rank_range].present? + min_lp = params[:lp_min].presence&.to_i + max_lp = params[:lp_max].presence&.to_i + return targets unless min_lp || max_lp - # Rank range filtering by LP - min_lp, max_lp = params[:rank_range] - min_lp && max_lp ? targets.where(current_lp: min_lp..max_lp) : targets + targets = targets.where('current_lp >= ?', min_lp) if min_lp + targets = targets.where('current_lp <= ?', max_lp) if max_lp + targets end def apply_search_filter(targets) @@ -367,33 +419,125 @@ def target_params ) end - # Extract top champions from mastery data + # Returns [peak_tier, peak_rank] β€” keeps the stored peak unless the current rank is provably higher. + # Master+ has no divisions so LP is the tiebreaker; below Master, roman numeral rank I > II > III > IV. + def resolve_peak(current_tier:, current_lp:, stored_peak_tier:, stored_peak_rank:) + return [current_tier, nil] if stored_peak_tier.blank? + + current_idx = TIER_ORDER.index(current_tier&.upcase) || 0 + stored_idx = TIER_ORDER.index(stored_peak_tier&.upcase) || 0 + + return [stored_peak_tier, stored_peak_rank] if current_idx < stored_idx + + if current_idx == stored_idx + # Same tier β€” for Master+ LP is the signal but we don't have stored peak LP here, + # so leave peak unchanged (it was set by a prior sync at equal or higher LP) + return [stored_peak_tier, stored_peak_rank] + end + + # current_idx > stored_idx β€” new tier is strictly higher + [current_tier, nil] + end + + # Thresholds calibrated by tier. Mirrors RosterManagementService#tier_thresholds. + # JSONB from DB returns string keys, so we use with_indifferent_access throughout. + def tier_thresholds(tier) + case tier&.upcase + when 'CHALLENGER', 'GRANDMASTER', 'MASTER' + { wr_strength: 53, wr_weakness: 49, kda_strength: 4.5, kda_weakness: 3.0, + cs_strength: 9.0, cs_weakness: 7.5, vision_strength: 45, vision_weakness: 28 } + when 'DIAMOND', 'EMERALD' + { wr_strength: 54, wr_weakness: 47, kda_strength: 4.0, kda_weakness: 2.5, + cs_strength: 8.5, cs_weakness: 7.0, vision_strength: 42, vision_weakness: 24 } + else + { wr_strength: 55, wr_weakness: 45, kda_strength: 3.5, kda_weakness: 2.0, + cs_strength: 8.0, cs_weakness: 6.0, vision_strength: 40, vision_weakness: 20 } + end + end + + def derive_strengths(perf, pool, role, tier = nil) + return [] if perf.blank? + + p = perf.with_indifferent_access + t = tier_thresholds(tier) + strengths = [] + strengths << 'Consistency' if scouting_consistent?(p, t) + strengths << 'Mechanical skill' if scouting_skilled?(p, t) + strengths << 'CS discipline' if scouting_good_cs?(p, role, t) + strengths << 'Map awareness' if scouting_good_vision?(p, role, t) + strengths << 'Team fighting' if p[:avg_kill_participation].to_f >= 65.0 + strengths << 'Champion pool depth' if pool.size >= 6 + strengths + end + + def derive_weaknesses(perf, pool, role, tier = nil) + return [] if perf.blank? + + p = perf.with_indifferent_access + t = tier_thresholds(tier) + [ + ('Inconsistent performance' if scouting_inconsistent?(p, t)), + ('Death management' if scouting_poor_kda?(p, t)), + ('CS discipline' if scouting_poor_cs?(p, role, t)), + ('Vision control' if scouting_poor_vision?(p, role, t)), + ('Limited champion pool' if pool.size < 3) + ].compact + end + + def non_support?(role) + role.to_s != 'support' + end + + def vision_role?(role) + %w[support jungle].include?(role.to_s) + end + + def scouting_poor_cs?(perf, role, thresholds) + non_support?(role) && + perf[:avg_cs_per_min].to_f.positive? && + perf[:avg_cs_per_min].to_f < thresholds[:cs_weakness] + end + + def scouting_poor_vision?(perf, role, thresholds) + vision_role?(role) && + perf[:avg_vision_score].to_f.positive? && + perf[:avg_vision_score].to_f < thresholds[:vision_weakness] + end + + def scouting_consistent?(perf, thresholds) + perf[:win_rate].to_f >= thresholds[:wr_strength] + end + + def scouting_skilled?(perf, thresholds) + perf[:avg_kda].to_f >= thresholds[:kda_strength] + end + + def scouting_good_cs?(perf, role, thresholds) + non_support?(role) && perf[:avg_cs_per_min].to_f >= thresholds[:cs_strength] + end + + def scouting_good_vision?(perf, role, thresholds) + vision_role?(role) && perf[:avg_vision_score].to_f >= thresholds[:vision_strength] + end + + def scouting_inconsistent?(perf, thresholds) + perf[:games_played].to_i >= 10 && perf[:win_rate].to_f < thresholds[:wr_weakness] + end + + def scouting_poor_kda?(perf, thresholds) + perf[:avg_kda].to_f.positive? && perf[:avg_kda].to_f < thresholds[:kda_weakness] + end + + # Extract top champions from mastery data using DataDragonService for full champion coverage. + # Falls back to "Champion_" only when Data Dragon is unreachable. def extract_champion_pool(mastery_data) return [] if mastery_data.blank? - # Get top 10 champions by mastery points - mastery_data.first(10).map do |mastery| - champion_id_to_name(mastery[:champion_id]) - end.compact - end - - # Simple champion ID to name mapping (top champions) - def champion_id_to_name(champion_id) - # This is a simplified mapping - in production you'd want a complete mapping - # or fetch from Data Dragon API - champion_map = { - 1 => 'Annie', 2 => 'Olaf', 3 => 'Galio', 4 => 'Twisted Fate', - 5 => 'Xin Zhao', 6 => 'Urgot', 7 => 'LeBlanc', 8 => 'Vladimir', - 9 => 'Fiddlesticks', 10 => 'Kayle', 11 => 'Master Yi', 12 => 'Alistar', - 13 => 'Ryze', 14 => 'Sion', 15 => 'Sivir', 16 => 'Soraka', - 17 => 'Teemo', 18 => 'Tristana', 19 => 'Warwick', 20 => 'Nunu', - 21 => 'Miss Fortune', 22 => 'Ashe', 23 => 'Tryndamere', 24 => 'Jax', - 25 => 'Morgana', 26 => 'Zilean', 27 => 'Singed', 28 => 'Evelynn', - 29 => 'Twitch', 30 => 'Karthus', 31 => 'Cho\'Gath', 32 => 'Amumu', - 33 => 'Rammus', 34 => 'Anivia', 35 => 'Shaco', 36 => 'Dr. Mundo' - # Add more as needed or fetch from Data Dragon - } - champion_map[champion_id] || "Champion_#{champion_id}" + id_map = DataDragonService.new.champion_id_map + + mastery_data.first(10).filter_map do |mastery| + id_map[mastery[:champion_id].to_i] + end end # Calculate performance trend based on win/loss ratio diff --git a/app/modules/scouting/jobs/sync_scouting_target_job.rb b/app/modules/scouting/jobs/sync_scouting_target_job.rb index bde11e0..6491d22 100644 --- a/app/modules/scouting/jobs/sync_scouting_target_job.rb +++ b/app/modules/scouting/jobs/sync_scouting_target_job.rb @@ -22,6 +22,7 @@ def perform(scouting_target_id, organization_id) sync_account_name!(target, riot_service) sync_league_entries!(target, riot_service) sync_mastery_data!(target, riot_service) + sync_recent_performance!(target, riot_service) target.update!(last_sync_at: Time.current) Rails.logger.info("Successfully synced scouting target #{target.id}") @@ -111,6 +112,7 @@ def update_rank_info(target, league_data) end target.update!(update_attributes) if update_attributes.present? + SeasonHistoryUpdater.call(target: target, league_data: league_data) end def update_champion_pool(target, mastery_data) @@ -125,5 +127,11 @@ def update_champion_pool(target, mastery_data) def load_champion_id_map DataDragonService.new.champion_id_map end + + def sync_recent_performance!(target, riot_service) + perf = PerformanceAggregator.new(riot_service: riot_service) + .call(puuid: target.riot_puuid, region: target.region) + target.update!(recent_performance: perf) if perf + end end end diff --git a/app/modules/scouting/serializers/scouting_target_serializer.rb b/app/modules/scouting/serializers/scouting_target_serializer.rb index 22af8a2..e93b18e 100644 --- a/app/modules/scouting/serializers/scouting_target_serializer.rb +++ b/app/modules/scouting/serializers/scouting_target_serializer.rb @@ -24,6 +24,7 @@ class ScoutingTargetSerializer < Blueprinter::Base end fields :real_name, :avatar_url, :profile_icon_id fields :peak_tier, :peak_rank, :last_api_sync_at + fields :season_history # Computed fields field :status_text do |target| diff --git a/app/modules/scouting/services/performance_aggregator.rb b/app/modules/scouting/services/performance_aggregator.rb new file mode 100644 index 0000000..7b03035 --- /dev/null +++ b/app/modules/scouting/services/performance_aggregator.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Fetches recent match history for a scouting target and aggregates +# per-champion and overall performance stats. +# +# Used by both SyncScoutingTargetJob (background) and the inline sync +# action in Scouting::PlayersController (synchronous response). +class PerformanceAggregator + MATCH_COUNT = 20 + + def initialize(riot_service:) + @riot = riot_service + end + + # Returns a hash ready to be stored in target.recent_performance. + # Returns nil if the PUUID is missing or no match data is available. + def call(puuid:, region:) + return nil if puuid.blank? + + match_ids = @riot.get_match_history(puuid: puuid, region: region, count: MATCH_COUNT) + return nil if match_ids.empty? + + stats = collect_stats(match_ids, puuid, region) + return nil if stats.empty? + + build_summary(stats) + rescue RiotApiService::RiotApiError => e + Rails.logger.warn("[PerformanceAggregator] Skipping match fetch: #{e.message}") + nil + end + + private + + def collect_stats(match_ids, puuid, region) + match_ids.filter_map do |match_id| + details = @riot.get_match_details(match_id: match_id, region: region) + details[:participants].find { |p| p[:puuid] == puuid } + rescue RiotApiService::RiotApiError => e + Rails.logger.warn("[PerformanceAggregator] Could not fetch #{match_id}: #{e.message}") + nil + end + end + + def build_summary(stats) + aggregate_overall(stats).merge( + champion_pool_stats: aggregate_per_champion(stats), + matches_analyzed: stats.size + ) + end + + def aggregate_overall(stats) + totals = sum_stats(stats) + wins = stats.count { |p| p[:win] } + total = stats.size + + overall_hash(totals, wins, total) + end + + def overall_hash(totals, wins, total) # rubocop:disable Metrics/AbcSize + { + games_played: total, + win_rate: (wins.to_f / total * 100).round(1), + avg_kda: kda_ratio(totals[:kills], totals[:deaths], totals[:assists], total).round(2), + avg_kills: (totals[:kills].to_f / total).round(1), + avg_deaths: (totals[:deaths].to_f / total).round(1), + avg_assists: (totals[:assists].to_f / total).round(1), + avg_vision_score: (totals[:vision].to_f / total).round(1), + avg_cs_per_min: (totals[:cs].to_f / total).round(1) + } + end + + def aggregate_per_champion(stats) + stats.group_by { |p| p[:champion_name] } + .map { |champion, games| champion_row(champion, games) } + .sort_by { |c| -c[:games] } + end + + def champion_row(champion, games) # rubocop:disable Metrics/AbcSize + totals = sum_stats(games) + wins = games.count { |p| p[:win] } + total = games.size + + { + champion: champion, + games: total, + wins: wins, + winrate: (wins.to_f / total * 100).round(1), + kda_ratio: kda_ratio(totals[:kills], totals[:deaths], totals[:assists], total).round(2), + avg_kills: (totals[:kills].to_f / total).round(1), + avg_deaths: (totals[:deaths].to_f / total).round(1), + avg_assists: (totals[:assists].to_f / total).round(1), + avg_cs_per_min: (totals[:cs].to_f / total).round(1) + } + end + + def sum_stats(stats) + { + kills: stats.sum { |p| p[:kills].to_i }, + deaths: stats.sum { |p| p[:deaths].to_i }, + assists: stats.sum { |p| p[:assists].to_i }, + vision: stats.sum { |p| p[:vision_score].to_i }, + cs: stats.sum { |p| p[:minions_killed].to_i + p[:neutral_minions_killed].to_i } + } + end + + def kda_ratio(kills, deaths, assists, total) + avg_deaths = deaths.to_f / total + return (kills + assists).to_f / total if avg_deaths.zero? + + (kills + assists).to_f / deaths + end +end diff --git a/app/modules/scouting/services/season_history_updater.rb b/app/modules/scouting/services/season_history_updater.rb new file mode 100644 index 0000000..095e4b2 --- /dev/null +++ b/app/modules/scouting/services/season_history_updater.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Maintains a cumulative season history on a scouting target. +# +# Each call records the current season's ranked stats (wins, losses, tier, LP). +# If an entry for the current season already exists it is updated in place. +# Older entries are preserved so history accumulates across syncs. +# +# Season numbering follows Riot's convention: Season N = year - 2010 +# (2024=S14, 2025=S15, 2026=S16, …) +class SeasonHistoryUpdater + def self.call(target:, league_data:) + new(target: target, league_data: league_data).call + end + + def initialize(target:, league_data:) + @target = target + @league_data = league_data + end + + def call + solo = @league_data[:solo_queue] + return unless solo.present? + + entry = build_entry(solo) + history = (@target.season_history || []).map(&:symbolize_keys) + + existing_idx = history.find_index { |e| e[:season] == entry[:season] } + if existing_idx + history[existing_idx] = entry + else + history.unshift(entry) + end + + @target.update!(season_history: history) + end + + private + + def build_entry(solo) + wins = solo[:wins].to_i + losses = solo[:losses].to_i + total = wins + losses + wr = total.positive? ? (wins.to_f / total * 100).round(1) : nil + + { + season: current_season_label, + tier: solo[:tier], + rank: solo[:rank], + lp: solo[:lp].to_i, + wins: wins, + losses: losses, + win_rate: wr, + date: Time.current.to_date.iso8601 + } + end + + def current_season_label + "S#{Time.current.year - 2010}" + end +end diff --git a/app/modules/scrims/controllers/lobby_controller.rb b/app/modules/scrims/controllers/lobby_controller.rb index 5854df8..956784d 100644 --- a/app/modules/scrims/controllers/lobby_controller.rb +++ b/app/modules/scrims/controllers/lobby_controller.rb @@ -5,54 +5,86 @@ module Controllers # LobbyController # # Public scrim feed β€” no authentication required. - # Only exposes scrims from organizations that opted into public visibility. + # Merges two sources: + # 1. Scrim records with visibility: 'public' + # 2. AvailabilityWindow records from public orgs (converted to next-occurrence slots) + # + # Security invariants: + # - Both sources require organizations.is_public = true + # - Windows use the .active scope (validates expires_at server-side) + # - No sensitive fields are serialized (no email, no subscription_plan, no internal config) + # - All query params validated against strict allowlists before reaching the DB + # - Queries are hard-capped before in-memory merge to bound memory usage class LobbyController < Api::V1::BaseController skip_before_action :authenticate_request! - ALLOWED_GAMES = %w[league_of_legends valorant cs2 dota2].freeze + ALLOWED_GAMES = %w[league_of_legends valorant cs2 dota2].freeze ALLOWED_REGIONS = %w[BR NA EUW EUNE LAN LAS OCE KR JP TR RU].freeze + # Hard caps β€” prevent unbounded in-memory merge regardless of DB size + SCRIM_CAP = 200 + WINDOW_CAP = 100 + # GET /api/v1/scrims/lobby - # Public feed of open scrims β€” no auth required def index + game = ALLOWED_GAMES.include?(params[:game]) ? params[:game] : nil + region = ALLOWED_REGIONS.include?(params[:region].to_s.upcase) ? params[:region].upcase : nil + + scrim_entries = fetch_scrim_entries(game: game, region: region) + window_entries = fetch_window_entries(game: game, region: region, + exclude_org_ids: scrim_entries.to_set { |e| e[:organization][:id] }) + + combined = (scrim_entries + window_entries).sort_by { |e| e[:scheduled_at].to_s } + paginated = paginate_array(combined) + + render json: { data: { scrims: paginated[:data], pagination: paginated[:pagination] } }, status: :ok + end + + private + + # ── Source 1: actual Scrim records ──────────────────────────────────────── + + def fetch_scrim_entries(game:, region:) scrims = Scrim.unscoped .eager_load(:organization) - .includes(:opponent_team, organization: :players) + .includes(:opponent_team) .where(scrims: { visibility: 'public' }) .where(organizations: { is_public: true }) .where('scrims.scheduled_at >= ?', Time.current) .order('scrims.scheduled_at ASC') + .limit(SCRIM_CAP) - scrims = scrims.where(game: params[:game]) if params[:game].present? && ALLOWED_GAMES.include?(params[:game]) + scrims = scrims.where(scrims: { game: game }) if game + scrims = scrims.where(organizations: { region: region }) if region + scrims = filter_by_tier(scrims, params[:tier]) if params[:tier].present? - if params[:region].present? && ALLOWED_REGIONS.include?(params[:region].upcase) - scrims = scrims.where(organizations: { region: params[:region].upcase }) - end + records = scrims.to_a + players_by_org = load_public_players(records.map { |s| s.organization_id }) + records.map { |s| serialize_lobby_scrim(s, players_by_org) } + end - scrims = filter_by_tier(scrims, params[:tier]) if params[:tier].present? + # ── Source 2: AvailabilityWindow records β†’ next occurrence ─────────────── - result = paginate(scrims) + def fetch_window_entries(game:, region:, exclude_org_ids:) + windows = AvailabilityWindow.unscoped + .active # active=true AND (expires_at IS NULL OR expires_at > now) + .joins(:organization) + .where(organizations: { is_public: true }) + .where.not(organization_id: exclude_org_ids.to_a) + .includes(:organization) + .limit(WINDOW_CAP) - render json: { - data: { - scrims: result[:data].map { |s| serialize_lobby_scrim(s) }, - pagination: result[:pagination] - } - }, status: :ok - end + windows = windows.where(availability_windows: { game: game }) if game + windows = windows.where(availability_windows: { region: region }) if region - private - - def filter_by_tier(scrims, tier) - tier_plans = case tier - when 'professional' then %w[professional enterprise] - when 'semi_pro' then %w[semi_pro] - else %w[free amateur] - end - scrims.where(organizations: { subscription_plan: tier_plans }) + records = windows.to_a + players_by_org = load_public_players(records.map { |w| w.organization_id }) + records.filter_map { |w| serialize_lobby_window(w, players_by_org) } end - def serialize_lobby_scrim(scrim) + # ── Serializers ─────────────────────────────────────────────────────────── + + def serialize_lobby_scrim(scrim, players_by_org) org = scrim.organization { id: scrim.id, @@ -62,27 +94,51 @@ def serialize_lobby_scrim(scrim) games_planned: scrim.games_planned, status: scrim.status, source: scrim.try(:source) || 'internal', - organization: { - id: org.id, - name: org.name, - slug: org.slug, - region: org.region, - tier: org.try(:tier), - public_tagline: org.try(:public_tagline), - discord_invite_url: org.try(:discord_invite_url), - roster: serialize_org_roster(org) - } + organization: serialize_org(org, players_by_org[org.id] || []) } end - # Returns the org's active players sorted by role, already preloaded via includes. + # Returns nil if next_occurrence cannot be computed β€” filter_map drops nils. + def serialize_lobby_window(window, players_by_org) + occurs_at = next_occurrence(window) + return nil unless occurs_at + + org = window.organization + { + id: "window-#{window.id}", # namespaced to avoid collision with Scrim IDs + scheduled_at: occurs_at, + scrim_type: 'practice', + focus_area: window.focus_area, + games_planned: 3, + status: 'open', + source: 'availability_window', + organization: serialize_org(org, players_by_org[org.id] || []) + } + end + + # Only expose fields safe for public consumption. + # Notably absent: email, subscription_plan, is_public, internal config. + def serialize_org(org, players) + { + id: org.id, + name: org.name, + slug: org.slug, + region: org.region, + tier: org.try(:tier), + public_tagline: org.try(:public_tagline), + discord_invite_url: org.try(:discord_invite_url), + roster: serialize_org_roster(players) + } + end + + # Players are preloaded via load_public_players β€” no association traversal here. # Capped at 10 to keep the response lean. - def serialize_org_roster(org) + def serialize_org_roster(players) role_sort = %w[top jungle mid adc support] - players = org.players.select(&:active?) - players.sort_by { |p| [role_sort.index(p.role) || 99, p.summoner_name] } - .first(10) - .map do |p| + active = players.select { |p| p.status == 'active' && p.deleted_at.nil? } + active.sort_by { |p| [role_sort.index(p.role) || 99, p.summoner_name.to_s] } + .first(10) + .map do |p| { summoner_name: p.summoner_name, role: p.role, @@ -91,6 +147,68 @@ def serialize_org_roster(org) } end end + + # ── Helpers ─────────────────────────────────────────────────────────────── + + # Loads players for the given org_ids bypassing OrganizationScoped, since + # this is a public endpoint with no authenticated user. Returns a Hash + # keyed by organization_id (UUID string) for O(1) lookup in serializers. + def load_public_players(org_ids) + return {} if org_ids.empty? + + Player.unscoped + .where(organization_id: org_ids, deleted_at: nil) + .select(:id, :organization_id, :summoner_name, :role, + :solo_queue_tier, :solo_queue_rank, :status, :deleted_at) + .group_by(&:organization_id) + end + + def filter_by_tier(scrims, tier) + tier_plans = case tier + when 'professional' then %w[professional enterprise] + when 'semi_pro' then %w[semi_pro] + else %w[free amateur] + end + scrims.where(organizations: { subscription_plan: tier_plans }) + end + + # Computes the next calendar occurrence of a recurring window from now. + # If today matches day_of_week but the window already ended, advances 7 days. + # Returns nil on any error so the entry is safely dropped via filter_map. + def next_occurrence(window) + tz_name = window.timezone.presence || 'UTC' + tz = ActiveSupport::TimeZone[tz_name] || ActiveSupport::TimeZone['UTC'] + now = Time.current.in_time_zone(tz) + + days_ahead = (window.day_of_week - now.wday) % 7 + days_ahead = 7 if days_ahead.zero? && now.hour >= window.end_hour + + target = now.to_date + days_ahead + tz.local(target.year, target.month, target.day, window.start_hour, 0, 0) + rescue ArgumentError, TZInfo::InvalidTimezone, TZInfo::AmbiguousTime + nil + end + + # Manual pagination for the in-memory merged array. + def paginate_array(array) + per_page = params[:per_page].to_i.clamp(20, 50) + page = [params[:page].to_i, 1].max + total_count = array.size + total_pages = [(total_count.to_f / per_page).ceil, 1].max + slice = array.slice((page - 1) * per_page, per_page) || [] + + { + data: slice, + pagination: { + current_page: page, + per_page: per_page, + total_pages: total_pages, + total_count: total_count, + has_next_page: page < total_pages, + has_prev_page: page > 1 + } + } + end end end end diff --git a/app/modules/search/controllers/search_controller.rb b/app/modules/search/controllers/search_controller.rb index 228b8f9..f1f7ef8 100644 --- a/app/modules/search/controllers/search_controller.rb +++ b/app/modules/search/controllers/search_controller.rb @@ -28,7 +28,12 @@ def index per_page = params[:per_page].to_i.clamp(1, MAX_PER_PAGE) per_page = 20 if params[:per_page].blank? - results = SearchService.global(query: query, types: types, per_page: per_page) + results = SearchService.global( + query: query, + types: types, + per_page: per_page, + organization_id: current_organization&.id + ) render_success({ query: query, diff --git a/app/modules/search/services/search_service.rb b/app/modules/search/services/search_service.rb index c906ee9..1cfa6fe 100644 --- a/app/modules/search/services/search_service.rb +++ b/app/modules/search/services/search_service.rb @@ -2,6 +2,9 @@ # Centralizes all Meilisearch queries, supporting global multi-index search # and scoped per-model queries with optional organization filtering. +# +# When Meilisearch is unavailable the global search degrades gracefully by +# falling back to a PostgreSQL ILIKE query on models that support it. class SearchService # Models exposed to global search, keyed by the string callers pass in `types` INDEXES = { @@ -12,17 +15,32 @@ class SearchService 'support_faqs' => SupportFaq }.freeze + # Models that have both an `organization_id` column and a `name`-like column + # suitable for the postgres_fallback query. Only Player has both. + # ScoutingTarget lacks organization_id; Organization/SupportFaq lack the + # scoping we need for multi-tenant safety. + POSTGRES_FALLBACK_MODELS = { + 'players' => Player + }.freeze + # ── Global multi-index search ───────────────────────────────────── # - # @param query [String] search term - # @param types [Array] limit to these indexes (nil = all) - # @param per_page [Integer] hits per index (default 20) + # @param query [String] search term + # @param types [Array] limit to these indexes (nil = all) + # @param per_page [Integer] hits per index (default 20) + # @param organization_id [String, nil] UUID used by the postgres fallback # @return [Hash] { "players" => [...hits...], "organizations" => [...], ... } - def self.global(query:, types: nil, per_page: 20) - return {} if query.blank? || !meilisearch_available? + def self.global(query:, types: nil, per_page: 20, organization_id: nil) + return {} if query.blank? + + if meilisearch_available? + target = types.present? ? INDEXES.slice(*Array(types)) : INDEXES + return target.transform_values { |model| search_hits(model, query, per_page) } + end + + return {} if organization_id.blank? - target = types.present? ? INDEXES.slice(*Array(types)) : INDEXES - target.transform_values { |model| search_hits(model, query, per_page) } + fallback_global(query: query, types: types, organization_id: organization_id) end # ── Single-model scope search ───────────────────────────────────── @@ -56,6 +74,23 @@ def self.scope(model_class, query:, filters: {}, limit: 200) nil end + # ── PostgreSQL fallback ─────────────────────────────────────────── + # + # Used when Meilisearch is unavailable. Only works for models that have + # both `organization_id` and a `summoner_name`/`name` column. + # + # @param model_class [Class] ActiveRecord model + # @param query [String] search term (will be SQL-escaped) + # @param organization_id [String] UUID to scope the query + # @return [ActiveRecord::Relation] + def self.postgres_fallback(model_class, query:, organization_id:) + sanitized = ActiveRecord::Base.sanitize_sql_like(query) + model_class + .where(organization_id: organization_id) + .where('name ILIKE ?', "%#{sanitized}%") + .limit(20) + end + # ── Private helpers ─────────────────────────────────────────────── private_class_method def self.meilisearch_available? MEILISEARCH_CLIENT.present? @@ -74,4 +109,25 @@ def self.scope(model_class, query:, filters: {}, limit: 200) private_class_method def self.build_filter(filters) filters.map { |k, v| "#{k} = #{v.to_s.inspect}" }.join(' AND ') end + + # Executes PostgreSQL ILIKE fallback for models in POSTGRES_FALLBACK_MODELS. + # Returns a hash of arrays compatible with the normal global response shape. + # + # @param query [String] + # @param types [Array, nil] + # @param organization_id [String] + # @return [Hash] + private_class_method def self.fallback_global(query:, types:, organization_id:) + target = types.present? ? POSTGRES_FALLBACK_MODELS.slice(*Array(types)) : POSTGRES_FALLBACK_MODELS + target.transform_values do |model| + sanitized = ActiveRecord::Base.sanitize_sql_like(query) + model + .where(organization_id: organization_id) + .where('summoner_name ILIKE ?', "%#{sanitized}%") + .limit(20) + end + rescue StandardError => e + Rails.logger.warn "[SearchService] PostgreSQL fallback failed: #{e.message}" + {} + end end diff --git a/app/modules/strategy/controllers/draft_simulations_controller.rb b/app/modules/strategy/controllers/draft_simulations_controller.rb new file mode 100644 index 0000000..110b9fb --- /dev/null +++ b/app/modules/strategy/controllers/draft_simulations_controller.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module Strategy + module Controllers + # Draft Simulations Controller + # Manages live draft simulator state per series (multi-game BO3/BO5) + class DraftSimulationsController < Api::V1::BaseController + before_action :set_draft_simulation, only: %i[update destroy] + + # GET /api/v1/strategy/draft-simulations + def list + series = organization_scoped(DraftSimulation) + .select(:series_id, :team1_name, :team2_name, :patch, :league, :fearless, :created_at, + :blue_picks, :red_picks, :blue_bans, :red_bans) + .order(created_at: :desc) + .group_by(&:series_id) + .map { |series_id, games| build_series_summary(series_id, games) } + + render_success({ series: series }) + end + + # GET /api/v1/strategy/draft-simulations/:series_id + def index + simulations = organization_scoped(DraftSimulation).for_series(params[:series_id]) + + render_success({ + draft_simulations: simulations.as_json + }) + end + + # POST /api/v1/strategy/draft-simulations + def create + simulation = organization_scoped(DraftSimulation).new(create_params) + simulation.organization = current_organization + + if simulation.save + render_created({ + draft_simulation: simulation.as_json + }, message: 'Draft simulation created successfully') + else + render_error( + message: 'Failed to create draft simulation', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: simulation.errors.as_json + ) + end + end + + # PATCH /api/v1/strategy/draft-simulations/:id + def update + if @draft_simulation.update(update_params) + render_updated({ + draft_simulation: @draft_simulation.as_json + }) + else + render_error( + message: 'Failed to update draft simulation', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @draft_simulation.errors.as_json + ) + end + end + + # DELETE /api/v1/strategy/draft-simulations/:id + def destroy + if @draft_simulation.destroy + render_deleted(message: 'Draft simulation deleted successfully') + else + render_error( + message: 'Failed to delete draft simulation', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + # DELETE /api/v1/strategy/draft-simulations/series/:series_id + def destroy_series + simulations = organization_scoped(DraftSimulation).where(series_id: params[:series_id]) + return render_error(message: 'Series not found', code: 'NOT_FOUND', status: :not_found) if simulations.empty? + + simulations.destroy_all + render_deleted(message: 'Series deleted successfully') + end + + private + + def set_draft_simulation + @draft_simulation = organization_scoped(DraftSimulation).find(params[:id]) + end + + def build_series_summary(series_id, games) + first = games.first + total_picks = games.sum { |g| Array(g.blue_picks).size + Array(g.red_picks).size } + total_bans = games.sum { |g| Array(g.blue_bans).size + Array(g.red_bans).size } + + { + series_id: series_id, + team1_name: first.team1_name, + team2_name: first.team2_name, + patch: first.patch, + league: first.league, + fearless: first.fearless, + game_count: games.size, + total_picks: total_picks, + total_bans: total_bans, + created_at: first.created_at + } + end + + def create_params + params.require(:draft_simulation).permit( + :series_id, + :patch, + :league, + :our_side, + :team1_name, + :team2_name, + :fearless, + fearless_used: {} + ) + end + + def update_params + params.require(:draft_simulation).permit( + :game_number, + :done, + :fearless_used, + blue_bans: [], + red_bans: [], + blue_picks: [], + red_picks: [], + fearless_used: {} + ) + end + end + end +end diff --git a/app/modules/strategy/controllers/tactical_boards_controller.rb b/app/modules/strategy/controllers/tactical_boards_controller.rb index 476db5c..c3f661a 100644 --- a/app/modules/strategy/controllers/tactical_boards_controller.rb +++ b/app/modules/strategy/controllers/tactical_boards_controller.rb @@ -138,61 +138,68 @@ def apply_sorting(boards) boards.order(sort_by => sort_order) end - def tactical_board_params # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - # Support both nested format (tactical_board: {map_state:...}) and flat format (name:..., board_state:...) + def tactical_board_params # Always prefer the nested tactical_board hash when present β€” even partial updates # (e.g. map_state only, no title) must read from tb, not from top-level params. - # The previous check `tb[:title].present? || tb[:name].present?` fell back to - # top-level params whenever an update omitted the title field, causing update({}) - # to be called silently and saving nothing despite returning 200 OK. - tb = params[:tactical_board] - source = tb.present? ? tb : params + # Falling back to top-level params only when no tactical_board key is sent at all. + source = params[:tactical_board].present? ? params[:tactical_board] : params + permitted = build_base_params(source) + merge_map_state(permitted, source) + merge_annotations(permitted, source) + merge_champion_selections(permitted, source) + permitted + end - permitted = { + def build_base_params(source) + { title: source[:title] || source[:name], match_id: source[:match_id], scrim_id: source[:scrim_id], game_time: source[:game_time] }.compact + end - # Accept map_state or board_state + def merge_map_state(permitted, source) map = source[:map_state] || source[:board_state] permitted[:map_state] = map.as_json if map.present? + end - # Accept annotations + def merge_annotations(permitted, source) permitted[:annotations] = source[:annotations].as_json if source[:annotations].present? + end - # Merge champion_selections into map_state.players. - # board_state (already in permitted[:map_state]) carries the authoritative positions - # from the rendered canvas (drag results). champion_selections carries identity - # (champion name, role). For each slot, use: - # 1. x/y from champion_selection if explicitly provided - # 2. x/y from board_state.players[i] as fallback (preserves drag position) - # 3. 50 as last resort default + def merge_champion_selections(permitted, source) selections = source[:champion_selections] - if selections.present? && selections.is_a?(Array) - existing_players = permitted.dig(:map_state, 'players') || [] - - permitted[:map_state] ||= { 'players' => [] } - permitted[:map_state]['players'] = selections.map.with_index do |cs, idx| - existing = existing_players[idx] || {} - - # board_state (existing[]) represents the live canvas after a drag β€” it - # always wins for position. champion_selections x/y is only a fallback - # for the initial placement when board_state has no entry yet. - cs_x = cs[:x].nil? ? cs['x'] : cs[:x] - cs_y = cs[:y].nil? ? cs['y'] : cs[:y] - - { - 'champion' => cs[:champion] || cs['champion'] || existing['champion'], - 'role' => cs[:role] || cs['role'] || existing['role'], - 'x' => (existing['x'] || cs_x || 50).to_f, - 'y' => (existing['y'] || cs_y || 50).to_f - } - end + return unless selections.present? && selections.is_a?(Array) + + existing_players = permitted.dig(:map_state, 'players') || [] + permitted[:map_state] ||= { 'players' => [] } + permitted[:map_state]['players'] = build_player_slots(selections, existing_players) + end + + def build_player_slots(selections, existing_players) + selections.map.with_index do |selection, idx| + existing = existing_players[idx] || {} + build_player_slot(selection, existing) end + end - permitted + # board_state (existing) represents the live canvas after a drag β€” it + # always wins for position. champion_selections x/y is only a fallback + # for the initial placement when board_state has no entry yet. + def build_player_slot(selection, existing) + sel_x = selection[:x].nil? ? selection['x'] : selection[:x] + sel_y = selection[:y].nil? ? selection['y'] : selection[:y] + { + 'champion' => fetch_first_value(selection[:champion], selection['champion'], existing['champion']), + 'role' => fetch_first_value(selection[:role], selection['role'], existing['role']), + 'x' => (existing['x'] || sel_x || 50).to_f, + 'y' => (existing['y'] || sel_y || 50).to_f + } + end + + def fetch_first_value(*candidates) + candidates.find { |val| !val.nil? } end end end diff --git a/app/modules/strategy/models/draft_simulation.rb b/app/modules/strategy/models/draft_simulation.rb new file mode 100644 index 0000000..6060cfa --- /dev/null +++ b/app/modules/strategy/models/draft_simulation.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# DraftSimulation model +# Stores live draft simulator state per game within a series +# series_id is a nanoid generated on the frontend; each game in the series is a separate record +class DraftSimulation < ApplicationRecord + # Concerns + include OrganizationScoped + + # Associations + belongs_to :organization + + # Validations + validates :series_id, presence: true + validates :game_number, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :our_side, inclusion: { in: %w[blue red] }, allow_nil: true + + # Scopes + scope :for_series, ->(series_id) { where(series_id: series_id).order(:game_number) } +end diff --git a/app/modules/team_goals/models/team_goal.rb b/app/modules/team_goals/models/team_goal.rb index 504367c..91f9b6a 100644 --- a/app/modules/team_goals/models/team_goal.rb +++ b/app/modules/team_goals/models/team_goal.rb @@ -169,6 +169,12 @@ def mark_as_completed! progress: 100, current_value: target_value ) + Events::EventPublisher.publish( + user_id: created_by_id || assigned_to_id || organization.users.first&.id || 'system', + org_id: organization_id, + type: 'team_goal.completed', + payload: { goal_id: id, title: title, player_id: player_id } + ) end def mark_as_failed! @@ -183,6 +189,12 @@ def update_progress!(new_current_value) self.current_value = new_current_value calculate_progress_if_needed save! + Events::EventPublisher.publish( + user_id: created_by_id || assigned_to_id || organization.users.first&.id || 'system', + org_id: organization_id, + type: 'team_goal.progress_updated', + payload: { goal_id: id, title: title, progress: progress, current_value: current_value } + ) end def assigned_to_name diff --git a/app/modules/tournaments/channels/tournament_channel.rb b/app/modules/tournaments/channels/tournament_channel.rb new file mode 100644 index 0000000..f42fc54 --- /dev/null +++ b/app/modules/tournaments/channels/tournament_channel.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# TournamentChannel β€” Real-time match status updates for a tournament. +# +# Broadcasts match state changes (checkin, score, status transitions, WO) to all +# subscribers watching a specific tournament. No auth required for read β€” subscription +# is open so spectators and participants can both follow live. +# +# Subscription params: +# tournament_id [String] β€” UUID of the tournament to subscribe to +# +# Broadcast payload (from MatchConfirmationService, TournamentWalkoverJob, etc.): +# { +# match_id: "uuid", +# status: "in_progress" | "awaiting_report" | "confirmed" | "walkover" | ..., +# team_a_score: 0, +# team_b_score: 0, +# updated_at: "2026-04-11T12:00:00Z", +# event: "checkin" | "report" | "confirmed" | "walkover" (optional) +# } +# +# @example Frontend subscription +# consumer.subscriptions.create( +# { channel: 'TournamentChannel', tournament_id: 'uuid' }, +# { received: (data) => console.log(data) } +# ) +class TournamentChannel < ApplicationCable::Channel + def subscribed + tournament_id = params[:tournament_id] + + unless tournament_id.present? && Tournament.exists?(id: tournament_id) + reject + return + end + + stream_from "tournament_#{tournament_id}" + logger.info "[TournamentChannel] subscribed user=#{current_user&.id || 'anon'} tournament=#{tournament_id}" + end + + def unsubscribed + stop_all_streams + end +end diff --git a/app/modules/tournaments/controllers/match_reports_controller.rb b/app/modules/tournaments/controllers/match_reports_controller.rb new file mode 100644 index 0000000..95f4bc0 --- /dev/null +++ b/app/modules/tournaments/controllers/match_reports_controller.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module Tournaments + module Controllers + # Match result reporting with dual-validation flow. + # + # GET /api/v1/tournaments/:tournament_id/matches/:match_id/report β€” report status + # POST /api/v1/tournaments/:tournament_id/matches/:match_id/report β€” submit report + # POST /api/v1/tournaments/:tournament_id/matches/:match_id/report/admin_resolve β€” admin resolves dispute + class MatchReportsController < Api::V1::BaseController + before_action :set_tournament + before_action :set_match + before_action :set_my_team, only: %i[show create] + before_action :require_admin!, only: %i[admin_resolve] + + # GET /api/v1/tournaments/:tournament_id/matches/:match_id/report + def show + my_report = @match.match_reports.find_by(tournament_team: @my_team) + opponent_team = opponent_of(@my_team) + opponent_report = opponent_team ? @match.match_reports.find_by(tournament_team: opponent_team) : nil + + render_success({ + match_status: @match.status, + my_report: MatchReportSerializer.new(my_report).as_json, + opponent_reported: opponent_report&.submitted? || false, + # Only expose opponent scores after both have reported (no oracle attack) + opponent_report: both_reported? ? MatchReportSerializer.new(opponent_report).as_json : nil, + deadline_at: my_report&.deadline_at&.iso8601 || 2.hours.from_now.iso8601 + }) + end + + # POST /api/v1/tournaments/:tournament_id/matches/:match_id/report + def create + result = MatchConfirmationService.new( + match: @match, + team: @my_team, + user: current_user, + team_a_score: params[:team_a_score], + team_b_score: params[:team_b_score], + evidence_url: params[:evidence_url] + ).call + + if result[:status] == :error + render_error(message: result[:message], code: 'VALIDATION_ERROR', status: :unprocessable_entity) + else + render_success({ + status: result[:status], + report: MatchReportSerializer.new(result[:report]).as_json, + message: status_message(result[:status]) + }) + end + end + + # POST /api/v1/tournaments/:tournament_id/matches/:match_id/report/admin_resolve + def admin_resolve + unless @match.disputed? + return render_error( + message: "Match is not in a disputed state (status: #{@match.status})", + code: 'NOT_DISPUTED', + status: :unprocessable_entity + ) + end + + winner_id = params[:winner_team_id] + winner = @match.team_a_id == winner_id ? @match.team_a : @match.team_b + loser = winner == @match.team_a ? @match.team_b : @match.team_a + + unless winner + return render_error(message: 'Invalid winner_team_id', code: 'INVALID_PARAMS', status: :unprocessable_entity) + end + + ActiveRecord::Base.transaction do + @match.match_reports.update_all(status: 'confirmed', confirmed_at: Time.current) + @match.update!( + team_a_score: params[:team_a_score] || @match.team_a_score, + team_b_score: params[:team_b_score] || @match.team_b_score, + status: 'confirmed' + ) + BracketProgressionService.new(@match, winner: winner, loser: loser).call + end + + render_success({ resolved: true, winner_team_id: winner.id }) + end + + private + + def set_tournament + @tournament = Tournament.find_by(id: params[:tournament_id]) + render_error(message: 'Tournament not found', code: 'NOT_FOUND', status: :not_found) unless @tournament + end + + def set_match + @match = @tournament.tournament_matches + .includes(:team_a, :team_b, :match_reports) + .find_by(id: params[:match_id]) + render_error(message: 'Match not found', code: 'NOT_FOUND', status: :not_found) unless @match + end + + def set_my_team + return unless current_organization + + @my_team = TournamentTeam.find_by( + tournament: @tournament, + organization: current_organization, + status: 'approved' + ) + return if @my_team + + render_error(message: 'Your team is not enrolled in this tournament', code: 'NOT_ENROLLED', + status: :forbidden) + end + + def require_admin! + return if current_user&.admin_or_owner? + + render_error(message: 'Admin access required', code: 'FORBIDDEN', status: :forbidden) + end + + def opponent_of(team) + return nil unless team + + if @match.team_a_id == team.id + @match.team_b + else + @match.team_a + end + end + + def both_reported? + @match.match_reports.where(status: 'submitted').count == 2 + end + + def status_message(status) + { + submitted: 'Result submitted. Waiting for opponent to confirm.', + confirmed: 'Both reports match. Result confirmed, bracket advanced.', + disputed: 'Scores diverge. An admin will resolve the dispute.' + }[status] || 'Report received.' + end + end + end +end diff --git a/app/modules/tournaments/controllers/tournament_matches_controller.rb b/app/modules/tournaments/controllers/tournament_matches_controller.rb new file mode 100644 index 0000000..58ab773 --- /dev/null +++ b/app/modules/tournaments/controllers/tournament_matches_controller.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module Tournaments + module Controllers + # Match listing and check-in for tournament participants. + # + # GET /api/v1/tournaments/:tournament_id/matches β€” list all matches + # GET /api/v1/tournaments/:tournament_id/matches/:id β€” show match detail + # POST /api/v1/tournaments/:tournament_id/matches/:id/checkin β€” captain checks in + class TournamentMatchesController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: %i[index show] + + before_action :set_tournament + before_action :set_match, only: %i[show checkin] + + # GET /api/v1/tournaments/:tournament_id/matches + def index + matches = @tournament.tournament_matches + .includes(:team_a, :team_b, :winner, :loser) + .by_round + render_success(matches.map { |m| TournamentMatchSerializer.new(m).as_json }) + end + + # GET /api/v1/tournaments/:tournament_id/matches/:id + def show + my_team = current_tournament_team + + data = TournamentMatchSerializer.new(@match).as_json.merge( + my_team_checked_in: my_team ? @match.team_checkins.exists?(tournament_team: my_team) : nil, + opponent_checked_in: opponent_checked_in?(my_team), + my_team_has_reported: my_team ? @match.match_reports.exists?(tournament_team: my_team) : nil, + checkin_deadline_at: @match.checkin_deadline_at&.iso8601, + wo_deadline_at: @match.wo_deadline_at&.iso8601 + ) + + render_success(data) + end + + # POST /api/v1/tournaments/:tournament_id/matches/:id/checkin + def checkin + unless @match.open_for_checkin? + return render_error( + message: "Check-in is not open for this match (status: #{@match.status})", + code: 'CHECKIN_NOT_OPEN', + status: :unprocessable_entity + ) + end + + my_team = current_tournament_team + unless my_team + return render_error( + message: 'Your organization is not a participant in this match', + code: 'NOT_PARTICIPANT', + status: :unprocessable_entity + ) + end + + if @match.team_checkins.exists?(tournament_team: my_team) + return render_error( + message: 'Your team has already checked in', + code: 'ALREADY_CHECKED_IN', + status: :unprocessable_entity + ) + end + + checkin = TeamCheckin.create!( + tournament_match: @match, + tournament_team: my_team, + checked_in_by: current_user, + checked_in_at: Time.current + ) + + # Transition to in_progress when both teams have checked in + if @match.both_checked_in? + @match.update!(status: 'in_progress', started_at: Time.current) + broadcast_match_update(@match) + end + + render_success({ + checked_in: true, + checked_in_at: checkin.checked_in_at.iso8601, + my_team_checked_in: true, + opponent_checked_in: opponent_checked_in?(my_team), + match_status: @match.reload.status + }) + end + + private + + def set_tournament + @tournament = Tournament.find_by(id: params[:tournament_id]) + render_error(message: 'Tournament not found', code: 'NOT_FOUND', status: :not_found) unless @tournament + end + + def set_match + @match = @tournament.tournament_matches + .includes(:team_a, :team_b, :team_checkins, :match_reports) + .find_by(id: params[:id]) + render_error(message: 'Match not found', code: 'NOT_FOUND', status: :not_found) unless @match + end + + # Find the approved tournament team for the current org in this match + def current_tournament_team + return nil unless respond_to?(:current_organization, true) && current_organization + + @current_tournament_team ||= TournamentTeam.find_by( + tournament: @tournament, + organization: current_organization, + status: 'approved' + ) + end + + def opponent_checked_in?(my_team) + return false unless my_team + + opponent = if @match.team_a_id == my_team.id + @match.team_b + else + @match.team_a + end + return false unless opponent + + @match.team_checkins.any? { |c| c.tournament_team_id == opponent.id } + end + + def broadcast_match_update(match) + ActionCable.server.broadcast( + "tournament_#{match.tournament_id}", + { + match_id: match.id, + status: match.status, + team_a_score: match.team_a_score, + team_b_score: match.team_b_score, + updated_at: match.updated_at.iso8601 + } + ) + end + end + end +end diff --git a/app/modules/tournaments/controllers/tournament_teams_controller.rb b/app/modules/tournaments/controllers/tournament_teams_controller.rb new file mode 100644 index 0000000..e83c7d4 --- /dev/null +++ b/app/modules/tournaments/controllers/tournament_teams_controller.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Tournaments + module Controllers + # Enrollment management for a tournament. + # + # GET /api/v1/tournaments/:tournament_id/teams β€” list teams + # POST /api/v1/tournaments/:tournament_id/teams β€” enroll org + # PATCH /api/v1/tournaments/:tournament_id/teams/:id/approve β€” admin approve + roster lock + # PATCH /api/v1/tournaments/:tournament_id/teams/:id/reject β€” admin reject + # DELETE /api/v1/tournaments/:tournament_id/teams/:id β€” withdraw (own team) + class TournamentTeamsController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: %i[index] + + before_action :set_tournament + before_action :set_team, only: %i[destroy approve reject] + before_action :require_admin!, only: %i[approve reject] + + # GET /api/v1/tournaments/:tournament_id/teams + def index + teams = @tournament.tournament_teams.includes(:organization, :tournament_roster_snapshots) + render_success(teams.map { |t| TournamentTeamSerializer.new(t, with_roster: true).as_json }) + end + + # POST /api/v1/tournaments/:tournament_id/teams + def create + unless @tournament.registration_open? + return render_error( + message: 'Registration is not open for this tournament', + code: 'REGISTRATION_CLOSED', + status: :unprocessable_entity + ) + end + + unless @tournament.slots_available? + return render_error( + message: "Tournament is full (#{@tournament.max_teams} teams)", + code: 'TOURNAMENT_FULL', + status: :unprocessable_entity + ) + end + + if @tournament.tournament_teams.exists?(organization: current_organization) + return render_error( + message: 'Your organization is already enrolled', + code: 'ALREADY_ENROLLED', + status: :unprocessable_entity + ) + end + + team = TournamentTeam.new( + tournament: @tournament, + organization: current_organization, + team_name: enrollment_params[:team_name] || current_organization.name, + team_tag: enrollment_params[:team_tag] || current_organization.team_tag, + logo_url: enrollment_params[:logo_url] || current_organization.logo_url + ) + + if team.save + render_created(TournamentTeamSerializer.new(team).as_json) + else + render_error( + message: team.errors.full_messages.join(', '), + code: 'VALIDATION_ERROR', + status: :unprocessable_entity + ) + end + end + + # PATCH /api/v1/tournaments/:tournament_id/teams/:id/approve + def approve + if @team.approved? + return render_error(message: 'Team is already approved', code: 'ALREADY_APPROVED', + status: :unprocessable_entity) + end + + ActiveRecord::Base.transaction do + @team.approve! + lock_roster!(@team) + end + + render_success(TournamentTeamSerializer.new(@team, with_roster: true).as_json) + end + + # PATCH /api/v1/tournaments/:tournament_id/teams/:id/reject + def reject + if @team.rejected? + return render_error(message: 'Team is already rejected', code: 'ALREADY_REJECTED', + status: :unprocessable_entity) + end + + @team.reject! + render_success(TournamentTeamSerializer.new(@team).as_json) + end + + # DELETE /api/v1/tournaments/:tournament_id/teams/:id + def destroy + unless @team.organization_id == current_organization.id || current_user&.admin_or_owner? + return render_error(message: 'Forbidden', code: 'FORBIDDEN', status: :forbidden) + end + + if @tournament.bracket_generated? + return render_error( + message: 'Cannot withdraw after bracket has been generated', + code: 'BRACKET_LOCKED', + status: :unprocessable_entity + ) + end + + @team.withdraw! + render_success({ withdrawn: true }) + end + + private + + def set_tournament + @tournament = Tournament.find_by(id: params[:tournament_id]) + render_error(message: 'Tournament not found', code: 'NOT_FOUND', status: :not_found) unless @tournament + end + + def set_team + @team = @tournament.tournament_teams.find_by(id: params[:id]) + render_error(message: 'Team not found', code: 'NOT_FOUND', status: :not_found) unless @team + end + + def require_admin! + return if current_user&.admin_or_owner? + + render_error(message: 'Admin access required', code: 'FORBIDDEN', status: :forbidden) + end + + # Roster Lock: snapshot all active players from the org at approval time. + # This record is immutable β€” never updated after creation. + def lock_roster!(team) + org = team.organization + players = org.players.where(status: %w[active rostered]).order(:role, :jersey_number) + + players.each_with_index do |player, idx| + position = idx < 5 ? 'starter' : 'substitute' + TournamentRosterSnapshot.create!( + tournament_team: team, + player: player, + summoner_name: player.summoner_name, + role: player.role, + position: position, + locked_at: Time.current + ) + end + end + + def enrollment_params + params.permit(:team_name, :team_tag, :logo_url) + end + end + end +end diff --git a/app/modules/tournaments/controllers/tournaments_controller.rb b/app/modules/tournaments/controllers/tournaments_controller.rb new file mode 100644 index 0000000..56be20c --- /dev/null +++ b/app/modules/tournaments/controllers/tournaments_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Tournaments + module Controllers + # CRUD for tournaments. + # + # GET /api/v1/tournaments β€” list (public) + # GET /api/v1/tournaments/:id β€” show with bracket (public) + # POST /api/v1/tournaments β€” create (admin only) + # PATCH /api/v1/tournaments/:id β€” update (admin only) + # POST /api/v1/tournaments/:id/generate_bracket β€” trigger bracket gen (admin only) + class TournamentsController < Api::V1::BaseController + include Cacheable + + skip_before_action :authenticate_request!, only: %i[index show] + + before_action :set_tournament, only: %i[show update generate_bracket] + before_action :require_admin!, only: %i[create update generate_bracket] + + after_action -> { invalidate_cache('tournaments') }, only: %i[update] + after_action -> { invalidate_cache("tournaments/#{@tournament&.id}") }, only: %i[update] + + # GET /api/v1/tournaments + def index + tournaments = Tournament.active.by_scheduled.includes(:tournament_teams, :tournament_matches) + + data = cache_response('tournaments', expires_in: 30.minutes) do + tournaments.map { |t| TournamentSerializer.new(t).as_json } + end + + render_success(data) + end + + # GET /api/v1/tournaments/:id + def show + data = cache_response("tournaments/#{@tournament.id}", expires_in: 30.minutes) do + TournamentSerializer.new(@tournament, with_bracket: true).as_json + end + + render_success(data) + end + + # POST /api/v1/tournaments + def create + tournament = Tournament.new(tournament_params) + + if tournament.save + render_created(TournamentSerializer.new(tournament).as_json) + else + render_error( + message: tournament.errors.full_messages.join(', '), + code: 'VALIDATION_ERROR', + status: :unprocessable_entity + ) + end + end + + # PATCH /api/v1/tournaments/:id + def update + if @tournament.update(tournament_params) + render_success(TournamentSerializer.new(@tournament).as_json) + else + render_error( + message: @tournament.errors.full_messages.join(', '), + code: 'VALIDATION_ERROR', + status: :unprocessable_entity + ) + end + end + + # POST /api/v1/tournaments/:id/generate_bracket + def generate_bracket + if @tournament.bracket_generated? + return render_error( + message: 'Bracket already generated', + code: 'BRACKET_EXISTS', + status: :unprocessable_entity + ) + end + + BracketGeneratorService.new(@tournament).call + @tournament.update!(status: 'in_progress') + render_success(TournamentSerializer.new(@tournament, with_bracket: true).as_json) + end + + private + + def set_tournament + @tournament = Tournament.find_by(id: params[:id]) + render_error(message: 'Tournament not found', code: 'NOT_FOUND', status: :not_found) unless @tournament + end + + def require_admin! + return if current_user&.admin_or_owner? + + render_error(message: 'Admin access required', code: 'FORBIDDEN', status: :forbidden) + end + + def tournament_params + # :format is a Rails routing reserved param (from the optional (.:format) route segment). + # path_parameters override it to nil in the merged params hash, so we read it + # directly from the raw request body to get the value the client actually sent. + permitted = params.permit( + :name, :game, :status, :max_teams, + :entry_fee_cents, :prize_pool_cents, :bo_format, + :current_round_label, :rules, + :registration_closes_at, :scheduled_start_at + ) + body_format = request.request_parameters[:format] || request.request_parameters.dig(:tournament, :format) + permitted[:format] = body_format if body_format.present? + permitted + end + end + end +end diff --git a/app/modules/tournaments/jobs/tournament_walkover_job.rb b/app/modules/tournaments/jobs/tournament_walkover_job.rb new file mode 100644 index 0000000..dde1066 --- /dev/null +++ b/app/modules/tournaments/jobs/tournament_walkover_job.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Tournaments + # Auto-WO job scheduled when check-in opens. + # Fires at checkin_deadline_at + 15 minutes. + # If a team failed to check in, they forfeit β€” the opposing team wins by W.O. + # If both failed to check in, match is marked walkover with no winner (admin decides). + # + # Scheduling: called from TournamentMatchesController (future: when checkin_open event fires). + # Schedule: Tournaments::TournamentWalkoverJob.set(wait_until: match.wo_deadline_at).perform_later(match.id) + class TournamentWalkoverJob < ApplicationJob + queue_as :default + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def perform(match_id) + match = TournamentMatch.includes(:team_a, :team_b, :team_checkins).find_by(id: match_id) + return unless match + return unless match.status == 'checkin_open' + + team_a_in = match.team_checkins.any? { |c| c.tournament_team_id == match.team_a_id } + team_b_in = match.team_checkins.any? { |c| c.tournament_team_id == match.team_b_id } + + return if team_a_in && team_b_in # Both checked in β€” normal flow started, job is stale + + if team_a_in && !team_b_in + apply_walkover(match, winner: match.team_a, loser: match.team_b) + elsif team_b_in && !team_a_in + apply_walkover(match, winner: match.team_b, loser: match.team_a) + else + # Neither checked in β€” double no-show, admin must decide + match.update!(status: 'walkover') + broadcast_update(match) + end + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + private + + def apply_walkover(match, winner:, loser:) + BracketProgressionService.new(match, winner: winner, loser: loser, status: 'walkover').call + broadcast_update(match) + Events::EventPublisher.publish( + user_id: match.tournament.organization.users.first&.id || 'system', + org_id: match.tournament.organization_id, + type: 'tournament_match.walkover', + payload: { match_id: match.id, tournament_id: match.tournament_id, winner_id: winner&.id } + ) + end + + def broadcast_update(match) + ActionCable.server.broadcast( + "tournament_#{match.tournament_id}", + { + match_id: match.id, + status: match.status, + team_a_score: match.team_a_score, + team_b_score: match.team_b_score, + updated_at: match.updated_at.iso8601, + event: 'walkover' + } + ) + end + end +end diff --git a/app/modules/tournaments/models/match_report.rb b/app/modules/tournaments/models/match_report.rb new file mode 100644 index 0000000..87608d6 --- /dev/null +++ b/app/modules/tournaments/models/match_report.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Stores a captain's score report for a tournament match. +# Dual-report validation: both captains report, matching scores auto-confirm; diverging β†’ disputed. +class MatchReport < ApplicationRecord + STATUSES = %w[pending submitted confirmed disputed].freeze + + # Associations + belongs_to :tournament_match + belongs_to :tournament_team + belongs_to :reported_by_user, class_name: 'User', optional: true + + # Validations + validates :status, inclusion: { in: STATUSES } + validates :team_a_score, numericality: { greater_than_or_equal_to: 0 } + validates :team_b_score, numericality: { greater_than_or_equal_to: 0 } + validates :evidence_url, presence: true, on: :submit + validates :tournament_team_id, uniqueness: { scope: :tournament_match_id, message: 'already reported' } + + # Scopes + scope :submitted, -> { where(status: 'submitted') } + scope :confirmed, -> { where(status: 'confirmed') } + scope :disputed, -> { where(status: 'disputed') } + + def submit!(team_a_score:, team_b_score:, evidence_url:, user:) + update!( + team_a_score: team_a_score, + team_b_score: team_b_score, + evidence_url: evidence_url, + reported_by_user: user, + status: 'submitted', + submitted_at: Time.current + ) + end + + def submitted? + status == 'submitted' + end + + def scores_match?(other_report) + team_a_score == other_report.team_a_score && + team_b_score == other_report.team_b_score + end +end diff --git a/app/modules/tournaments/models/team_checkin.rb b/app/modules/tournaments/models/team_checkin.rb new file mode 100644 index 0000000..099dac1 --- /dev/null +++ b/app/modules/tournaments/models/team_checkin.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Records that a team's captain confirmed presence before match start. +# Unique per team per match. Missing checkin at deadline triggers WalkoverJob. +class TeamCheckin < ApplicationRecord + # Associations + belongs_to :tournament_match + belongs_to :tournament_team + belongs_to :checked_in_by, class_name: 'User', optional: true + + # Validations + validates :tournament_team_id, uniqueness: { scope: :tournament_match_id, message: 'already checked in' } + + validate :team_is_participant + + private + + def team_is_participant + return unless tournament_match && tournament_team + + return if [tournament_match.team_a_id, tournament_match.team_b_id].include?(tournament_team_id) + + errors.add(:tournament_team, 'is not a participant in this match') + end +end diff --git a/app/modules/tournaments/models/tournament.rb b/app/modules/tournaments/models/tournament.rb new file mode 100644 index 0000000..c2518d9 --- /dev/null +++ b/app/modules/tournaments/models/tournament.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Represents a double-elimination tournament for ArenaBR. +# Manages registration, bracket generation, and lifecycle transitions. +class Tournament < ApplicationRecord + STATUSES = %w[draft registration_open seeding in_progress finished cancelled].freeze + FORMATS = %w[double_elimination single_elimination].freeze + GAMES = %w[league_of_legends].freeze + + # Associations + has_many :tournament_teams, dependent: :destroy + has_many :tournament_matches, dependent: :destroy + has_many :approved_teams, -> { where(status: 'approved') }, + class_name: 'TournamentTeam' + + # Validations + validates :name, presence: true, length: { maximum: 100 } + validates :game, inclusion: { in: GAMES } + validates :format, inclusion: { in: FORMATS } + validates :status, inclusion: { in: STATUSES } + validates :max_teams, numericality: { greater_than: 0 } + validates :entry_fee_cents, numericality: { greater_than_or_equal_to: 0 } + validates :prize_pool_cents, numericality: { greater_than_or_equal_to: 0 } + + # Scopes + scope :open_registration, -> { where(status: 'registration_open') } + scope :active, -> { where(status: %w[registration_open seeding in_progress]) } + scope :by_scheduled, -> { order(scheduled_start_at: :asc) } + + def registration_open? + status == 'registration_open' + end + + def bracket_generated? + if association(:tournament_matches).loaded? + tournament_matches.any? + else + tournament_matches.exists? + end + end + + def enrolled_teams_count + # Use loaded association (avoids N+1 when preloaded via includes) + if association(:tournament_teams).loaded? + tournament_teams.count { |t| t.status == 'approved' } + else + tournament_teams.where(status: 'approved').count + end + end + + def slots_available? + enrolled_teams_count < max_teams + end + + def entry_fee_reais + entry_fee_cents / 100.0 + end + + def prize_pool_reais + prize_pool_cents / 100.0 + end +end diff --git a/app/modules/tournaments/models/tournament_match.rb b/app/modules/tournaments/models/tournament_match.rb new file mode 100644 index 0000000..c62c7d2 --- /dev/null +++ b/app/modules/tournaments/models/tournament_match.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Represents a single match within a tournament bracket. +# Uses FK self-references (next_match_winner_id, next_match_loser_id) for O(1) bracket progression. +class TournamentMatch < ApplicationRecord + STATUSES = %w[scheduled checkin_open in_progress awaiting_report awaiting_confirm + disputed confirmed completed walkover].freeze + BRACKET_SIDES = %w[upper lower grand_final].freeze + + # Associations + belongs_to :tournament + belongs_to :team_a, class_name: 'TournamentTeam', optional: true + belongs_to :team_b, class_name: 'TournamentTeam', optional: true + belongs_to :winner, class_name: 'TournamentTeam', optional: true + belongs_to :loser, class_name: 'TournamentTeam', optional: true + + # Self-referential β€” O(1) bracket progression + belongs_to :next_match_winner, class_name: 'TournamentMatch', optional: true, + foreign_key: :next_match_winner_id + belongs_to :next_match_loser, class_name: 'TournamentMatch', optional: true, + foreign_key: :next_match_loser_id + + has_many :match_reports, dependent: :destroy + has_many :team_checkins, dependent: :destroy + + # Validations + validates :status, inclusion: { in: STATUSES } + validates :bracket_side, inclusion: { in: BRACKET_SIDES } + validates :round_label, presence: true + validates :round_order, numericality: { greater_than_or_equal_to: 0 } + validates :match_number, numericality: { greater_than: 0 } + + # Scopes + scope :scheduled, -> { where(status: 'scheduled') } + scope :checkin_open, -> { where(status: 'checkin_open') } + scope :in_progress, -> { where(status: 'in_progress') } + scope :disputed, -> { where(status: 'disputed') } + scope :upper_bracket, -> { where(bracket_side: 'upper') } + scope :lower_bracket, -> { where(bracket_side: 'lower') } + scope :by_round, -> { order(:round_order, :match_number) } + + def checkin_for(team) + team_checkins.find_by(tournament_team: team) + end + + def team_a_checked_in? + team_checkins.exists?(tournament_team: team_a) + end + + def team_b_checked_in? + team_checkins.exists?(tournament_team: team_b) + end + + def both_checked_in? + team_a_checked_in? && team_b_checked_in? + end + + def report_for(team) + match_reports.find_by(tournament_team: team) + end + + def both_reported? + match_reports.where(status: 'submitted').count == 2 + end + + def open_for_checkin? + status == 'checkin_open' + end + + def open_for_report? + status.in?(%w[awaiting_report awaiting_confirm]) + end + + def disputed? + status == 'disputed' + end +end diff --git a/app/modules/tournaments/models/tournament_roster_snapshot.rb b/app/modules/tournaments/models/tournament_roster_snapshot.rb new file mode 100644 index 0000000..7fd4790 --- /dev/null +++ b/app/modules/tournaments/models/tournament_roster_snapshot.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Immutable roster snapshot β€” created at inscription approval time (Roster Lock). +# Never updated after creation. Used for historical audit and dispute resolution. +class TournamentRosterSnapshot < ApplicationRecord + POSITIONS = %w[starter substitute].freeze + ROLES = %w[top jungle mid adc support fill].freeze + + # Associations + belongs_to :tournament_team + belongs_to :player + + # Validations + validates :summoner_name, presence: true + validates :position, inclusion: { in: POSITIONS } + validates :role, inclusion: { in: ROLES }, allow_nil: true + validates :player_id, uniqueness: { scope: :tournament_team_id, message: 'already in roster' } + + # Scopes + scope :starters, -> { where(position: 'starter') } + scope :substitutes, -> { where(position: 'substitute') } +end diff --git a/app/modules/tournaments/models/tournament_team.rb b/app/modules/tournaments/models/tournament_team.rb new file mode 100644 index 0000000..2022352 --- /dev/null +++ b/app/modules/tournaments/models/tournament_team.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Represents an organization's enrollment in a tournament. +# Tracks status from pending β†’ approved/rejected, and links to the roster snapshot. +class TournamentTeam < ApplicationRecord + STATUSES = %w[pending approved rejected withdrawn disqualified].freeze + + # Associations + belongs_to :tournament + belongs_to :organization + + has_many :tournament_roster_snapshots, dependent: :destroy + has_many :match_reports, dependent: :destroy + has_many :team_checkins, dependent: :destroy + + # Matches where this team participates + has_many :matches_as_team_a, class_name: 'TournamentMatch', foreign_key: :team_a_id, dependent: :nullify + has_many :matches_as_team_b, class_name: 'TournamentMatch', foreign_key: :team_b_id, dependent: :nullify + has_many :won_matches, class_name: 'TournamentMatch', foreign_key: :winner_id, dependent: :nullify + has_many :lost_matches, class_name: 'TournamentMatch', foreign_key: :loser_id, dependent: :nullify + + # Validations + validates :team_name, presence: true, length: { maximum: 50 } + validates :team_tag, presence: true, length: { in: 2..5 } + validates :status, inclusion: { in: STATUSES } + validates :tournament_id, uniqueness: { scope: :organization_id, message: 'already enrolled' } + + # Scopes + scope :pending, -> { where(status: 'pending') } + scope :approved, -> { where(status: 'approved') } + + def approved? + status == 'approved' + end + + def pending? + status == 'pending' + end + + def approve! + update!(status: 'approved', approved_at: Time.current) + end + + def reject! + update!(status: 'rejected', rejected_at: Time.current) + end + + def withdraw! + update!(status: 'withdrawn') + end +end diff --git a/app/modules/tournaments/serializers/match_report_serializer.rb b/app/modules/tournaments/serializers/match_report_serializer.rb new file mode 100644 index 0000000..0b8a0bf --- /dev/null +++ b/app/modules/tournaments/serializers/match_report_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Serializes a MatchReport for a tournament match result submission. +class MatchReportSerializer + def initialize(report, options = {}) + @report = report + @options = options + end + + def as_json + return nil unless @report + + { + id: @report.id, + tournament_match_id: @report.tournament_match_id, + tournament_team_id: @report.tournament_team_id, + team_a_score: @report.team_a_score, + team_b_score: @report.team_b_score, + evidence_url: @report.evidence_url, + status: @report.status, + submitted_at: @report.submitted_at&.iso8601, + confirmed_at: @report.confirmed_at&.iso8601, + deadline_at: @report.deadline_at&.iso8601 + } + end +end diff --git a/app/modules/tournaments/serializers/tournament_match_serializer.rb b/app/modules/tournaments/serializers/tournament_match_serializer.rb new file mode 100644 index 0000000..f3ee4df --- /dev/null +++ b/app/modules/tournaments/serializers/tournament_match_serializer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Serializes a TournamentMatch with both team sides, scores, bracket positioning, +# and schedule timestamps. +class TournamentMatchSerializer + def initialize(match, options = {}) + @match = match + @options = options + end + + def as_json + bracket_fields + .merge(team_fields) + .merge(schedule_fields) + end + + private + + def bracket_fields + { + id: @match.id, + tournament_id: @match.tournament_id, + bracket_side: @match.bracket_side, + round_label: @match.round_label, + round_order: @match.round_order, + match_number: @match.match_number, + bo_format: @match.bo_format, + status: @match.status, + next_match_winner_id: @match.next_match_winner_id, + next_match_loser_id: @match.next_match_loser_id + } + end + + def team_fields + { + team_a_id: @match.team_a_id, + team_a_name: @match.team_a&.team_name, + team_a_tag: @match.team_a&.team_tag, + team_a_logo: @match.team_a&.logo_url, + team_a_score: @match.team_a_score, + team_b_id: @match.team_b_id, + team_b_name: @match.team_b&.team_name, + team_b_tag: @match.team_b&.team_tag, + team_b_logo: @match.team_b&.logo_url, + team_b_score: @match.team_b_score, + winner_id: @match.winner_id, + loser_id: @match.loser_id + } + end + + def schedule_fields + { + scheduled_at: @match.scheduled_at&.iso8601, + checkin_opens_at: @match.checkin_opens_at&.iso8601, + checkin_deadline_at: @match.checkin_deadline_at&.iso8601, + wo_deadline_at: @match.wo_deadline_at&.iso8601, + started_at: @match.started_at&.iso8601, + completed_at: @match.completed_at&.iso8601 + } + end +end diff --git a/app/modules/tournaments/serializers/tournament_serializer.rb b/app/modules/tournaments/serializers/tournament_serializer.rb new file mode 100644 index 0000000..95da27e --- /dev/null +++ b/app/modules/tournaments/serializers/tournament_serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Serializes a Tournament. Use with_bracket: true to include all match data. +class TournamentSerializer + def initialize(tournament, options = {}) + @tournament = tournament + @options = options + end + + def as_json + base.tap do |h| + h[:matches] = serialize_matches if @options[:with_bracket] + end + end + + private + + def base + core_fields.merge(fee_fields).merge(schedule_fields) + end + + def core_fields + { + id: @tournament.id, + name: @tournament.name, + game: @tournament.game, + format: @tournament.format, + status: @tournament.status, + max_teams: @tournament.max_teams, + enrolled_teams_count: @tournament.enrolled_teams_count, + slots_available: @tournament.slots_available?, + bracket_generated: @tournament.bracket_generated?, + bo_format: @tournament.bo_format, + current_round_label: @tournament.current_round_label, + rules: @tournament.rules + } + end + + def fee_fields + { + entry_fee_cents: @tournament.entry_fee_cents, + prize_pool_cents: @tournament.prize_pool_cents + } + end + + def schedule_fields + { + registration_closes_at: @tournament.registration_closes_at&.iso8601, + scheduled_start_at: @tournament.scheduled_start_at&.iso8601, + started_at: @tournament.started_at&.iso8601, + finished_at: @tournament.finished_at&.iso8601, + created_at: @tournament.created_at.iso8601 + } + end + + def serialize_matches + @tournament.tournament_matches + .includes(:team_a, :team_b, :winner, :loser) + .by_round + .map { |m| TournamentMatchSerializer.new(m).as_json } + end +end diff --git a/app/modules/tournaments/serializers/tournament_team_serializer.rb b/app/modules/tournaments/serializers/tournament_team_serializer.rb new file mode 100644 index 0000000..c3b12b3 --- /dev/null +++ b/app/modules/tournaments/serializers/tournament_team_serializer.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Serializes a TournamentTeam. Use with_roster: true to include locked roster snapshot. +class TournamentTeamSerializer + def initialize(team, options = {}) + @team = team + @options = options + end + + def as_json + base.tap do |h| + h[:roster] = serialize_roster if @options[:with_roster] + end + end + + private + + def base + { + id: @team.id, + tournament_id: @team.tournament_id, + organization_id: @team.organization_id, + team_name: @team.team_name, + team_tag: @team.team_tag, + logo_url: @team.logo_url, + status: @team.status, + seed: @team.seed, + bracket_side: @team.bracket_side, + enrolled_at: @team.enrolled_at&.iso8601, + approved_at: @team.approved_at&.iso8601, + rejected_at: @team.rejected_at&.iso8601 + } + end + + def serialize_roster + @team.tournament_roster_snapshots.map do |s| + { + player_id: s.player_id, + summoner_name: s.summoner_name, + role: s.role, + position: s.position, + locked_at: s.locked_at.iso8601 + } + end + end +end diff --git a/app/modules/tournaments/services/bracket_generator_service.rb b/app/modules/tournaments/services/bracket_generator_service.rb new file mode 100644 index 0000000..5d80424 --- /dev/null +++ b/app/modules/tournaments/services/bracket_generator_service.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +# Generates a full 16-team Double Elimination bracket. +# +# Structure: +# Upper Bracket (UB): 4 rounds β†’ UB R1 (8 matches), UB R2 (4), UB Semis (2), UB Final (1) +# Lower Bracket (LB): 6 rounds β†’ LB R1 (4), LB R2 (4), LB R3 (2), LB R4 (2), LB Semis (1), LB Final (1) +# Grand Final (GF): 1 match +# Total: 8+4+2+1 + 4+4+2+2+1+1 + 1 = 15 UB + 14 LB + 1 GF = 30 matches +# (For 16 teams: 15 UB + 14 LB + 1 GF = 30 total β€” each team can lose twice before elimination) +# +# FK self-references enable O(1) bracket progression: +# TournamentMatch.next_match_winner_id β†’ where winner advances +# TournamentMatch.next_match_loser_id β†’ where loser drops (nil = eliminated) +# +# @example +# BracketGeneratorService.new(tournament).call +# # => Array of TournamentMatch +class BracketGeneratorService + UB_ROUNDS = [ + { label: 'UB Round 1', order: 1, matches: 8 }, + { label: 'UB Round 2', order: 2, matches: 4 }, + { label: 'UB Semifinals', order: 3, matches: 2 }, + { label: 'UB Final', order: 4, matches: 1 } + ].freeze + + LB_ROUNDS = [ + { label: 'LB Round 1', order: 5, matches: 4 }, + { label: 'LB Round 2', order: 6, matches: 4 }, + { label: 'LB Round 3', order: 7, matches: 2 }, + { label: 'LB Round 4', order: 8, matches: 2 }, + { label: 'LB Semifinals', order: 9, matches: 1 }, + { label: 'LB Final', order: 10, matches: 1 } + ].freeze + + GF_ROUND = { label: 'Grand Final', order: 11, matches: 1 }.freeze + + def initialize(tournament) + @tournament = tournament + end + + def call + raise "Bracket already generated for tournament #{@tournament.id}" if @tournament.bracket_generated? + + ActiveRecord::Base.transaction do + matches = build_all_matches + wire_bracket(matches) + matches + end + end + + # BO per phase: + # UB Final β†’ BO3 + # Grand Final β†’ BO5 + # everything else uses the tournament default (usually BO1) + BO_OVERRIDES = { + 'UB Final' => 3, + 'Grand Final' => 5 + }.freeze + + private + + def build_all_matches + all = {} + match_number = 1 + + UB_ROUNDS.each do |round| + all[round[:label]], match_number = build_round_matches('upper', round, match_number) + end + + LB_ROUNDS.each do |round| + all[round[:label]], match_number = build_round_matches('lower', round, match_number) + end + + all[GF_ROUND[:label]] = [create_match('grand_final', GF_ROUND, match_number)] + all + end + + def build_round_matches(side, round, start_number) + number = start_number + matches = round[:matches].times.map do + m = create_match(side, round, number) + number += 1 + m + end + [matches, number] + end + + def bo_for_round(label) + BO_OVERRIDES.fetch(label, @tournament.bo_format) + end + + def create_match(side, round, match_number) + TournamentMatch.create!( + tournament: @tournament, + bracket_side: side, + round_label: round[:label], + round_order: round[:order], + match_number: match_number, + bo_format: bo_for_round(round[:label]), + status: 'scheduled' + ) + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def wire_bracket(all) + ubr1 = all['UB Round 1'] # 8 matches + ubr2 = all['UB Round 2'] # 4 matches + ubsf = all['UB Semifinals'] # 2 matches + ubf = all['UB Final'] # 1 match + + lbr1 = all['LB Round 1'] # 4 matches (8 UBR1 losers) + lbr2 = all['LB Round 2'] # 4 matches (LBR1 winner vs UBR2 loser) + lbr3 = all['LB Round 3'] # 2 matches (LBR2 winners) + lbr4 = all['LB Round 4'] # 2 matches (LBR3 winner vs UBSF loser) + lbsf = all['LB Semifinals'] # 1 match (LBR4 winners) + lbf = all['LB Final'] # 1 match (LBSF winner vs UBF loser) + gf = all['Grand Final'] # 1 match (UBF winner vs LBF winner) + + # UB R1: pairs (0,1), (2,3), (4,5), (6,7) feed UBR2[0..3] + # UB R1 losers: pairs (0,1), (2,3), (4,5), (6,7) feed LBR1[0..3] + ubr1.each_with_index do |m, i| + m.update!( + next_match_winner_id: ubr2[i / 2].id, + next_match_loser_id: lbr1[i / 2].id + ) + end + + # UB R2: pairs (0,1), (2,3) feed UBSF[0..1] + # UB R2 losers feed LBR2[0..3] β€” each UBR2 loser meets an LBR1 winner + ubr2.each_with_index do |m, i| + m.update!( + next_match_winner_id: ubsf[i / 2].id, + next_match_loser_id: lbr2[i].id + ) + end + + # LB R1 winners also feed LBR2 (same match, other slot) + lbr1.each_with_index do |m, i| + m.update!(next_match_winner_id: lbr2[i].id) + # LBR1 losers are eliminated (next_match_loser_id stays nil) + end + + # LB R2 winners: pairs (0,1), (2,3) feed LBR3[0..1] + lbr2.each_with_index do |m, i| + m.update!(next_match_winner_id: lbr3[i / 2].id) + # LBR2 losers are eliminated + end + + # UB Semis: winners β†’ UB Final; losers β†’ LBR4[0..1] + ubsf.each_with_index do |m, i| + m.update!( + next_match_winner_id: ubf[0].id, + next_match_loser_id: lbr4[i].id + ) + end + + # LB R3 winners feed LBR4 (other slot β€” UBSF loser is the seeded side) + lbr3.each_with_index do |m, i| + m.update!(next_match_winner_id: lbr4[i].id) + # LBR3 losers are eliminated + end + + # LB R4 winners β†’ LBSF; losers eliminated + lbr4.each do |m| + m.update!(next_match_winner_id: lbsf[0].id) + end + + # UB Final: winner β†’ GF; loser β†’ LB Final + ubf[0].update!( + next_match_winner_id: gf[0].id, + next_match_loser_id: lbf[0].id + ) + + # LB Semifinals β†’ LB Final + lbsf[0].update!(next_match_winner_id: lbf[0].id) + + # LB Final β†’ Grand Final + lbf[0].update!(next_match_winner_id: gf[0].id) + + # Grand Final: no next matches (nil) β€” tournament ends + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength +end diff --git a/app/modules/tournaments/services/bracket_progression_service.rb b/app/modules/tournaments/services/bracket_progression_service.rb new file mode 100644 index 0000000..29ef85b --- /dev/null +++ b/app/modules/tournaments/services/bracket_progression_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Advances winner and loser to their next matches after a confirmed result. +# +# Uses the FK self-references on TournamentMatch (next_match_winner_id, +# next_match_loser_id) for O(1) lookup β€” no hardcoded round maps. +# +# @example +# BracketProgressionService.new(match, winner: team_a, loser: team_b).call +class BracketProgressionService + def initialize(match, winner:, loser:, status: 'completed') + @match = match + @winner = winner + @loser = loser + @status = status + end + + def call + ActiveRecord::Base.transaction do + finalize_match! + advance_winner! + advance_loser! + check_tournament_complete! + end + end + + private + + def finalize_match! + @match.update!( + winner: @winner, + loser: @loser, + status: @status, + completed_at: Time.current + ) + end + + def advance_winner! + return unless @match.next_match_winner_id + + next_match = TournamentMatch.find_by(id: @match.next_match_winner_id) + return unless next_match + + # Assign winner to the first available slot (team_a then team_b) + if next_match.team_a_id.nil? + next_match.update!(team_a: @winner) + elsif next_match.team_b_id.nil? + next_match.update!(team_b: @winner) + end + end + + def advance_loser! + return unless @match.next_match_loser_id + + next_match = TournamentMatch.find_by(id: @match.next_match_loser_id) + return unless next_match + + # Assign loser to the first available slot + if next_match.team_a_id.nil? + next_match.update!(team_a: @loser) + elsif next_match.team_b_id.nil? + next_match.update!(team_b: @loser) + end + end + + def check_tournament_complete! + tournament = @match.tournament + return unless @match.bracket_side == 'grand_final' + + tournament.update!( + status: 'finished', + finished_at: Time.current + ) + end +end diff --git a/app/modules/tournaments/services/match_confirmation_service.rb b/app/modules/tournaments/services/match_confirmation_service.rb new file mode 100644 index 0000000..7ae2ce5 --- /dev/null +++ b/app/modules/tournaments/services/match_confirmation_service.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# Handles dual-report validation for tournament matches. +# +# Flow: +# 1. Captain submits report (team_a_score, team_b_score, evidence_url) +# 2. MatchReport record created/updated for their team +# 3. If both teams have reported: +# - Scores match β†’ status: confirmed β†’ BracketProgressionService +# - Scores differ β†’ status: disputed (admin resolves via admin_resolve endpoint) +# 4. If only one team reported β†’ status: awaiting_confirm +# +# @example +# result = MatchConfirmationService.new( +# match: tournament_match, +# team: my_tournament_team, +# user: current_user, +# team_a_score: 2, +# team_b_score: 1, +# evidence_url: "https://..." +# ).call +# result[:status] # => :submitted | :confirmed | :disputed | :error +class MatchConfirmationService + REPORT_DEADLINE_HOURS = 2 + + def initialize(match:, team:, user:, team_a_score:, team_b_score:, evidence_url:) + @match = match + @team = team + @user = user + @team_a_score = team_a_score.to_i + @team_b_score = team_b_score.to_i + @evidence_url = evidence_url + end + + def call + validate! + + ActiveRecord::Base.transaction do + report = upsert_report! + outcome = compare_reports(report) + { status: outcome, report: report } + end + rescue ArgumentError => e + { status: :error, message: e.message } + end + + private + + def validate! + raise ArgumentError, "Match is not open for reporting (status: #{@match.status})" unless @match.open_for_report? + raise ArgumentError, 'Evidence screenshot is required' if @evidence_url.blank? + raise ArgumentError, 'Team is not a participant in this match' unless participant? + end + + def participant? + [@match.team_a_id, @match.team_b_id].include?(@team.id) + end + + def upsert_report! + report = MatchReport.find_or_initialize_by( + tournament_match: @match, + tournament_team: @team + ) + + report.assign_attributes( + team_a_score: @team_a_score, + team_b_score: @team_b_score, + evidence_url: @evidence_url, + reported_by_user: @user, + status: 'submitted', + submitted_at: Time.current, + deadline_at: report.deadline_at || REPORT_DEADLINE_HOURS.hours.from_now + ) + + report.save! + report + end + + def compare_reports(my_report) + other_team = opponent_team + other_report = MatchReport.find_by(tournament_match: @match, tournament_team: other_team) + + unless other_report&.submitted? + # Still waiting for opponent + @match.update!(status: 'awaiting_confirm') + broadcast_update + return :submitted + end + + if my_report.scores_match?(other_report) + confirm_match!(my_report, other_report) + :confirmed + else + dispute_match!(my_report, other_report) + :disputed + end + end + + def confirm_match!(my_report, other_report) + winner, loser = determine_winner_loser + + my_report.update!(status: 'confirmed', confirmed_at: Time.current) + other_report.update!(status: 'confirmed', confirmed_at: Time.current) + + @match.update!( + team_a_score: @team_a_score, + team_b_score: @team_b_score, + status: 'confirmed' + ) + + BracketProgressionService.new(@match, winner: winner, loser: loser).call + broadcast_update + Events::EventPublisher.publish( + user_id: @user.id, + org_id: @user.organization_id, + type: 'tournament_match.confirmed', + payload: { + match_id: @match.id, + tournament_id: @match.tournament_id, + team_a_score: @match.team_a_score, + team_b_score: @match.team_b_score, + winner_id: winner&.id + } + ) + end + + def dispute_match!(my_report, other_report) + my_report.update!(status: 'disputed') + other_report.update!(status: 'disputed') + @match.update!(status: 'disputed') + broadcast_update + end + + def determine_winner_loser + if @team_a_score > @team_b_score + [@match.team_a, @match.team_b] + else + [@match.team_b, @match.team_a] + end + end + + def opponent_team + if @match.team_a_id == @team.id + @match.team_b + else + @match.team_a + end + end + + def broadcast_update + ActionCable.server.broadcast( + "tournament_#{@match.tournament_id}", + { + match_id: @match.id, + status: @match.reload.status, + team_a_score: @match.team_a_score, + team_b_score: @match.team_b_score, + updated_at: @match.updated_at.iso8601 + } + ) + end +end diff --git a/app/queries/match_filter_query.rb b/app/queries/match_filter_query.rb new file mode 100644 index 0000000..41aee1d --- /dev/null +++ b/app/queries/match_filter_query.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Applies filtering and sorting to a pre-scoped Match relation. +# +# Accepts an ActiveRecord relation already scoped to an organization and a +# params hash, then chains every supported filter and the final sort order. +# Pagination is intentionally excluded and remains the caller's responsibility. +# +# @example +# matches = organization_scoped(Match).includes(:player_match_stats, :players) +# MatchFilterQuery.new(matches, params).call +class MatchFilterQuery + ALLOWED_SORT_FIELDS = %w[game_start game_duration match_type victory created_at].freeze + ALLOWED_SORT_ORDERS = %w[asc desc].freeze + DEFAULT_SORT_FIELD = 'game_start' + DEFAULT_SORT_ORDER = 'desc' + + # @param relation [ActiveRecord::Relation] organization-scoped Match relation + # @param params [ActionController::Parameters, Hash] request parameters + def initialize(relation, params) + @relation = relation + @params = params + end + + # Applies all filters and sort order, returning the resulting relation. + # + # @return [ActiveRecord::Relation] + def call + result = apply_basic_filters(@relation) + result = apply_date_filters(result) + result = apply_opponent_filter(result) + result = apply_tournament_filter(result) + apply_sorting(result) + end + + private + + def apply_basic_filters(matches) + matches = matches.by_type(@params[:match_type]) if @params[:match_type].present? + matches = matches.victories if @params[:result] == 'victory' + matches = matches.defeats if @params[:result] == 'defeat' + matches + end + + def apply_date_filters(matches) + if @params[:start_date].present? && @params[:end_date].present? + matches.in_date_range(@params[:start_date], @params[:end_date]) + elsif @params[:days].present? + matches.recent(@params[:days].to_i) + else + matches + end + end + + def apply_opponent_filter(matches) + return matches unless @params[:opponent].present? + + matches.with_opponent(@params[:opponent]) + end + + def apply_tournament_filter(matches) + return matches unless @params[:tournament].present? + + matches.where('tournament_name ILIKE ?', "%#{@params[:tournament]}%") + end + + def apply_sorting(matches) + sort_by = ALLOWED_SORT_FIELDS.include?(@params[:sort_by]) ? @params[:sort_by] : DEFAULT_SORT_FIELD + sort_order = ALLOWED_SORT_ORDERS.include?(@params[:sort_order]) ? @params[:sort_order] : DEFAULT_SORT_ORDER + + matches.order(sort_by => sort_order) + end +end diff --git a/app/services/authentication/password_hasher.rb b/app/services/authentication/password_hasher.rb new file mode 100644 index 0000000..fa2a6ee --- /dev/null +++ b/app/services/authentication/password_hasher.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Authentication + # Handles password hashing and verification with support for lazy migration + # from bcrypt to Argon2id. The hash format is self-describing, so no extra + # column or flag is needed to detect which algorithm was used. + class PasswordHasher + # Ultra-fast params in test to avoid adding 150-250ms per RSpec example + # that touches authentication. Production values follow OWASP preferred profile. + # m_cost is an exponent: memory = 2^m_cost KiB. Valid range: 3..31. + # m_cost: 16 => 2^16 KiB = 64 MiB (OWASP preferred profile). + # m_cost: 3 => 2^3 KiB = 8 KiB (fast for test suite). + ARGON2_PARAMS = if Rails.env.test? + { m_cost: 3, t_cost: 1, p_cost: 1 }.freeze + else + { m_cost: 16, t_cost: 3, p_cost: 2 }.freeze + end + + # Covers $2a$ (standard), $2b$ (canonical), $2x$/$2y$ (legacy JRuby/PHP variants) + BCRYPT_PREFIX = /\A\$2[abxy]\$/ + + def self.hash(plain_password) + Argon2::Password.create(plain_password, **ARGON2_PARAMS) + end + + def self.verify(plain_password, digest) + return false if plain_password.blank? || digest.blank? + + bcrypt?(digest) ? verify_bcrypt(plain_password, digest) : verify_argon2(plain_password, digest) + end + + def self.needs_upgrade?(digest) + bcrypt?(digest) + end + + def self.bcrypt?(digest) + digest.to_s.match?(BCRYPT_PREFIX) + end + + def self.verify_bcrypt(plain_password, digest) + result = BCrypt::Password.new(digest) == plain_password + Rails.logger.info('[PasswordHasher] bcrypt digest detected β€” upgrade queued') if result + result + rescue BCrypt::Errors::InvalidHash + false + end + private_class_method :verify_bcrypt + + def self.verify_argon2(plain_password, digest) + Argon2::Password.verify_password(plain_password, digest) + rescue Argon2::Error + false + end + private_class_method :verify_argon2 + end +end diff --git a/app/services/circuit_breaker_service.rb b/app/services/circuit_breaker_service.rb new file mode 100644 index 0000000..05840a8 --- /dev/null +++ b/app/services/circuit_breaker_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Implements the circuit breaker pattern to prevent cascade failures. +# +# The circuit has three states: +# - closed (normal): requests pass through; failures are counted +# - open (tripped): requests are rejected immediately; no upstream calls +# - half-open (recovery): a limited number of probe requests are allowed +# +# State is stored in Redis via Sidekiq.redis so it is shared across all Puma +# workers and Sidekiq threads without adding another dependency. +# +# @example Wrap a Riot API call +# CircuitBreakerService.call("riot_api") do +# make_request(url) +# end +# +# @example Handle an open circuit +# begin +# CircuitBreakerService.call("riot_api") { fetch_data } +# rescue CircuitBreakerService::CircuitOpenError +# render_error(message: "Service temporarily unavailable", ...) +# end +class CircuitBreakerService + FAILURE_THRESHOLD = ENV.fetch('CIRCUIT_BREAKER_THRESHOLD', 5).to_i + RECOVERY_TIMEOUT = 60 + HALF_OPEN_MAX = 2 + + CircuitOpenError = Class.new(StandardError) + + # @param service_name [String] unique name for this circuit (used as Redis key prefix) + # @return [Object] return value of the block + # @raise [CircuitOpenError] when the circuit is open + def self.call(service_name, &) + new(service_name).call(&) + end + + def initialize(service_name) + @service_name = service_name + @key_failures = "circuit_breaker:#{service_name}:failures" + @key_state = "circuit_breaker:#{service_name}:state" + @key_opened = "circuit_breaker:#{service_name}:opened_at" + end + + def call(&) + case current_state + when :open + raise CircuitOpenError, "Circuit #{@service_name} is open" + when :half_open + attempt_recovery(&) + else + execute_with_tracking(&) + end + end + + private + + def current_state + Sidekiq.redis do |redis| + stored = redis.call('GET', @key_state) + return :closed unless stored == 'open' + + opened_at = redis.call('GET', @key_opened).to_f + return :open if Time.now.to_f - opened_at < RECOVERY_TIMEOUT + + :half_open + end + end + + def execute_with_tracking + result = yield + Sidekiq.redis { |r| r.call('DEL', @key_failures) } + result + rescue StandardError => e + record_failure + raise e + end + + def attempt_recovery + result = yield + Sidekiq.redis do |r| + r.call('DEL', @key_failures) + r.call('DEL', @key_state) + end + Rails.logger.info("[CIRCUIT_BREAKER] Circuit #{@service_name} CLOSED after recovery") + result + rescue StandardError => e + Sidekiq.redis do |r| + r.call('SET', @key_state, 'open') + r.call('SET', @key_opened, Time.now.to_f.to_s) + end + raise e + end + + def record_failure + failures = Sidekiq.redis { |r| r.call('INCR', @key_failures) } + return unless failures >= FAILURE_THRESHOLD + + Sidekiq.redis do |r| + r.call('SET', @key_state, 'open') + r.call('SET', @key_opened, Time.now.to_f.to_s) + end + Rails.logger.warn("[CIRCUIT_BREAKER] Circuit #{@service_name} OPENED after #{failures} consecutive failures") + end +end diff --git a/app/services/events/event_publisher.rb b/app/services/events/event_publisher.rb new file mode 100644 index 0000000..b3a5d95 --- /dev/null +++ b/app/services/events/event_publisher.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Events + # Publishes domain events to prostaff-events (Phoenix) for real-time WebSocket delivery. + # + # Design: fire-and-forget via Sidekiq. A Phoenix outage NEVER breaks a Rails request. + # All failures are logged and swallowed. Uses queue :events (retry: 0 β€” stale events + # have no value if delayed > a few seconds). + # + # Transport: Rails publishes to Redis pub/sub channel; Phoenix subscribes via + # Phoenix.PubSub Redis adapter. No HTTP from Rails to Phoenix. + # + # @example + # Events::EventPublisher.publish( + # user_id: current_user.id, + # org_id: current_organization.id, + # type: 'scrim_request.accepted', + # payload: { scrim_request_id: @scrim_request.id } + # ) + class EventPublisher + REDIS_CHANNEL_PREFIX = 'prostaff:events' + + # Publishes a domain event asynchronously. Never raises. + # + # @param user_id [String] UUID of the acting user (for audit/routing) + # @param org_id [String] Organization UUID (for tenant-scoped broadcasting) + # @param type [String] Dot-notation event type, e.g. 'scrim_request.accepted' + # @param payload [Hash] Arbitrary event data + def self.publish(user_id:, org_id:, type:, payload: {}) + unless type.present? && user_id.present? && org_id.present? + Rails.logger.warn(event: 'event_publisher_skipped', reason: 'missing_fields', type: type) + return + end + + Events::EventPublishJob.perform_later( + user_id: user_id.to_s, + org_id: org_id.to_s, + type: type, + payload: payload + ) + rescue StandardError => e + Rails.logger.error(event: 'event_publisher_enqueue_error', type: type, error: e.message) + end + end +end diff --git a/app/views/contact_mailer/new_message.html.erb b/app/views/contact_mailer/new_message.html.erb index 3f3c69e..6e550fa 100644 --- a/app/views/contact_mailer/new_message.html.erb +++ b/app/views/contact_mailer/new_message.html.erb @@ -1,15 +1,27 @@ -

New Contact Form Submission

+

Nova mensagem de contato

-

From: <%= @name %> <<%= @email %>>

-

Subject: <%= @subject %>

+
+ + + + + + +
+ De:  <%= @name %> <<%= @email %>> +
+ Assunto:  <%= @subject %> +
-
+ + + + +
+ <%= simple_format(@message) %> +
-

<%= simple_format(@message) %>

- -
- -

- This message was sent via the contact form at prostaff.gg/contact.
- Reply directly to this email to respond to <%= @name %>. +

+ Mensagem enviada via formulario de contato em prostaff.gg/contact.
+ Responda diretamente a este email para responder a <%= @name %>.

diff --git a/app/views/contact_mailer/new_message.text.erb b/app/views/contact_mailer/new_message.text.erb index 5d72071..b5bd396 100644 --- a/app/views/contact_mailer/new_message.text.erb +++ b/app/views/contact_mailer/new_message.text.erb @@ -1,13 +1,13 @@ -New Contact Form Submission -=========================== +Nova mensagem de contato - ProStaff +==================================== -From: <%= @name %> <<%= @email %>> -Subject: <%= @subject %> +De: <%= @name %> <<%= @email %>> +Assunto: <%= @subject %> --- <%= @message %> --- -This message was sent via the contact form at prostaff.gg/contact. -Reply directly to this email to respond to <%= @name %>. +Mensagem enviada via formulario de contato em prostaff.gg/contact. +Responda diretamente a este email para responder a <%= @name %>. diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 84c7878..36c25a5 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -1,69 +1,47 @@ - - - - - - - -
-
-

ProStaff

-
-
- <%= yield %> -
- -
- - + + + + + + ProStaff + + + + + + +
+ + <%# Header %> + + + + +
+ + ProStaff + +
+ + <%# Body %> + + + + +
+ <%= yield %> +
+ + <%# Footer %> + + + + +
+

© <%= Time.current.year %> ProStaff.gg. Todos os direitos reservados.

+

Esta e uma mensagem automatica. Por favor, nao responda a este email.

+
+ +
+ + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 6073edf..33d64e0 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1,8 +1,8 @@ -ProStaff -======================================== - -<%= yield %> - ----------------------------------------- -Β© <%= Time.current.year %> ProStaff.gg. All rights reserved. -This is an automated message, please do not reply. +ProStaff.gg +========================================== + +<%= yield %> + +------------------------------------------ +(c) <%= Time.current.year %> ProStaff.gg. Todos os direitos reservados. +Esta e uma mensagem automatica. Por favor, nao responda a este email. diff --git a/app/views/player_mailer/password_reset.html.erb b/app/views/player_mailer/password_reset.html.erb new file mode 100644 index 0000000..c0a5a9f --- /dev/null +++ b/app/views/player_mailer/password_reset.html.erb @@ -0,0 +1,34 @@ +

Redefinicao de senha

+ +

Ola, <%= @player.real_name.presence || @player.summoner_name %>,

+ +

Recebemos uma solicitacao de redefinicao de senha para sua conta ArenaBR (<%= @player.player_email %>).

+ +

Clique no botao abaixo para criar uma nova senha:

+ + + + + +
+ <%# @reset_url e validada no PlayerMailer#password_reset como URI::HTTP antes de ser atribuida %> + + style="display:inline-block;padding:13px 28px;background-color:#e53e3e;color:#ffffff;text-decoration:none;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;border-radius:4px;"> + Redefinir senha + +
+ +

Ou copie e cole este link no seu navegador:

+

<%= @reset_url %>

+ + + + + +
+ Este link expira em <%= @expires_in %> minutos. +
+ +

Se voce nao solicitou a redefinicao de senha, ignore este email. Sua senha nao sera alterada.

+ +

Equipe ArenaBR

diff --git a/app/views/player_mailer/password_reset.text.erb b/app/views/player_mailer/password_reset.text.erb new file mode 100644 index 0000000..2aea569 --- /dev/null +++ b/app/views/player_mailer/password_reset.text.erb @@ -0,0 +1,14 @@ +Redefinicao de senha - ArenaBR + +Ola, <%= @player.real_name.presence || @player.summoner_name %>, + +Recebemos uma solicitacao de redefinicao de senha para sua conta ArenaBR (<%= @player.player_email %>). + +Acesse o link abaixo para criar uma nova senha: +<%= @reset_url %> + +ATENCAO: Este link expira em <%= @expires_in %> minutos. + +Se voce nao solicitou a redefinicao de senha, ignore este email. Sua senha nao sera alterada. + +Equipe ArenaBR diff --git a/app/views/player_mailer/password_reset_confirmation.html.erb b/app/views/player_mailer/password_reset_confirmation.html.erb new file mode 100644 index 0000000..c1faa3b --- /dev/null +++ b/app/views/player_mailer/password_reset_confirmation.html.erb @@ -0,0 +1,28 @@ +

Senha redefinida com sucesso

+ +

Ola, <%= @player.real_name.presence || @player.summoner_name %>,

+ +

A senha da sua conta ArenaBR (<%= @player.player_email %>) foi redefinida com sucesso.

+ + + + + +
+ Nao foi voce? Entre em contato com o suporte imediatamente em + hello@prostaff.gg. + Sua conta pode ter sido comprometida. +
+ + + + + +
+ + Acessar minha conta + +
+ +

Equipe ArenaBR

diff --git a/app/views/player_mailer/password_reset_confirmation.text.erb b/app/views/player_mailer/password_reset_confirmation.text.erb new file mode 100644 index 0000000..03b9068 --- /dev/null +++ b/app/views/player_mailer/password_reset_confirmation.text.erb @@ -0,0 +1,11 @@ +Senha redefinida com sucesso - ArenaBR + +Ola, <%= @player.real_name.presence || @player.summoner_name %>, + +A senha da sua conta ArenaBR (<%= @player.player_email %>) foi redefinida com sucesso. + +ATENCAO: Se voce nao fez essa alteracao, entre em contato com o suporte imediatamente em hello@prostaff.gg. Sua conta pode ter sido comprometida. + +Acessar minha conta: <%= @frontend_url %>/login + +Equipe ArenaBR diff --git a/app/views/user_mailer/password_reset.html.erb b/app/views/user_mailer/password_reset.html.erb index 8adc090..443f95d 100644 --- a/app/views/user_mailer/password_reset.html.erb +++ b/app/views/user_mailer/password_reset.html.erb @@ -1,22 +1,34 @@ -

Password Reset Request

- -

Hi <%= @user.full_name || 'there' %>,

- -

We received a request to reset the password for your ProStaff account (<%= @user.email %>).

- -

Click the button below to reset your password:

- -

- <%# @reset_url is validated in UserMailer#password_reset to be http/https only (URI::HTTP check) %> - Reset Password<%# nosemgrep: ruby.rails.security.audit.xss.templates.var-in-href.var-in-href %> -

- -

Or copy and paste this link into your browser:

-

<%= @reset_url %>

- -

This link will expire in <%= @expires_in %> minutes.

- -

If you didn't request a password reset, you can safely ignore this email. Your password will not be changed.

- -

Best regards,
-The ProStaff Team

+

Redefinicao de senha

+ +

Ola, <%= @user.full_name || 'usuario' %>,

+ +

Recebemos uma solicitacao de redefinicao de senha para sua conta ProStaff (<%= @user.email %>).

+ +

Clique no botao abaixo para criar uma nova senha:

+ + + + + +
+ <%# @reset_url e validada no UserMailer#password_reset como URI::HTTP antes de ser atribuida %> + + style="display:inline-block;padding:13px 28px;background-color:#e53e3e;color:#ffffff;text-decoration:none;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;border-radius:4px;"> + Redefinir senha + +
+ +

Ou copie e cole este link no seu navegador:

+

<%= @reset_url %>

+ + + + + +
+ Este link expira em <%= @expires_in %> minutos. +
+ +

Se voce nao solicitou a redefinicao de senha, ignore este email. Sua senha nao sera alterada.

+ +

Equipe ProStaff

diff --git a/app/views/user_mailer/password_reset.text.erb b/app/views/user_mailer/password_reset.text.erb index 0eff796..7076e78 100644 --- a/app/views/user_mailer/password_reset.text.erb +++ b/app/views/user_mailer/password_reset.text.erb @@ -1,15 +1,14 @@ -Password Reset Request - -Hi <%= @user.full_name || 'there' %>, - -We received a request to reset the password for your ProStaff account (<%= @user.email %>). - -Click the link below to reset your password: -<%= @reset_url %> - -This link will expire in <%= @expires_in %> minutes. - -If you didn't request a password reset, you can safely ignore this email. Your password will not be changed. - -Best regards, -The ProStaff Team +Redefinicao de senha - ProStaff + +Ola, <%= @user.full_name || 'usuario' %>, + +Recebemos uma solicitacao de redefinicao de senha para sua conta ProStaff (<%= @user.email %>). + +Acesse o link abaixo para criar uma nova senha: +<%= @reset_url %> + +ATENCAO: Este link expira em <%= @expires_in %> minutos. + +Se voce nao solicitou a redefinicao de senha, ignore este email. Sua senha nao sera alterada. + +Equipe ProStaff diff --git a/app/views/user_mailer/password_reset_confirmation.html.erb b/app/views/user_mailer/password_reset_confirmation.html.erb index 2e15455..c19c584 100644 --- a/app/views/user_mailer/password_reset_confirmation.html.erb +++ b/app/views/user_mailer/password_reset_confirmation.html.erb @@ -1,12 +1,28 @@ -

Password Successfully Reset

- -

Hi <%= @user.full_name || 'there' %>,

- -

This email confirms that your ProStaff account password has been successfully reset.

- -

If you made this change, you can safely ignore this email.

- -

If you did not reset your password, please contact our support team immediately as your account may have been compromised.

- -

Best regards,
-The ProStaff Team

+

Senha redefinida com sucesso

+ +

Ola, <%= @user.full_name || 'usuario' %>,

+ +

A senha da sua conta ProStaff (<%= @user.email %>) foi redefinida com sucesso.

+ + + + + +
+ Nao foi voce? Entre em contato com o suporte imediatamente em + hello@prostaff.gg. + Sua conta pode ter sido comprometida. +
+ + + + + +
+ + Acessar minha conta + +
+ +

Equipe ProStaff

diff --git a/app/views/user_mailer/password_reset_confirmation.text.erb b/app/views/user_mailer/password_reset_confirmation.text.erb index b89a3ac..2cb2a63 100644 --- a/app/views/user_mailer/password_reset_confirmation.text.erb +++ b/app/views/user_mailer/password_reset_confirmation.text.erb @@ -1,12 +1,11 @@ -Password Successfully Reset +Senha redefinida com sucesso - ProStaff -Hi <%= @user.full_name || 'there' %>, +Ola, <%= @user.full_name || 'usuario' %>, -This email confirms that your ProStaff account password has been successfully reset. +A senha da sua conta ProStaff (<%= @user.email %>) foi redefinida com sucesso. -If you made this change, you can safely ignore this email. +ATENCAO: Se voce nao fez essa alteracao, entre em contato com o suporte imediatamente em hello@prostaff.gg. Sua conta pode ter sido comprometida. -If you did not reset your password, please contact our support team immediately as your account may have been compromised. +Acessar minha conta: <%= @frontend_url %>/login -Best regards, -The ProStaff Team +Equipe ProStaff diff --git a/app/views/user_mailer/trial_expired.html.erb b/app/views/user_mailer/trial_expired.html.erb new file mode 100644 index 0000000..5ab8179 --- /dev/null +++ b/app/views/user_mailer/trial_expired.html.erb @@ -0,0 +1,42 @@ +

Seu periodo de teste encerrou

+ +

Ola, <%= @user.full_name || 'usuario' %>,

+ +

O periodo de teste de 14 dias da organizacao <%= @organization&.name %> encerrou. O acesso a plataforma foi suspenso.

+ +

Assine o ProStaff para continuar gerenciando seu time e acessar todos os recursos:

+ + + + + + + + + + + +
+ •  Estatisticas de jogadores e historico de partidas +
+ •  VOD review com anotacoes +
+ •  Scouting e analise de talentos +
+ + + + + +
+ + Assinar ProStaff + +
+ +

Os dados da sua organizacao serao mantidos por 30 dias apos o encerramento do trial.

+ +

Duvidas? Entre em contato: hello@prostaff.gg

+ +

Equipe ProStaff

diff --git a/app/views/user_mailer/trial_expired.text.erb b/app/views/user_mailer/trial_expired.text.erb new file mode 100644 index 0000000..30b11d9 --- /dev/null +++ b/app/views/user_mailer/trial_expired.text.erb @@ -0,0 +1,15 @@ +Seu periodo de teste ProStaff encerrou + +Ola, <%= @user.full_name || 'usuario' %>, + +O periodo de teste de 14 dias da organizacao "<%= @organization&.name %>" encerrou. +O acesso a plataforma foi suspenso. + +Assine o ProStaff para continuar: +<%= ENV.fetch('FRONTEND_URL', 'https://prostaff.gg') %>/billing + +Os dados da sua organizacao serao mantidos por 30 dias apos o encerramento do trial. + +Duvidas? Entre em contato: hello@prostaff.gg + +Equipe ProStaff diff --git a/app/views/user_mailer/trial_expiring_soon.html.erb b/app/views/user_mailer/trial_expiring_soon.html.erb new file mode 100644 index 0000000..144080c --- /dev/null +++ b/app/views/user_mailer/trial_expiring_soon.html.erb @@ -0,0 +1,47 @@ +

Seu teste expira em <%= @days_remaining %> dia(s)

+ +

Ola, <%= @user.full_name || 'usuario' %>,

+ +

O periodo de teste da organizacao <%= @organization&.name %> expira em <%= @days_remaining %> dia(s). Apos o encerramento, o acesso sera suspenso.

+ +

O que voce perdera apos a expiracao:

+ + + + + + + + + + + +
+ •  Acesso a estatisticas de jogadores e partidas +
+ •  VOD review e analise de performance +
+ •  Scouting e gestao de elenco +
+ + + + + +
+ + Fazer upgrade agora + +
+ +

+ + Ver planos e precos + +

+ +

Duvidas? Entre em contato: hello@prostaff.gg

+ +

Equipe ProStaff

diff --git a/app/views/user_mailer/trial_expiring_soon.text.erb b/app/views/user_mailer/trial_expiring_soon.text.erb new file mode 100644 index 0000000..8bb0987 --- /dev/null +++ b/app/views/user_mailer/trial_expiring_soon.text.erb @@ -0,0 +1,16 @@ +Seu teste ProStaff expira em <%= @days_remaining %> dia(s) + +Ola, <%= @user.full_name || 'usuario' %>, + +O periodo de teste da organizacao "<%= @organization&.name %>" expira em <%= @days_remaining %> dia(s). +Apos o encerramento, o acesso sera suspenso. + +Faca o upgrade agora para manter acesso a todos os recursos: +<%= ENV.fetch('FRONTEND_URL', 'https://prostaff.gg') %>/billing + +Ver planos e precos: +<%= ENV.fetch('FRONTEND_URL', 'https://prostaff.gg') %>/pricing + +Duvidas? Entre em contato: hello@prostaff.gg + +Equipe ProStaff diff --git a/app/views/user_mailer/welcome.html.erb b/app/views/user_mailer/welcome.html.erb index 5e7ba05..ce73361 100644 --- a/app/views/user_mailer/welcome.html.erb +++ b/app/views/user_mailer/welcome.html.erb @@ -1,21 +1,45 @@ -

Welcome to ProStaff!

- -

Hi <%= @user.full_name || 'there' %>,

- -

Welcome to ProStaff! We're excited to have you on board.

- -

ProStaff is your all-in-one platform for managing your esports team, tracking player performance, and analyzing matches.

- -

Here are some things you can do with ProStaff:

-
    -
  • Track player statistics and performance
  • -
  • Manage team schedules and practice sessions
  • -
  • Review VODs with timestamp annotations
  • -
  • Scout new talent and track prospects
  • -
  • Set and monitor team goals
  • -
- -

If you have any questions or need help getting started, don't hesitate to reach out to our support team.

- -

Best regards,
-The ProStaff Team

+

Bem-vindo ao ProStaff!

+ +

Ola, <%= @user.full_name || 'jogador' %>,

+ +

Sua conta esta pronta. Bem-vindo a plataforma de gestao de times de esports mais completa para League of Legends.

+ +

Com o ProStaff voce pode:

+ + + + + + + + + + + + + + +
+ •  Gerenciar jogadores, estatisticas e performance individual +
+ •  Acompanhar historico de partidas e dados da Riot API +
+ •  Revisar VODs com anotacoes em timestamps +
+ •  Fazer scouting de talentos e acompanhar prospects +
+ + + + + +
+ + style="display:inline-block;padding:13px 28px;background-color:#e53e3e;color:#ffffff;text-decoration:none;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;border-radius:4px;"> + Acessar ProStaff + +
+ +

Qualquer duvida, entre em contato com hello@prostaff.gg.

+ +

Equipe ProStaff

diff --git a/app/views/user_mailer/welcome.text.erb b/app/views/user_mailer/welcome.text.erb index f3cc013..2607692 100644 --- a/app/views/user_mailer/welcome.text.erb +++ b/app/views/user_mailer/welcome.text.erb @@ -1,19 +1,17 @@ -Welcome to ProStaff! - -Hi <%= @user.full_name || 'there' %>, - -Welcome to ProStaff! We're excited to have you on board. - -ProStaff is your all-in-one platform for managing your esports team, tracking player performance, and analyzing matches. - -Here are some things you can do with ProStaff: -- Track player statistics and performance -- Manage team schedules and practice sessions -- Review VODs with timestamp annotations -- Scout new talent and track prospects -- Set and monitor team goals - -If you have any questions or need help getting started, don't hesitate to reach out to our support team. - -Best regards, -The ProStaff Team +Bem-vindo ao ProStaff! + +Ola, <%= @user.full_name || 'usuario' %>, + +Sua conta esta pronta. Bem-vindo a plataforma de gestao de times de esports mais completa para League of Legends. + +Com o ProStaff voce pode: +- Gerenciar jogadores, estatisticas e performance individual +- Acompanhar historico de partidas e dados da Riot API +- Revisar VODs com anotacoes em timestamps +- Fazer scouting de talentos e acompanhar prospects + +Acesse agora: <%= ENV.fetch('FRONTEND_URL', 'https://prostaff.gg') %> + +Qualquer duvida: hello@prostaff.gg + +Equipe ProStaff diff --git a/config/database.yml b/config/database.yml index 15fc4b3..209d2b4 100644 --- a/config/database.yml +++ b/config/database.yml @@ -20,6 +20,9 @@ default: &default # Supabase transaction pooler (port 6543) does not support prepared statements. # Disabling here prevents PG::DuplicatePstatement errors across all envs. prepared_statements: false + # PgBouncer transaction mode doesn't support session-level advisory locks. + # Disabling prevents ConcurrentMigrationError on container restarts. + advisory_locks: false # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling # DB_POOL lets you set a higher pool for Sidekiq without raising Puma threads. diff --git a/config/deploy.yml b/config/deploy.yml index cb25970..74832a6 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -119,7 +119,7 @@ healthcheck: builder: arch: amd64 args: - RUBY_VERSION: 3.4.5 + RUBY_VERSION: 3.4.8 secrets: - RAILS_MASTER_KEY diff --git a/config/environments/development.rb b/config/environments/development.rb index bdec72a..10d0068 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -8,7 +8,7 @@ # In the development environment your application's code is reloaded any time # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. - config.cache_classes = false + config.enable_reloading = true config.eager_load = false diff --git a/config/environments/production.rb b/config/environments/production.rb index 1352c85..1a414de 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -3,7 +3,7 @@ require 'active_support/core_ext/integer/time' Rails.application.configure do # rubocop:disable Metrics/BlockLength - config.cache_classes = true + config.enable_reloading = false config.eager_load = true @@ -22,6 +22,9 @@ 'prostaff.gg', 'www.prostaff.gg', ENV.fetch('APP_HOST', nil), + # Internal service names: prostaff-events Reconciler calls the API using the + # Docker Compose service hostname (e.g. "api" or "api:3000") at boot time. + /\Aapi(:\d+)?\z/, # Internal IPs: Docker bridge, Coolify overlay, localhost β€” used by health check probes /\A(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?\z/ ].compact @@ -91,6 +94,7 @@ password: ENV['SMTP_PASSWORD'], authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym, enable_starttls_auto: ENV.fetch('SMTP_ENABLE_STARTTLS_AUTO', 'true') == 'true', + ssl: ENV.fetch('SMTP_PORT', '587') == '465', domain: ENV.fetch('SMTP_DOMAIN', 'gmail.com') } else diff --git a/config/environments/test.rb b/config/environments/test.rb index 9872699..eb4acc8 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -10,8 +10,8 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - # Turn false under Spring and add config.action_view.cache_template_loading = true. - config.cache_classes = true + # Disable code reloading between requests in test (replaces removed config.cache_classes). + config.enable_reloading = false # Eager loading loads your whole application. When running a single test locally, # this probably isn't necessary. It's a good idea to do in a continuous integration diff --git a/config/initializers/cache_instrumentation.rb b/config/initializers/cache_instrumentation.rb new file mode 100644 index 0000000..94b32ad --- /dev/null +++ b/config/initializers/cache_instrumentation.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Subscribes to Rails cache read events and increments Redis counters so that +# cache hit rate can be observed without an external APM agent. +# +# Counters stored in Redis: +# metrics:cache:reads β€” total cache reads +# metrics:cache:hits β€” reads that returned a cached value +# metrics:cache:misses β€” reads that missed the cache +# +# These counters are intentionally never reset automatically so that they +# accumulate across deployments. Reset manually via Rails console: +# Rails.cache.redis.call('DEL', 'metrics:cache:reads', 'metrics:cache:hits', 'metrics:cache:misses') +# +# Exposed via GET /api/v1/monitoring/cache_stats (admin only). +ActiveSupport::Notifications.subscribe('cache_read.active_support') do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + hit = event.payload[:hit] + + Rails.cache.redis.pipelined do |pipe| + pipe.call('INCR', 'metrics:cache:reads') + pipe.call('INCR', hit ? 'metrics:cache:hits' : 'metrics:cache:misses') + end +rescue StandardError + # Instrumentation must never raise β€” a Redis failure here must not break the request. +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 3191b10..e342f3a 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -3,7 +3,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do # The fallback (second argument) must be a single string separated by commas - origins ENV.fetch('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173,http://localhost:8888,http://localhost:4444,https://prostaff.gg,https://www.prostaff.gg,https://api.prostaff.gg,https://status.prostaff.gg,https://docs.prostaff.gg,https://scrims.lol,https://www.scrims.lol,https://arena-br.vercel.app').split(',') + origins ENV.fetch('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173,http://localhost:5555,http://localhost:8888,http://localhost:4444,https://prostaff.gg,https://www.prostaff.gg,https://api.prostaff.gg,https://status.prostaff.gg,https://docs.prostaff.gg,https://scrims.lol,https://www.scrims.lol,https://arena-br.vercel.app').split(',') resource '*', headers: :any, diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 3325558..79d36fb 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -37,11 +37,13 @@ class Attack end # Allow localhost and Docker bridge in development and test environments + # Docker uses 172.16.0.0/12 range for bridge networks (172.16–172.31) safelist('allow from localhost') do |req| next false unless Rails.env.development? || Rails.env.test? ip = req.ip.to_s - ip == '127.0.0.1' || ip == '::1' || ip.start_with?('172.18.', '172.17.') + ip == '127.0.0.1' || ip == '::1' || + (ip.start_with?('172.') && ip.split('.')[1].to_i >= 16 && ip.split('.')[1].to_i <= 31) end # Block known malicious bots and scrapers @@ -77,13 +79,23 @@ class Attack end # Throttle registration β€” 10/hour per IP to allow shared NAT (office, household) + # Uses X-Forwarded-For when present (Next.js proxy repassa o IP real do cliente) throttle('register/ip', limit: 10, period: 1.hour) do |req| - req.ip if req.path == '/api/v1/auth/register' && req.post? + next unless req.path == '/api/v1/auth/register' && req.post? + + forwarded = req.env['HTTP_X_FORWARDED_FOR'] + first_ip = forwarded&.split(',')&.first + first_ip ? first_ip.strip : req.ip end - # Throttle player self-registration (ArenaBR) β€” 5/hour, mais restrito que staff + # Throttle player self-registration (ArenaBR) β€” 5/hour por IP real do cliente + # Uses X-Forwarded-For when present (Next.js proxy repassa o IP real do cliente) throttle('player-register/ip', limit: 5, period: 1.hour) do |req| - req.ip if req.path == '/api/v1/auth/player-register' && req.post? + next unless req.path == '/api/v1/auth/player-register' && req.post? + + forwarded = req.env['HTTP_X_FORWARDED_FOR'] + first_ip = forwarded&.split(',')&.first + first_ip ? first_ip.strip : req.ip end # Throttle player login β€” mesma polΓ­tica que login de staff diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 4b1dbbc..6082b05 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -36,7 +36,13 @@ def configure_sidekiq_with_retry # rubocop:disable Metrics/AbcSize pool_timeout: 5 } + config.logger = Sidekiq::Logger.new($stdout, level: :info) + config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new + config.on(:startup) do + Rails.logger = Sidekiq.logger + ActiveRecord::Base.logger = nil + schedule_file = Rails.root.join('config', 'sidekiq.yml') if File.exist?(schedule_file) schedule = YAML.load_file(schedule_file) diff --git a/config/routes.rb b/config/routes.rb index d120dd9..ed28e75 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,6 +62,7 @@ scope 'organizations/:id', as: 'organization' do patch '', to: 'organizations#update', as: 'update' post 'logo', to: 'organizations#upload_logo', as: 'logo' + patch 'lines', to: 'organizations#update_lines', as: 'update_lines' end # Profile -- stays in api/v1 @@ -147,6 +148,9 @@ resources :audit_logs, only: [:index], path: 'audit-logs', controller: '/admin/controllers/audit_logs' + # ML quality metrics (rolling AUC from RollingAucJob) + get 'ml-metrics', to: '/admin/controllers/ml_metrics#index' + # Status Incidents resources :status_incidents, path: 'status/incidents', controller: '/admin/controllers/status_incidents' do @@ -157,7 +161,8 @@ end # Monitoring (admin-only observability) -- stays in api/v1 - get 'monitoring/sidekiq', to: 'monitoring#sidekiq' + get 'monitoring/sidekiq', to: 'monitoring#sidekiq' + get 'monitoring/cache_stats', to: 'monitoring#cache_stats' # Support System scope '/support', as: 'support' do @@ -248,6 +253,7 @@ get 'competitive/draft-performance', to: '/analytics/controllers/competitive#draft_performance' get 'competitive/tournament-stats', to: '/analytics/controllers/competitive#tournament_stats' get 'competitive/opponents', to: '/analytics/controllers/competitive#opponents' + get 'competitive/patch-meta', to: '/analytics/controllers/competitive#patch_meta' get 'competitive/player-stats', to: '/analytics/controllers/competitive_player#player_stats' end @@ -374,6 +380,8 @@ post :import post 'sync-from-scraper', action: :sync_from_scraper post 'sync-from-leaguepedia', action: :sync_from_leaguepedia + get 'match-preview', action: :match_preview + get 'es-series', action: :es_series get 'diagnose-missing', action: :diagnose_missing post 'recover-missing', action: :recover_missing post 'historical-backfill', action: :historical_backfill @@ -412,6 +420,20 @@ end end + # Draft Simulations (DS1 β€” live draft simulator, multi-game series) + resources :draft_simulations, path: 'draft-simulations', + controller: '/strategy/controllers/draft_simulations', + only: %i[create destroy] do + collection do + get :list + get ':series_id', action: :index, as: :series + delete 'series/:series_id', action: :destroy_series, as: :destroy_series + end + member do + patch :update + end + end + # Assets endpoints get 'assets/champion/:champion_name', to: '/strategy/controllers/assets#champion_assets' get 'assets/map', to: '/strategy/controllers/assets#map_assets' @@ -455,14 +477,83 @@ # AI Intelligence Module β€” draft analysis and win probability # Requires Tier 1 (Professional) subscription. namespace :ai do - post 'draft/analyze', to: '/ai_intelligence/controllers/draft#analyze' + post 'draft/analyze', to: '/ai_intelligence/controllers/draft#analyze' + post 'draft/synergy-matrix', to: '/ai_intelligence/controllers/draft#synergy_matrix' + post 'recommend-pick', to: '/ai_intelligence/controllers/recommend#recommend_pick' + get 'champion-analytics', to: '/ai_intelligence/controllers/champion_analytics#index' + end + + # Wallet Module β€” proxy to ProPay service + scope '/wallet', as: 'wallet' do + get '/', to: 'wallet#show', as: 'root' + get 'transactions', to: 'wallet#transactions', as: 'transactions' + post 'deposit', to: 'wallet#deposit', as: 'deposit' + post 'payouts', to: 'wallet#create_payout', as: 'payouts' + get 'payouts/:id', to: 'wallet#payout_status', as: 'payout_status' + end + get 'wallet/charges/:txid', to: 'wallet#charge_status', as: 'wallet_charge_status' + + # Tournaments Module β€” ArenaBR double elimination + resources :tournaments, controller: '/tournaments/controllers/tournaments', + only: %i[index show create update] do + member do + post :generate_bracket + end + + resources :teams, only: %i[index create destroy], + controller: '/tournaments/controllers/tournament_teams' do + member do + patch :approve + patch :reject + end + end + + resources :matches, only: %i[index show], + controller: '/tournaments/controllers/tournament_matches' do + member do + post :checkin + end + + resource :report, only: %i[show create], + controller: '/tournaments/controllers/match_reports' do + post :admin_resolve, on: :member + end + end end end end - # Mount Sidekiq web UI in development - if Rails.env.development? - require 'sidekiq/web' - mount Sidekiq::Web => '/sidekiq' + # Internal service-to-service routes β€” authenticated via INTERNAL_JWT_SECRET only. + # Used by prostaff-events for startup reconciliation of active InhouseQueues. + namespace :internal do + namespace :api do + get 'inhouse_queues/active', to: '/inhouses/controllers/internal/inhouse_queues#active' + end + + # Called by ProPay TierSyncJob when a subscription is activated or cancelled. + patch 'organizations/by_user/:user_id/tier', to: 'organizations#update_tier' + end + + require 'sidekiq/web' + require 'rack/session' + Sidekiq::Web.use(Rack::Auth::Basic) do |user, password| + expected_user = ENV.fetch('SIDEKIQ_WEB_USER', nil) + expected_password = ENV.fetch('SIDEKIQ_WEB_PASSWORD', nil) + + next false if expected_user.blank? || expected_password.blank? + + user_match = ActiveSupport::SecurityUtils.secure_compare(user, expected_user) + password_match = ActiveSupport::SecurityUtils.secure_compare( + Digest::SHA256.hexdigest(password), + Digest::SHA256.hexdigest(expected_password) + ) + + user_match && password_match end + # Rails API mode strips session middleware β€” Sidekiq::Web needs it for CSRF + Sidekiq::Web.use Rack::Session::Cookie, + secret: Rails.application.secret_key_base, + same_site: true, + max_age: 86_400 + mount Sidekiq::Web => '/sidekiq' end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 79e5c31..4773cfd 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -3,7 +3,9 @@ # Concurrency = number of Sidekiq threads = DB pool slots consumed. # Supabase Nano (2 vCPU / 2 GB) + PgBouncer session mode: keep this low. -# Puma: 2 workers Γ— 5 threads = 10 connections. +# Puma: 4 workers x 5 threads = 20 connections (api container). +# Sidekiq: 10 connections (sidekiq container). +# Total: ~30 conexoes simultaneas de banco. # Sidekiq: DB_POOL should match concurrency (set both in your env together). # Recommended production env: SIDEKIQ_CONCURRENCY=10 DB_POOL=10 :concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10).to_i %> @@ -13,6 +15,7 @@ - critical - high - default + - events - search - mailers - low @@ -35,15 +38,32 @@ class: Analytics::RefreshMetadataViewsJob description: 'Refresh materialized views for database metadata (table privileges, extensions, policies)' - # Historical backfill: triggers scraper backfill (Leaguepedia β†’ ES) then - # syncs new matches into Rails DB. Resumable β€” only processes pending - # tournaments. First run imports full history (~8-12h), subsequent runs - # take minutes. Runs daily at 4 AM UTC. - historical_backfill: + # Historical backfill β€” CBLOL main split (highest priority, runs first) + historical_backfill_cblol: cron: '0 4 * * *' class: Competitive::HistoricalBackfillJob queue: low_priority - description: 'Trigger historical backfill on scraper and sync matches to Rails DB' + args: + - league: 'CBLOL' + description: 'Backfill CBLOL main split (Leaguepedia β†’ ES β†’ Rails DB)' + + # CBLOL Academy β€” runs 30min after CBLOL to avoid scraper contention + historical_backfill_academy: + cron: '30 4 * * *' + class: Competitive::HistoricalBackfillJob + queue: low_priority + args: + - league: 'CBLOL Academy' + description: 'Backfill CBLOL Academy (Leaguepedia β†’ ES β†’ Rails DB)' + + # Circuito Desafiante β€” runs 1h after CBLOL + historical_backfill_cd: + cron: '0 5 * * *' + class: Competitive::HistoricalBackfillJob + queue: low_priority + args: + - league: 'Circuito Desafiante' + description: 'Backfill Circuito Desafiante (Leaguepedia β†’ ES β†’ Rails DB)' # Request and expire scrim result reports daily at 10 AM UTC scrim_result_reminders: @@ -59,9 +79,18 @@ queue: low_priority description: 'Rebuild AI champion matrices and vectors nightly' - # Record component health snapshots every 5 minutes for uptime history + # Ping Riot platform status API every 6 hours to keep the job heartbeat alive. + # Ensures StatusSnapshotJob reports riot_api as operational without burning player-data rate limit. + riot_api_ping: + cron: '0 */6 * * *' + class: RiotApiPingJob + queue: low + description: 'Ping Riot status API to keep health check heartbeat alive' + + # Record component health snapshots every 15 minutes for uptime history. + # Reduced from */5 to avoid excessive DB/Redis pressure from 6 checks per run. status_snapshot: - cron: '*/5 * * * *' + cron: '*/15 * * * *' class: StatusSnapshotJob queue: default description: 'Record component health snapshots for uptime history' diff --git a/data/champion_patch_winrate.json b/data/champion_patch_winrate.json new file mode 100644 index 0000000..77747cd --- /dev/null +++ b/data/champion_patch_winrate.json @@ -0,0 +1,233 @@ +{ + "Aatrox_14": 0.5012, + "Aatrox_15": 0.5134, + "Aatrox_16": 0.5221, + "Azir_14": 0.4823, + "Azir_15": 0.4891, + "Azir_16": 0.4978, + "Caitlyn_14": 0.5134, + "Caitlyn_15": 0.5267, + "Caitlyn_16": 0.5189, + "Darius_14": 0.5234, + "Darius_15": 0.5312, + "Darius_16": 0.5198, + "Ekko_14": 0.5089, + "Ekko_15": 0.5023, + "Ekko_16": 0.5101, + "Ezreal_14": 0.4934, + "Ezreal_15": 0.4978, + "Ezreal_16": 0.5045, + "Fiora_14": 0.5156, + "Fiora_15": 0.5201, + "Fiora_16": 0.5178, + "Garen_14": 0.5312, + "Garen_15": 0.5289, + "Garen_16": 0.5234, + "Hecarim_14": 0.5023, + "Hecarim_15": 0.5156, + "Hecarim_16": 0.5089, + "Irelia_14": 0.4889, + "Irelia_15": 0.4967, + "Irelia_16": 0.5034, + "Janna_14": 0.5423, + "Janna_15": 0.5389, + "Janna_16": 0.5412, + "Jinx_14": 0.5156, + "Jinx_15": 0.5234, + "Jinx_16": 0.5312, + "KSante_14": 0.4812, + "KSante_15": 0.4934, + "KSante_16": 0.5012, + "Kaisa_14": 0.5067, + "Kaisa_15": 0.5145, + "Kaisa_16": 0.5201, + "Khazix_14": 0.5189, + "Khazix_15": 0.5234, + "Khazix_16": 0.5156, + "LeBlanc_14": 0.4956, + "LeBlanc_15": 0.5023, + "LeBlanc_16": 0.5067, + "LeeSin_14": 0.4923, + "LeeSin_15": 0.4889, + "LeeSin_16": 0.4978, + "Lulu_14": 0.5367, + "Lulu_15": 0.5412, + "Lulu_16": 0.5445, + "Malzahar_14": 0.5201, + "Malzahar_15": 0.5178, + "Malzahar_16": 0.5234, + "Nami_14": 0.5289, + "Nami_15": 0.5312, + "Nami_16": 0.5356, + "Nautilus_14": 0.5134, + "Nautilus_15": 0.5167, + "Nautilus_16": 0.5089, + "Nidalee_14": 0.4812, + "Nidalee_15": 0.4867, + "Nidalee_16": 0.4923, + "Orianna_14": 0.5045, + "Orianna_15": 0.5089, + "Orianna_16": 0.5134, + "Pantheon_14": 0.5156, + "Pantheon_15": 0.5201, + "Pantheon_16": 0.5167, + "Renata_14": 0.5223, + "Renata_15": 0.5289, + "Renata_16": 0.5312, + "Riven_14": 0.5023, + "Riven_15": 0.5089, + "Riven_16": 0.5045, + "Seraphine_14": 0.5267, + "Seraphine_15": 0.5312, + "Seraphine_16": 0.5289, + "Thresh_14": 0.5067, + "Thresh_15": 0.5134, + "Thresh_16": 0.5112, + "Tristana_14": 0.5112, + "Tristana_15": 0.5178, + "Tristana_16": 0.5245, + "Twisted Fate_14": 0.4934, + "Twisted Fate_15": 0.5012, + "Twisted Fate_16": 0.5056, + "Veigar_14": 0.5289, + "Veigar_15": 0.5323, + "Veigar_16": 0.5367, + "Vex_14": 0.5134, + "Vex_15": 0.5189, + "Vex_16": 0.5245, + "Vi_14": 0.5078, + "Vi_15": 0.5112, + "Vi_16": 0.5156, + "Viktor_14": 0.5023, + "Viktor_15": 0.5067, + "Viktor_16": 0.5134, + "Xin Zhao_14": 0.5234, + "Xin Zhao_15": 0.5289, + "Xin Zhao_16": 0.5312, + "Yasuo_14": 0.4867, + "Yasuo_15": 0.4923, + "Yasuo_16": 0.4978, + "Yone_14": 0.4934, + "Yone_15": 0.4978, + "Yone_16": 0.5023, + "Zed_14": 0.5023, + "Zed_15": 0.5067, + "Zed_16": 0.5101, + "Ziggs_14": 0.5201, + "Ziggs_15": 0.5245, + "Ziggs_16": 0.5189, + "Zoe_14": 0.4889, + "Zoe_15": 0.4956, + "Zoe_16": 0.5012, + "Aphelios_14": 0.4878, + "Aphelios_15": 0.4934, + "Aphelios_16": 0.5001, + "Blitzcrank_14": 0.5134, + "Blitzcrank_15": 0.5167, + "Blitzcrank_16": 0.5201, + "Brand_14": 0.5267, + "Brand_15": 0.5312, + "Brand_16": 0.5289, + "Camille_14": 0.5089, + "Camille_15": 0.5134, + "Camille_16": 0.5178, + "Cassiopeia_14": 0.5156, + "Cassiopeia_15": 0.5201, + "Cassiopeia_16": 0.5167, + "Draven_14": 0.5201, + "Draven_15": 0.5245, + "Draven_16": 0.5289, + "Elise_14": 0.5023, + "Elise_15": 0.5067, + "Elise_16": 0.5045, + "Gangplank_14": 0.4923, + "Gangplank_15": 0.4978, + "Gangplank_16": 0.5034, + "Graves_14": 0.5145, + "Graves_15": 0.5189, + "Graves_16": 0.5212, + "Jarvan IV_14": 0.5067, + "Jarvan IV_15": 0.5112, + "Jarvan IV_16": 0.5156, + "Jayce_14": 0.4956, + "Jayce_15": 0.5012, + "Jayce_16": 0.5045, + "Kassadin_14": 0.5134, + "Kassadin_15": 0.5189, + "Kassadin_16": 0.5234, + "Katarina_14": 0.5234, + "Katarina_15": 0.5267, + "Katarina_16": 0.5301, + "Kennen_14": 0.5023, + "Kennen_15": 0.5056, + "Kennen_16": 0.5089, + "Lucian_14": 0.5067, + "Lucian_15": 0.5112, + "Lucian_16": 0.5145, + "Lux_14": 0.5289, + "Lux_15": 0.5323, + "Lux_16": 0.5356, + "Maokai_14": 0.5312, + "Maokai_15": 0.5267, + "Maokai_16": 0.5234, + "Mordekaiser_14": 0.5178, + "Mordekaiser_15": 0.5223, + "Mordekaiser_16": 0.5267, + "Morgana_14": 0.5201, + "Morgana_15": 0.5245, + "Morgana_16": 0.5289, + "Nocturne_14": 0.5156, + "Nocturne_15": 0.5201, + "Nocturne_16": 0.5234, + "Pyke_14": 0.5023, + "Pyke_15": 0.5067, + "Pyke_16": 0.5089, + "Renekton_14": 0.5045, + "Renekton_15": 0.5089, + "Renekton_16": 0.5134, + "Samira_14": 0.5112, + "Samira_15": 0.5156, + "Samira_16": 0.5201, + "Senna_14": 0.5178, + "Senna_15": 0.5234, + "Senna_16": 0.5267, + "Sett_14": 0.5234, + "Sett_15": 0.5289, + "Sett_16": 0.5312, + "Singed_14": 0.5456, + "Singed_15": 0.5423, + "Singed_16": 0.5389, + "Sion_14": 0.5189, + "Sion_15": 0.5212, + "Sion_16": 0.5178, + "Sylas_14": 0.5023, + "Sylas_15": 0.5067, + "Sylas_16": 0.5112, + "Taliyah_14": 0.5067, + "Taliyah_15": 0.5112, + "Taliyah_16": 0.5145, + "Talon_14": 0.5145, + "Talon_15": 0.5189, + "Talon_16": 0.5223, + "Urgot_14": 0.5267, + "Urgot_15": 0.5312, + "Urgot_16": 0.5289, + "Vayne_14": 0.4978, + "Vayne_15": 0.5034, + "Vayne_16": 0.5089, + "Vel'Koz_14": 0.5201, + "Vel'Koz_15": 0.5245, + "Vel'Koz_16": 0.5289, + "Wukong_14": 0.5189, + "Wukong_15": 0.5234, + "Wukong_16": 0.5267, + "Xayah_14": 0.5023, + "Xayah_15": 0.5067, + "Xayah_16": 0.5112, + "Zac_14": 0.5289, + "Zac_15": 0.5312, + "Zac_16": 0.5345, + "Zeri_14": 0.4956, + "Zeri_15": 0.5012, + "Zeri_16": 0.5067 +} diff --git a/db/migrate/20260125000001_enable_rls_on_remaining_tables.rb b/db/migrate/20260125000001_enable_rls_on_remaining_tables.rb index 45a06fe..f9b3376 100644 --- a/db/migrate/20260125000001_enable_rls_on_remaining_tables.rb +++ b/db/migrate/20260125000001_enable_rls_on_remaining_tables.rb @@ -18,9 +18,9 @@ def up enable_rls_on_table(:support_faqs) enable_rls_on_table(:organizations) - # Enable RLS on Rails internal tables (block all API access) - enable_rls_on_table(:ar_internal_metadata) - enable_rls_on_table(:schema_migrations) + # NOTE: schema_migrations and ar_internal_metadata intentionally excluded. + # Adding FORCE RLS with deny-all to Rails internal tables breaks db:migrate + # on every deploy. These tables are not exposed via any API and need no RLS. # =========================================================================== # SUPPORT TICKETS - Organization scoped @@ -296,25 +296,6 @@ def up FOR DELETE USING (false); SQL - - # =========================================================================== - # RAILS INTERNAL TABLES - Block all API access - # These should never be accessible via PostgREST/API - # =========================================================================== - - # ar_internal_metadata - Rails internal - execute <<-SQL - CREATE POLICY ar_internal_metadata_deny_all ON ar_internal_metadata - FOR ALL - USING (false); - SQL - - # schema_migrations - Rails internal - execute <<-SQL - CREATE POLICY schema_migrations_deny_all ON schema_migrations - FOR ALL - USING (false); - SQL end def down @@ -372,10 +353,6 @@ def down drop_policy(:organizations, 'organizations_update_policy') drop_policy(:organizations, 'organizations_delete_policy') - # Drop policies for Rails internal tables - drop_policy(:ar_internal_metadata, 'ar_internal_metadata_deny_all') - drop_policy(:schema_migrations, 'schema_migrations_deny_all') - # Disable RLS disable_rls_on_table(:support_tickets) disable_rls_on_table(:support_ticket_messages) @@ -386,8 +363,6 @@ def down disable_rls_on_table(:opponent_teams) disable_rls_on_table(:support_faqs) disable_rls_on_table(:organizations) - disable_rls_on_table(:ar_internal_metadata) - disable_rls_on_table(:schema_migrations) end private diff --git a/db/migrate/20260208200854_create_fantasy_waitlists.rb b/db/migrate/20260208200854_create_fantasy_waitlists.rb deleted file mode 100644 index 4d4632e..0000000 --- a/db/migrate/20260208200854_create_fantasy_waitlists.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CreateFantasyWaitlists < ActiveRecord::Migration[7.2] - def change - create_table :fantasy_waitlists do |t| - t.string :email, null: false - t.bigint :organization_id - t.boolean :notified, default: false - t.datetime :subscribed_at - - t.timestamps - end - add_index :fantasy_waitlists, :email, unique: true - add_index :fantasy_waitlists, :organization_id - end -end diff --git a/db/migrate/20260411100001_create_tournaments.rb b/db/migrate/20260411100001_create_tournaments.rb new file mode 100644 index 0000000..24da7c5 --- /dev/null +++ b/db/migrate/20260411100001_create_tournaments.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CreateTournaments < ActiveRecord::Migration[7.2] + def change + create_table :tournaments, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.string :name, null: false + t.string :game, null: false, default: 'league_of_legends' + t.string :format, null: false, default: 'double_elimination' + + # draft | registration_open | seeding | in_progress | finished | cancelled + t.string :status, null: false, default: 'draft' + + t.integer :max_teams, null: false, default: 16 + t.integer :entry_fee_cents, null: false, default: 0 + t.integer :prize_pool_cents, null: false, default: 0 + + # Bo format for group stage, semifinals, final + t.integer :bo_format, null: false, default: 3 + + t.string :current_round_label + t.text :rules + + t.datetime :registration_closes_at + t.datetime :scheduled_start_at + t.datetime :started_at + t.datetime :finished_at + + t.timestamps + end + + add_index :tournaments, :status + add_index :tournaments, :scheduled_start_at + end +end diff --git a/db/migrate/20260411100002_create_tournament_teams.rb b/db/migrate/20260411100002_create_tournament_teams.rb new file mode 100644 index 0000000..daadae9 --- /dev/null +++ b/db/migrate/20260411100002_create_tournament_teams.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateTournamentTeams < ActiveRecord::Migration[7.2] + def change + create_table :tournament_teams, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.references :tournament, null: false, foreign_key: true, type: :uuid + t.references :organization, null: false, foreign_key: true, type: :uuid + + # Team display info (snapshot at enrollment time) + t.string :team_name, null: false + t.string :team_tag, null: false + t.string :logo_url + + # pending | approved | rejected | withdrawn | disqualified + t.string :status, null: false, default: 'pending' + + t.integer :seed # assigned during seeding phase + t.string :bracket_side # upper | lower (current bracket position) + + t.datetime :enrolled_at, null: false, default: -> { 'NOW()' } + t.datetime :approved_at + t.datetime :rejected_at + + t.timestamps + end + + add_index :tournament_teams, %i[tournament_id organization_id], unique: true, + name: 'idx_tournament_teams_unique_per_org' + add_index :tournament_teams, :status + end +end diff --git a/db/migrate/20260411100003_create_tournament_roster_snapshots.rb b/db/migrate/20260411100003_create_tournament_roster_snapshots.rb new file mode 100644 index 0000000..65d471a --- /dev/null +++ b/db/migrate/20260411100003_create_tournament_roster_snapshots.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Immutable roster snapshot created at approval time (Roster Lock). +# Records which players were on the team when inscription was approved. +# Used for dispute resolution and historical audit β€” never mutated after creation. +class CreateTournamentRosterSnapshots < ActiveRecord::Migration[7.2] + def change + create_table :tournament_roster_snapshots, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.references :tournament_team, null: false, foreign_key: true, type: :uuid + t.references :player, null: false, foreign_key: true, type: :uuid + + # Snapshot fields β€” copied from player at lock time, immutable + t.string :summoner_name, null: false + t.string :role # top | jungle | mid | adc | support + t.string :position, null: false # starter | substitute + + t.datetime :locked_at, null: false, default: -> { 'NOW()' } + + t.timestamps + end + + add_index :tournament_roster_snapshots, %i[tournament_team_id player_id], unique: true, + name: 'idx_roster_snapshots_unique_per_player' + end +end diff --git a/db/migrate/20260411100004_create_tournament_matches.rb b/db/migrate/20260411100004_create_tournament_matches.rb new file mode 100644 index 0000000..5cddc22 --- /dev/null +++ b/db/migrate/20260411100004_create_tournament_matches.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class CreateTournamentMatches < ActiveRecord::Migration[7.2] + def change + create_table :tournament_matches, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.references :tournament, null: false, foreign_key: true, type: :uuid + + # Self-referential FKs for O(1) bracket progression (no hardcoded round maps) + t.uuid :next_match_winner_id # winner advances here + t.uuid :next_match_loser_id # loser drops to here (nil for LB final / GF) + + # Competing teams (nil until bracket fills in) + t.references :team_a, foreign_key: { to_table: :tournament_teams }, type: :uuid + t.references :team_b, foreign_key: { to_table: :tournament_teams }, type: :uuid + + # Current scores (updated as reports come in) + t.integer :team_a_score, null: false, default: 0 + t.integer :team_b_score, null: false, default: 0 + + # Match outcome + t.references :winner, foreign_key: { to_table: :tournament_teams }, type: :uuid + t.references :loser, foreign_key: { to_table: :tournament_teams }, type: :uuid + + # Bracket metadata + t.string :bracket_side, null: false # upper | lower | grand_final + t.string :round_label, null: false # "UB Round 1", "LB Final", "Grand Final" + t.integer :round_order, null: false # sort order within phase + t.integer :match_number, null: false # display number + t.integer :bo_format, null: false, default: 3 + + # Status state machine + # scheduled β†’ checkin_open β†’ in_progress β†’ awaiting_report β†’ + # awaiting_confirm β†’ confirmed β†’ completed + # disputed (from awaiting_confirm) β†’ confirmed (admin resolves) + # walkover (if team no-shows checkin) + t.string :status, null: false, default: 'scheduled' + + t.datetime :scheduled_at + t.datetime :checkin_opens_at + t.datetime :checkin_deadline_at + t.datetime :wo_deadline_at + t.datetime :started_at + t.datetime :completed_at + + t.timestamps + end + + add_index :tournament_matches, :status + add_index :tournament_matches, :next_match_winner_id + add_index :tournament_matches, :next_match_loser_id + end +end diff --git a/db/migrate/20260411100005_create_match_reports.rb b/db/migrate/20260411100005_create_match_reports.rb new file mode 100644 index 0000000..2425369 --- /dev/null +++ b/db/migrate/20260411100005_create_match_reports.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateMatchReports < ActiveRecord::Migration[7.2] + def change + create_table :match_reports, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.references :tournament_match, null: false, foreign_key: true, type: :uuid + t.references :tournament_team, null: false, foreign_key: true, type: :uuid + t.references :reported_by_user, foreign_key: { to_table: :users }, type: :uuid + + # Reported scores (from perspective of this team's captain) + t.integer :team_a_score, null: false, default: 0 + t.integer :team_b_score, null: false, default: 0 + + # Evidence screenshot URL (required for report submission) + t.string :evidence_url + + # pending | submitted | confirmed | disputed + t.string :status, null: false, default: 'pending' + + t.datetime :submitted_at + t.datetime :confirmed_at + t.datetime :deadline_at, null: false + + t.timestamps + end + + add_index :match_reports, %i[tournament_match_id tournament_team_id], unique: true, + name: 'idx_match_reports_unique_per_team' + add_index :match_reports, :status + end +end diff --git a/db/migrate/20260411100006_create_team_checkins.rb b/db/migrate/20260411100006_create_team_checkins.rb new file mode 100644 index 0000000..d407c30 --- /dev/null +++ b/db/migrate/20260411100006_create_team_checkins.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateTeamCheckins < ActiveRecord::Migration[7.2] + def change + create_table :team_checkins, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.references :tournament_match, null: false, foreign_key: true, type: :uuid + t.references :tournament_team, null: false, foreign_key: true, type: :uuid + t.references :checked_in_by, foreign_key: { to_table: :users }, type: :uuid + + t.datetime :checked_in_at, null: false, default: -> { 'NOW()' } + + t.timestamps + end + + add_index :team_checkins, %i[tournament_match_id tournament_team_id], unique: true, + name: 'idx_team_checkins_unique_per_team' + end +end diff --git a/db/migrate/20260412000001_add_team_tag_to_organizations.rb b/db/migrate/20260412000001_add_team_tag_to_organizations.rb new file mode 100644 index 0000000..c59949a --- /dev/null +++ b/db/migrate/20260412000001_add_team_tag_to_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTeamTagToOrganizations < ActiveRecord::Migration[7.2] + def change + add_column :organizations, :team_tag, :string, limit: 5 + end +end diff --git a/db/migrate/20260414124103_revoke_supabase_anon_role_access.rb b/db/migrate/20260414124103_revoke_supabase_anon_role_access.rb new file mode 100644 index 0000000..6ae29bf --- /dev/null +++ b/db/migrate/20260414124103_revoke_supabase_anon_role_access.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Security fix: revoke direct table access from Supabase anon role. +# +# Context: +# ProStaff uses Supabase as PostgreSQL backend. Supabase exposes a REST API +# (/rest/v1/) that maps directly to tables. Unauthenticated requests use the +# `anon` role. The VITE_SUPABASE_PUBLISHABLE_KEY (anon key) is compiled into +# the frontend JS bundle and is publicly visible. +# +# Pentest finding (2026-04-14): GET /rest/v1/?select=* with only the +# anon key returned HTTP 200 + empty array on 9 tables. RLS was filtering rows +# but the anon role still had SELECT privilege, confirming table existence and +# allowing future exploitation if an RLS policy is ever misconfigured. +# +# Fix: +# REVOKE ALL on each affected table from the anon role. +# PostgREST will return 404 (table not in schema) instead of 200 + []. +# Rails is unaffected β€” it connects as the postgres/service_role user, +# not as anon. +# +# Tables that returned HTTP 200 in the pentest: +# organizations, users, players, matches, player_match_stats, +# audit_logs, messages, team_goals, vod_reviews +# +# Tables already returning 404 (no change needed): +# scouting_notes, refresh_tokens, watchlists +class RevokeSupabaseAnonRoleAccess < ActiveRecord::Migration[7.1] + # Tables that were accessible to the anon role + TABLES = %w[ + organizations + users + players + matches + player_match_stats + audit_logs + messages + team_goals + vod_reviews + ].freeze + + def up + # Check if anon role exists before acting (local dev may not have it) + return unless anon_role_exists? + + TABLES.each do |table| + execute "REVOKE ALL ON TABLE #{table} FROM anon;" + end + + Rails.logger.info "[Security] Revoked anon role access on #{TABLES.size} tables" + end + + def down + return unless anon_role_exists? + + # Restore minimum Supabase default grants + # (SELECT only β€” Supabase default for anon role is read-only) + TABLES.each do |table| + execute "GRANT SELECT ON TABLE #{table} TO anon;" + end + end + + private + + def anon_role_exists? + result = execute("SELECT 1 FROM pg_roles WHERE rolname = 'anon'") + result.any? + end +end diff --git a/db/migrate/20260414145951_add_scouting_origin_to_players.rb b/db/migrate/20260414145951_add_scouting_origin_to_players.rb new file mode 100644 index 0000000..c07a246 --- /dev/null +++ b/db/migrate/20260414145951_add_scouting_origin_to_players.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Preserves the link between a hired player and the ScoutingTarget they came from. +# Also stores a snapshot of the scouting data at the time of hiring so that even if +# the ScoutingTarget is later updated or the status changes, the coach can always see +# what data drove the hiring decision. +class AddScoutingOriginToPlayers < ActiveRecord::Migration[7.1] + def change + add_column :players, :scouted_from_id, :uuid, null: true + add_column :players, :scouting_data_snapshot, :jsonb, null: false, default: {} + + add_index :players, :scouted_from_id + add_foreign_key :players, :scouting_targets, column: :scouted_from_id, on_delete: :nullify + end +end diff --git a/db/migrate/20260415174436_add_season_history_to_scouting_targets.rb b/db/migrate/20260415174436_add_season_history_to_scouting_targets.rb new file mode 100644 index 0000000..85c6556 --- /dev/null +++ b/db/migrate/20260415174436_add_season_history_to_scouting_targets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSeasonHistoryToScoutingTargets < ActiveRecord::Migration[7.2] + def change + add_column :scouting_targets, :season_history, :jsonb, default: [] + end +end diff --git a/db/migrate/20260416120000_add_champion_pool_index.rb b/db/migrate/20260416120000_add_champion_pool_index.rb new file mode 100644 index 0000000..152e908 --- /dev/null +++ b/db/migrate/20260416120000_add_champion_pool_index.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Adds a composite index on player_match_stats (player_id, champion, created_at) +# to accelerate champion pool analytics queries that filter by player and +# aggregate performance per champion over time. +# +# Uses CONCURRENTLY to avoid locking the table during migration. +# disable_ddl_transaction! is required when using algorithm: :concurrently. +class AddChampionPoolIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + unless index_exists?(:player_match_stats, %i[player_id champion created_at], + name: 'idx_pms_player_champion_date') + add_index :player_match_stats, %i[player_id champion created_at], + name: 'idx_pms_player_champion_date', + algorithm: :concurrently + end + end +end diff --git a/db/migrate/20260419000001_fix_schema_migrations_rls.rb b/db/migrate/20260419000001_fix_schema_migrations_rls.rb new file mode 100644 index 0000000..db9f420 --- /dev/null +++ b/db/migrate/20260419000001_fix_schema_migrations_rls.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class FixSchemaMigrationsRls < ActiveRecord::Migration[7.2] + def up + execute 'DROP POLICY IF EXISTS schema_migrations_deny_all ON schema_migrations;' + execute 'DROP POLICY IF EXISTS ar_internal_metadata_deny_all ON ar_internal_metadata;' + begin + execute 'ALTER TABLE schema_migrations DISABLE ROW LEVEL SECURITY;' + rescue StandardError + nil + end + begin + execute 'ALTER TABLE ar_internal_metadata DISABLE ROW LEVEL SECURITY;' + rescue StandardError + nil + end + end + + def down; end +end diff --git a/db/migrate/20260419000002_add_source_app_to_users_and_players.rb b/db/migrate/20260419000002_add_source_app_to_users_and_players.rb new file mode 100644 index 0000000..ef1fb99 --- /dev/null +++ b/db/migrate/20260419000002_add_source_app_to_users_and_players.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class AddSourceAppToUsersAndPlayers < ActiveRecord::Migration[7.2] + def up + add_column :users, :source_app, :string, null: false, default: 'prostaff' + add_column :players, :source_app, :string, null: false, default: 'arena_br' + + # password_reset_tokens: tornar user_id opcional e adicionar player_id + # para suportar reset de senha de jogadores ArenaBR + change_column_null :password_reset_tokens, :user_id, true + add_column :password_reset_tokens, :player_id, :uuid + + add_index :users, :source_app + add_index :players, :source_app + add_index :password_reset_tokens, :player_id + + add_foreign_key :password_reset_tokens, :players, on_delete: :cascade + + # Garante que o token pertence a exatamente um sujeito + execute <<-SQL + ALTER TABLE password_reset_tokens + ADD CONSTRAINT chk_token_owner + CHECK ( + (user_id IS NOT NULL AND player_id IS NULL) OR + (user_id IS NULL AND player_id IS NOT NULL) + ); + SQL + end + + def down + execute 'ALTER TABLE password_reset_tokens DROP CONSTRAINT IF EXISTS chk_token_owner;' + remove_foreign_key :password_reset_tokens, :players + remove_index :password_reset_tokens, :player_id + remove_column :password_reset_tokens, :player_id + change_column_null :password_reset_tokens, :user_id, false + remove_index :players, :source_app + remove_index :users, :source_app + remove_column :players, :source_app + remove_column :users, :source_app + end +end diff --git a/db/migrate/20260420000001_add_line_to_players.rb b/db/migrate/20260420000001_add_line_to_players.rb new file mode 100644 index 0000000..b7b4a61 --- /dev/null +++ b/db/migrate/20260420000001_add_line_to_players.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLineToPlayers < ActiveRecord::Migration[7.2] + def change + add_column :players, :line, :string, default: 'main', null: false + + add_index :players, :line + end +end diff --git a/db/migrate/20260420000003_add_enabled_lines_to_organizations.rb b/db/migrate/20260420000003_add_enabled_lines_to_organizations.rb new file mode 100644 index 0000000..fd84f98 --- /dev/null +++ b/db/migrate/20260420000003_add_enabled_lines_to_organizations.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddEnabledLinesToOrganizations < ActiveRecord::Migration[7.2] + def change + add_column :organizations, :enabled_lines, :string, array: true, default: ['main'], null: false + add_index :organizations, :enabled_lines, using: :gin + end +end diff --git a/db/migrate/20260420000004_add_opponent_champion_to_player_match_stats.rb b/db/migrate/20260420000004_add_opponent_champion_to_player_match_stats.rb new file mode 100644 index 0000000..90a8a8a --- /dev/null +++ b/db/migrate/20260420000004_add_opponent_champion_to_player_match_stats.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Adds opponent_champion to player_match_stats for laning matchup context. +# Populated during match sync by finding the participant on the opposing team +# with the same teamPosition (role) as the tracked player. +class AddOpponentChampionToPlayerMatchStats < ActiveRecord::Migration[7.1] + def change + add_column :player_match_stats, :opponent_champion, :string + + add_index :player_match_stats, :opponent_champion, + name: 'idx_pms_opponent_champion' + end +end diff --git a/db/migrate/20260422000001_add_index_to_status_snapshots.rb b/db/migrate/20260422000001_add_index_to_status_snapshots.rb new file mode 100644 index 0000000..87ac871 --- /dev/null +++ b/db/migrate/20260422000001_add_index_to_status_snapshots.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddIndexToStatusSnapshots < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_index :status_snapshots, %i[component checked_at], + order: { checked_at: :desc }, + algorithm: :concurrently, + if_not_exists: true, + name: 'idx_status_snapshots_component_checked_at' + end +end diff --git a/db/migrate/20260424000001_add_null_pair_index_to_ai_champion_matrices.rb b/db/migrate/20260424000001_add_null_pair_index_to_ai_champion_matrices.rb new file mode 100644 index 0000000..4b25845 --- /dev/null +++ b/db/migrate/20260424000001_add_null_pair_index_to_ai_champion_matrices.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddNullPairIndexToAiChampionMatrices < ActiveRecord::Migration[7.1] + def change + # Partial index covering rows where both patch and league are NULL. + # The existing index_ai_champion_matrices_unique only covers non-null patch+league. + # Without this, upsert with ON CONFLICT cannot target the null-patch/league rows. + add_index :ai_champion_matrices, %i[champion_a champion_b], + name: 'index_ai_champion_matrices_null_pair', + unique: true, + where: 'patch IS NULL AND league IS NULL' + end +end diff --git a/db/migrate/20260426000001_add_competitive_team_name_to_organizations.rb b/db/migrate/20260426000001_add_competitive_team_name_to_organizations.rb new file mode 100644 index 0000000..d58a5f8 --- /dev/null +++ b/db/migrate/20260426000001_add_competitive_team_name_to_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCompetitiveTeamNameToOrganizations < ActiveRecord::Migration[7.1] + def change + add_column :organizations, :competitive_team_name, :string, comment: "Competitive team name used to identify the org's matches in Leaguepedia (e.g. 'paiN Gaming')" + end +end diff --git a/db/migrate/20260426193655_add_recipient_type_to_messages.rb b/db/migrate/20260426193655_add_recipient_type_to_messages.rb new file mode 100644 index 0000000..380def2 --- /dev/null +++ b/db/migrate/20260426193655_add_recipient_type_to_messages.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddRecipientTypeToMessages < ActiveRecord::Migration[7.2] + def change + add_column :messages, :recipient_type, :string, default: 'User', null: false + end +end diff --git a/db/migrate/20260426193938_support_player_messaging_sender_type_remove_f_ks.rb b/db/migrate/20260426193938_support_player_messaging_sender_type_remove_f_ks.rb new file mode 100644 index 0000000..ef68fa3 --- /dev/null +++ b/db/migrate/20260426193938_support_player_messaging_sender_type_remove_f_ks.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Extends messages to support staffβ†’player communication. +# +# Changes: +# - Removes FK on recipient_id (was constrained to users, now can reference players) +# - Removes FK on user_id (was constrained to users, now can reference players as senders) +# - Adds sender_type column to distinguish User vs Player senders +# +# The recipient_type column was added in a previous migration (AddRecipientTypeToMessages). +class SupportPlayerMessagingSenderTypeRemoveFKs < ActiveRecord::Migration[7.2] + def up + remove_foreign_key :messages, column: :recipient_id, if_exists: true + remove_foreign_key :messages, column: :user_id, if_exists: true + + add_column :messages, :sender_type, :string, default: 'User', null: false + end + + def down + remove_column :messages, :sender_type, if_exists: true + + add_foreign_key :messages, :users, column: :user_id + add_foreign_key :messages, :users, column: :recipient_id + end +end diff --git a/db/migrate/20260426194058_remove_messages_user_foreign_keys.rb b/db/migrate/20260426194058_remove_messages_user_foreign_keys.rb new file mode 100644 index 0000000..69d0fa0 --- /dev/null +++ b/db/migrate/20260426194058_remove_messages_user_foreign_keys.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Removes hard FK constraints that prevent player IDs being stored in +# messages.user_id (sender) and messages.recipient_id (target). +# After this migration those columns are free UUIDs β€” integrity is enforced +# at the application layer via recipient_type / sender_type. +class RemoveMessagesUserForeignKeys < ActiveRecord::Migration[7.2] + def up + # no-op: FKs already removed by SupportPlayerMessagingSenderTypeRemoveFKs (20260426193938) + end + + def down + # no-op: reversing 20260426193938 restores the FKs + end +end diff --git a/db/migrate/20260426194356_add_sender_type_to_messages.rb b/db/migrate/20260426194356_add_sender_type_to_messages.rb new file mode 100644 index 0000000..10097f3 --- /dev/null +++ b/db/migrate/20260426194356_add_sender_type_to_messages.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSenderTypeToMessages < ActiveRecord::Migration[7.2] + def change + add_column :messages, :sender_type, :string, default: 'User', null: false, if_not_exists: true + end +end diff --git a/db/migrate/20260428120000_create_draft_simulations.rb b/db/migrate/20260428120000_create_draft_simulations.rb new file mode 100644 index 0000000..f2ee02f --- /dev/null +++ b/db/migrate/20260428120000_create_draft_simulations.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateDraftSimulations < ActiveRecord::Migration[7.2] + def change + create_table :draft_simulations, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.uuid :organization_id, null: false + t.string :series_id, null: false + t.integer :game_number, null: false, default: 1 + t.string :patch + t.string :league + t.string :our_side + t.string :team1_name + t.string :team2_name + t.boolean :fearless, default: false + t.jsonb :blue_bans, default: [] + t.jsonb :red_bans, default: [] + t.jsonb :blue_picks, default: [] + t.jsonb :red_picks, default: [] + t.boolean :done, default: false + t.jsonb :fearless_used, default: {} + + t.timestamps + end + + add_foreign_key :draft_simulations, :organizations, on_delete: :cascade + + add_index :draft_simulations, :organization_id + add_index :draft_simulations, :series_id + add_index :draft_simulations, %i[organization_id series_id game_number], unique: true, + name: 'index_draft_simulations_on_org_series_game' + end +end diff --git a/db/migrate/20260428130000_create_ml_prediction_logs.rb b/db/migrate/20260428130000_create_ml_prediction_logs.rb new file mode 100644 index 0000000..bf3764f --- /dev/null +++ b/db/migrate/20260428130000_create_ml_prediction_logs.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateMlPredictionLogs < ActiveRecord::Migration[7.2] + def change + create_table :ml_prediction_logs, id: :uuid, default: -> { 'gen_random_uuid()' } do |t| + t.string :match_id + t.jsonb :blue_picks, null: false, default: [] + t.jsonb :red_picks, null: false, default: [] + t.string :patch + t.string :league + t.decimal :predicted_win_prob, precision: 5, scale: 4, null: false + t.string :model_version + t.string :source + t.boolean :blue_won + t.timestamptz :predicted_at, null: false, default: -> { 'NOW()' } + t.timestamptz :outcome_at + + t.timestamps + end + + add_index :ml_prediction_logs, :predicted_at, order: { predicted_at: :desc } + add_index :ml_prediction_logs, :match_id + end +end diff --git a/db/migrate/20260429210000_add_game_to_scrims.rb b/db/migrate/20260429210000_add_game_to_scrims.rb new file mode 100644 index 0000000..988df19 --- /dev/null +++ b/db/migrate/20260429210000_add_game_to_scrims.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddGameToScrims < ActiveRecord::Migration[7.1] + def change + add_column :scrims, :game, :string, null: false, default: 'league_of_legends' + add_index :scrims, :game + add_index :scrims, %i[game visibility scheduled_at], name: 'idx_scrims_game_visibility_scheduled' + end +end diff --git a/db/migrate/20260511120000_add_game_fingerprint_to_competitive_matches.rb b/db/migrate/20260511120000_add_game_fingerprint_to_competitive_matches.rb new file mode 100644 index 0000000..829c02b --- /dev/null +++ b/db/migrate/20260511120000_add_game_fingerprint_to_competitive_matches.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddGameFingerprintToCompetitiveMatches < ActiveRecord::Migration[7.1] + def up + add_column :competitive_matches, :game_fingerprint, :string + end + + def down + remove_column :competitive_matches, :game_fingerprint + end +end diff --git a/deploy/README.md b/deploy/README.md index 02d4870..f5b2843 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -28,7 +28,7 @@ deploy/ ## Stack Atual ``` -Ruby 3.4.5 +Ruby 3.4.8 Rails 7.2 PostgreSQL 15+ (Supabase) Redis 7.2 (via Coolify) diff --git a/deploy/scripts/docker-entrypoint.sh b/deploy/scripts/docker-entrypoint.sh index 3256644..a909119 100644 --- a/deploy/scripts/docker-entrypoint.sh +++ b/deploy/scripts/docker-entrypoint.sh @@ -84,10 +84,9 @@ echo "[4/5] Running database migrations..." >&2 if bundle exec rails db:migrate 2>&1 | tee /tmp/migration.log >&2; then echo " [OK] Migrations completed" >&2 else - echo " [WARNING] Migration failed, check output above" >&2 - echo " β†’ Attempting to create database..." >&2 - bundle exec rails db:create 2>&1 | tee -a /tmp/migration.log >&2 - bundle exec rails db:migrate 2>&1 | tee -a /tmp/migration.log >&2 + echo " [ERROR] Migration failed β€” aborting startup to prevent running stale schema" >&2 + echo " β†’ Check /tmp/migration.log for details" >&2 + exit 1 fi # Skip preload in production - Puma will handle it diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml new file mode 100644 index 0000000..c7b9038 --- /dev/null +++ b/docker-compose.monitoring.yml @@ -0,0 +1,117 @@ +services: + node-exporter: + image: prom/node-exporter:latest + restart: unless-stopped + pid: host + networks: + - monitoring + ports: + - "127.0.0.1:9100:9100" + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + - '--path.rootfs=/rootfs' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.49.1 + restart: unless-stopped + privileged: true + networks: + - monitoring + ports: + - "127.0.0.1:9202:8080" + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker:/var/lib/docker:ro + - /dev/disk:/dev/disk:ro + + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + networks: + - monitoring + ports: + - "127.0.0.1:9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.enable-lifecycle' + + alertmanager: + image: prom/alertmanager:latest + restart: unless-stopped + networks: + - monitoring + ports: + - "127.0.0.1:9093:9093" + volumes: + - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager-data:/alertmanager + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + environment: + SMTP_ADDRESS: '${SMTP_ADDRESS:-smtp.gmail.com}' + SMTP_PORT: '${SMTP_PORT:-587}' + SMTP_USERNAME: '${SMTP_USERNAME}' + SMTP_PASSWORD: '${SMTP_PASSWORD}' + ALERT_EMAIL_TO: '${ALERT_EMAIL_TO}' + + grafana: + image: grafana/grafana:latest + restart: unless-stopped + networks: + - monitoring + - coolify + volumes: + - grafana-data:/var/lib/grafana + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + environment: + GF_SECURITY_ADMIN_USER: '${GRAFANA_ADMIN_USER:-admin}' + GF_SECURITY_ADMIN_PASSWORD: '${GRAFANA_ADMIN_PASSWORD}' + GF_PATHS_PROVISIONING: '/etc/grafana/provisioning' + GF_USERS_ALLOW_SIGN_UP: 'false' + GF_SERVER_ROOT_URL: 'https://monitoring.prostaff.gg' + labels: + - traefik.enable=true + - traefik.docker.network=coolify + - traefik.http.routers.grafana-http.rule=Host(`monitoring.prostaff.gg`) + - traefik.http.routers.grafana-http.entrypoints=http + - traefik.http.routers.grafana-http.middlewares=grafana-redirect-https + - traefik.http.middlewares.grafana-redirect-https.redirectscheme.scheme=https + - traefik.http.middlewares.grafana-redirect-https.redirectscheme.permanent=true + - traefik.http.routers.grafana.rule=Host(`monitoring.prostaff.gg`) + - traefik.http.routers.grafana.entrypoints=https + - traefik.http.routers.grafana.tls=true + - traefik.http.routers.grafana.tls.certresolver=letsencrypt + - traefik.http.services.grafana.loadbalancer.server.port=3000 + - traefik.http.services.grafana.loadbalancer.server.scheme=http + depends_on: + - prometheus + +volumes: + prometheus-data: + driver: local + grafana-data: + driver: local + alertmanager-data: + driver: local + +networks: + monitoring: + driver: bridge + coolify: + external: true diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml index 8fbb768..7bdca51 100644 --- a/docker/docker-compose.production.yml +++ b/docker/docker-compose.production.yml @@ -1,4 +1,28 @@ services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + ports: + - "127.0.0.1:5432:5432" + networks: + - coolify + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: '${POSTGRES_DB:-prostaff_production}' + POSTGRES_USER: '${POSTGRES_USER:-prostaff}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prostaff} -d ${POSTGRES_DB:-prostaff_production}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + labels: + - coolify.managed=true + - coolify.applicationId=1 + - coolify.type=application + redis: image: redis:7.2-alpine restart: unless-stopped @@ -75,7 +99,7 @@ services: # CORS Middleware Definition - traefik.http.middlewares.prostaff-cors.headers.accesscontrolallowmethods=GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD - - traefik.http.middlewares.prostaff-cors.headers.accesscontrolalloworiginlist=https://prostaff.gg,https://www.prostaff.gg,https://docs.prostaff.gg,https://status.prostaff.gg,https://scrims.lol,https://www.scrims.lol + - traefik.http.middlewares.prostaff-cors.headers.accesscontrolalloworiginlist=https://prostaff.gg,https://www.prostaff.gg,https://docs.prostaff.gg,https://status.prostaff.gg,https://scrims.lol,https://www.scrims.lol,https://arena-br.vercel.app - traefik.http.middlewares.prostaff-cors.headers.accesscontrolallowcredentials=true - traefik.http.middlewares.prostaff-cors.headers.accesscontrolallowheaders=Authorization,Content-Type,Accept,Origin,X-Requested-With - traefik.http.middlewares.prostaff-cors.headers.accesscontrolmaxage=86400 @@ -88,7 +112,9 @@ services: environment: RAILS_ENV: production - DATABASE_URL: '${DATABASE_URL}' + WEB_CONCURRENCY: '4' + RAILS_MAX_THREADS: '5' + DATABASE_URL: 'postgresql://${POSTGRES_USER:-prostaff}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-prostaff_production}' # Connect to Redis via Docker network hostname REDIS_URL: 'redis://default:${REDIS_PASSWORD}@redis:6379/0' ELASTICSEARCH_URL: '${ELASTICSEARCH_URL:-http://elastic:9200}' @@ -96,6 +122,11 @@ services: PORT: 3000 RAILS_MASTER_KEY: '${RAILS_MASTER_KEY}' RIOT_API_KEY: '${RIOT_API_KEY}' + RIOT_GATEWAY_URL: '${RIOT_GATEWAY_URL}' + INTERNAL_JWT_SECRET: '${INTERNAL_JWT_SECRET}' + PANDASCORE_API_KEY: '${PANDASCORE_API_KEY}' + SCRAPER_API_URL: 'http://scraper-api:8000' + SCRAPER_API_KEY: '${SCRAPER_API_KEY}' CORS_ORIGINS: '${CORS_ORIGINS:-https://prostaff.gg,https://www.prostaff.gg,https://api.prostaff.gg,https://status.prostaff.gg,https://docs.prostaff.gg}' JWT_SECRET_KEY: '${JWT_SECRET_KEY}' SECRET_KEY_BASE: '${SECRET_KEY_BASE}' @@ -103,10 +134,26 @@ services: HASHID_SALT: '${HASHID_SALT}' HASHID_MIN_LENGTH: '${HASHID_MIN_LENGTH}' FRONTEND_URL: '${FRONTEND_URL}' + PROSTAFF_URL: '${PROSTAFF_URL:-https://prostaff.gg}' + SCRIMS_URL: '${SCRIMS_URL:-https://scrims.lol}' + ARENA_BR_URL: '${ARENA_BR_URL:-https://arena-br.vercel.app}' APP_HOST: '${APP_HOST:-api.prostaff.gg}' # Meilisearch (self-hosted β€” same Docker network) MEILISEARCH_URL: 'http://meilisearch:7700' MEILI_MASTER_KEY: '${MEILI_MASTER_KEY}' + # SMTP (email delivery) + SMTP_USERNAME: '${SMTP_USERNAME}' + SMTP_PASSWORD: '${SMTP_PASSWORD}' + SMTP_ADDRESS: '${SMTP_ADDRESS:-smtp.gmail.com}' + SMTP_PORT: '${SMTP_PORT:-587}' + SMTP_DOMAIN: '${SMTP_DOMAIN:-gmail.com}' + # prostaff-events integration (set PHOENIX_EVENTS_ENABLED=true in Coolify panel when deploying events service) + PHOENIX_EVENTS_ENABLED: '${PHOENIX_EVENTS_ENABLED:-false}' + PHOENIX_EVENTS_URL: '${PHOENIX_EVENTS_URL:-http://events:4000}' + SIDEKIQ_WEB_USER: '${SIDEKIQ_WEB_USER}' + SIDEKIQ_WEB_PASSWORD: '${SIDEKIQ_WEB_PASSWORD}' + # ProPay gateway (internal Docker network) + PROPAY_URL: '${PROPAY_URL:-http://propay:5555}' healthcheck: test: @@ -118,6 +165,8 @@ services: start_period: 60s depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy @@ -131,20 +180,45 @@ services: - coolify environment: RAILS_ENV: production - DATABASE_URL: '${DATABASE_URL}' + DATABASE_URL: 'postgresql://${POSTGRES_USER:-prostaff}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-prostaff_production}' REDIS_URL: 'redis://default:${REDIS_PASSWORD}@redis:6379/0' ELASTICSEARCH_URL: '${ELASTICSEARCH_URL:-http://elastic:9200}' RAILS_MASTER_KEY: '${RAILS_MASTER_KEY}' RIOT_API_KEY: '${RIOT_API_KEY}' + RIOT_GATEWAY_URL: '${RIOT_GATEWAY_URL}' + INTERNAL_JWT_SECRET: '${INTERNAL_JWT_SECRET}' + PANDASCORE_API_KEY: '${PANDASCORE_API_KEY}' + SCRAPER_API_URL: 'http://scraper-api:8000' + SCRAPER_API_KEY: '${SCRAPER_API_KEY}' + JWT_SECRET_KEY: '${JWT_SECRET_KEY}' SECRET_KEY_BASE: '${SECRET_KEY_BASE}' # HashID Configuration HASHID_SALT: '${HASHID_SALT}' HASHID_MIN_LENGTH: '${HASHID_MIN_LENGTH}' FRONTEND_URL: '${FRONTEND_URL}' + PROSTAFF_URL: '${PROSTAFF_URL:-https://prostaff.gg}' + SCRIMS_URL: '${SCRIMS_URL:-https://scrims.lol}' + ARENA_BR_URL: '${ARENA_BR_URL:-https://arena-br.vercel.app}' # Meilisearch (self-hosted β€” same Docker network) MEILISEARCH_URL: 'http://meilisearch:7700' MEILI_MASTER_KEY: '${MEILI_MASTER_KEY}' + # SMTP (email delivery) + SMTP_USERNAME: '${SMTP_USERNAME}' + SMTP_PASSWORD: '${SMTP_PASSWORD}' + SMTP_ADDRESS: '${SMTP_ADDRESS:-smtp.gmail.com}' + SMTP_PORT: '${SMTP_PORT:-587}' + SMTP_DOMAIN: '${SMTP_DOMAIN:-gmail.com}' + # ProPay gateway (internal Docker network) + PROPAY_URL: '${PROPAY_URL:-http://propay:5555}' + healthcheck: + test: ["CMD-SHELL", "grep -q sidekiq /proc/1/cmdline || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy api: @@ -241,6 +315,8 @@ services: start_period: 10s volumes: + postgres-data: + driver: local redis-data: driver: local meilisearch-data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a170c18..6170307 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,22 +3,20 @@ services: # Para produΓ§Γ£o/homologaΓ§Γ£o use Supabase (DATABASE_URL no .env) # Este container Γ© opcional - apenas para desenvolvimento offline ou testes postgres: - image: postgres:15-alpine + image: postgres:17-alpine environment: - POSTGRES_DB: ${POSTGRES_DB:-prostaff_api_development} - POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-prostaff_production} + POSTGRES_USER: ${POSTGRES_USER:-prostaff} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} volumes: - postgres_data:/var/lib/postgresql/data ports: - "${POSTGRES_PORT:-5432}:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prostaff} -d ${POSTGRES_DB:-prostaff_production}"] interval: 10s timeout: 5s retries: 5 - profiles: - - local-db # Use 'docker-compose --profile local-db up' para iniciar # Redis for Sidekiq, Rails Cache and Rate Limiting redis: diff --git a/docs-page/index.html b/docs-page/index.html index ed18b50..18dd78e 100644 --- a/docs-page/index.html +++ b/docs-page/index.html @@ -7,11 +7,6 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 7c5f546..9041756 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -16,6 +16,7 @@ paths: "/api/v1/admin/players": get: summary: List all players across all organizations (admin) + description: "Bypasses organization scoping β€” admin and owner roles see players from all organizations, including soft-deleted ones. Non-admin users see only their own org. Useful for cross-org audits and support workflows." tags: - Admin security: @@ -89,21 +90,97 @@ paths: type: integer with_access: type: integer + example: + message: Players retrieved successfully + data: + players: + - id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + real_name: Carlos Henrique + role: adc + status: active + solo_queue_tier: CHALLENGER + solo_queue_rank: I + solo_queue_lp: 842 + win_rate: 64.3 + player_access_enabled: true + - id: b2c3d4e5-f6a7-8901-bcde-f12345678901 + summoner_name: Grevthar + real_name: Gustavo Ferreira + role: support + status: active + solo_queue_tier: GRANDMASTER + solo_queue_rank: I + solo_queue_lp: 312 + win_rate: 58.1 + player_access_enabled: false + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 2 + has_next_page: false + has_prev_page: false + summary: + total: 2 + active: 2 + deleted: 0 + with_access: 1 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 401 + error: Unauthorized + message: Invalid or expired token '403': description: forbidden β€” admin/owner role required content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/admin/players \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/admin/players") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/players/{id}/soft_delete": post: summary: Soft-delete (archive) a player + description: "Sets deleted_at timestamp and changes status to 'removed'. The player record is preserved for audit purposes but hidden from standard queries. Action is logged to the audit trail. Use restore to undo." tags: - Admin security: @@ -124,12 +201,25 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player archived successfully + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + status: archived + deleted_at: '2026-04-21T10:30:00.000Z' + removed_reason: Player left the organization '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 404 + error: Not Found + message: Record not found requestBody: content: application/json: @@ -141,9 +231,54 @@ paths: example: Player left the organization required: - reason + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/admin/players//soft_delete \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason":"Player left the organization"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/admin/players/#{id}/soft_delete") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"reason" => "Player left the organization"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players/${id}/soft_delete`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "reason": "Player left the organization" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/players/{id}/restore": post: summary: Restore an archived player + description: "Clears the deleted_at timestamp and sets the player status to the value provided (defaults to 'inactive'). Action is logged. Cannot be used to bypass the 'removed' status path β€” use change_status for standard status transitions." tags: - Admin security: @@ -164,6 +299,15 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player restored successfully + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + status: active + deleted_at: + removed_reason: requestBody: content: application/json: @@ -180,9 +324,54 @@ paths: example: active required: - status + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/admin/players//restore \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status":"active"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/admin/players/#{id}/restore") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"status" => "active"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players/${id}/restore`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "status": "active" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/players/{id}/enable_access": post: summary: Enable player portal access + description: "Creates player-specific login credentials (email + password). Once enabled, the player can authenticate via POST /auth/player-login and receive a player-scoped JWT token with limited permissions. Action is logged to the audit trail." tags: - Admin security: @@ -203,12 +392,26 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player portal access enabled + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + player_access_enabled: true + player_email: ranger@teamprostaff.gg '422': description: validation error content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + email: + - has already been taken requestBody: content: application/json: @@ -226,9 +429,55 @@ paths: required: - email - password + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/admin/players//enable_access \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email":"player@team.gg","password":"SecurePass123!"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/admin/players/#{id}/enable_access") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"email" => "player@team.gg", "password" => "SecurePass123!"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players/${id}/enable_access`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "email": "player@team.gg", + "password": "SecurePass123!" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/players/{id}/disable_access": post: summary: Disable player portal access + description: "Revokes the player's ability to log in via the player portal. Existing active player tokens remain valid until expiry β€” the player cannot obtain new ones. Action is logged to the audit trail." tags: - Admin security: @@ -249,9 +498,57 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player portal access disabled + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + player_access_enabled: false + player_email: + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/admin/players//disable_access \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/admin/players/#{id}/disable_access") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players/${id}/disable_access`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/players/{id}/change_status": post: summary: Change the status of a non-archived player + description: "Transitions a player between active, inactive, benched, or trial statuses. Cannot set status to 'removed' β€” use the soft_delete endpoint for archival. Blocked on archived players. Action is logged." tags: - Admin security: @@ -274,12 +571,25 @@ paths: type: string player: "$ref": "#/components/schemas/Player" + example: + message: Player status updated to benched + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + status: benched '422': description: invalid status or player is archived content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + status: + - is not valid for archived players requestBody: content: application/json: @@ -296,9 +606,54 @@ paths: example: benched required: - status + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/admin/players//change_status \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status":"benched"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/admin/players/#{id}/change_status") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"status" => "benched"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players/${id}/change_status`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "status": "benched" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/players/{id}/transfer": post: summary: Transfer player to another organization + description: "Moves the player record to a new organization, sets status to 'inactive', and stores the previous organization_id for history. Runs in a database transaction. Action is logged to the audit trail." tags: - Admin security: @@ -323,6 +678,15 @@ paths: type: string new_organization: type: string + example: + message: Player transferred successfully + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + status: active + previous_organization: Team ProStaff BR + new_organization: LOUD Esports requestBody: content: application/json: @@ -339,9 +703,54 @@ paths: example: Trade agreement required: - new_organization_id + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/admin/players//transfer \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"new_organization_id":"org-uuid-here"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/admin/players/#{id}/transfer") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"new_organization_id" => "org-uuid-here"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/players/${id}/transfer`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "new_organization_id": "org-uuid-here" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/audit-logs": get: summary: List audit logs + description: "Read-only access to the platform's audit log. Restricted to admin and owner roles. Logs capture all create/update/delete actions on players, matches, and other entities, including old and new values." tags: - Admin security: @@ -410,9 +819,72 @@ paths: format: date-time pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + logs: + - id: c3d4e5f6-a7b8-9012-cdef-123456789012 + user: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + name: Coach Rafael + email: coach@teamprostaff.gg + organization: + id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + name: Team ProStaff BR + action: soft_delete + entity_type: Player + entity_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + old_values: + status: active + new_values: + status: archived + removed_reason: Contract expired + created_at: '2026-04-21T14:22:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/admin/audit-logs \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/admin/audit-logs") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/audit-logs`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/admin/organizations": get: summary: List all organizations (admin) + description: "Platform-level view of all organizations. Supports search (backed by Meilisearch with SQL fallback), filtering by tier and subscription status. Restricted to admin and owner roles." tags: - Admin security: @@ -477,9 +949,73 @@ paths: format: date-time pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + organizations: + - id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + name: Team ProStaff BR + slug: team-prostaff-br + region: BR + tier: semi_pro + subscription_plan: pro + subscription_status: active + users_count: 5 + created_at: '2025-01-10T09:00:00.000Z' + - id: e5f6a7b8-c9d0-1234-efab-345678901234 + name: LOUD Esports + slug: loud-esports + region: BR + tier: professional + subscription_plan: enterprise + subscription_status: active + users_count: 12 + created_at: '2024-06-01T00:00:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 2 + has_next_page: false + has_prev_page: false + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/admin/organizations \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/admin/organizations") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/admin/organizations`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/performance": get: summary: Get team performance analytics + description: "Computes aggregated KDA, win rate, objective control, and laning stats for the authenticated organization. Results are scoped to the current org and the specified date range. Data feeds the main analytics dashboard." tags: - Analytics security: @@ -551,15 +1087,77 @@ paths: type: array win_rate_trend: type: array + example: + data: + team_overview: + total_matches: 42 + wins: 28 + losses: 14 + win_rate: 66.67 + avg_game_duration: 1934 + avg_kda: 3.21 + avg_kills_per_game: 18.4 + avg_deaths_per_game: 9.2 + avg_assists_per_game: 31.7 + best_performers: + - summoner_name: Ranger + role: adc + avg_kda: 5.12 + avg_damage: 28400 + - summoner_name: Aegis + role: top + avg_kda: 3.87 + avg_damage: 19200 + win_rate_trend: + - week: '2026-04-14' + win_rate: 60.0 + - week: '2026-04-21' + win_rate: 72.7 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/performance \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/analytics/performance") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/performance`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/team-comparison": get: summary: Compare team players performance + description: "Side-by-side comparison of multiple players' stats within the organization. All players must belong to the current organization (multi-tenant enforced)." tags: - Analytics security: @@ -629,12 +1227,89 @@ paths: type: object role_rankings: type: object + example: + data: + players: + - player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + games_played: 38 + kda: 5.12 + avg_damage: 28400 + avg_gold: 14200 + avg_cs: 8.3 + avg_vision_score: 22.1 + avg_performance_score: 87.4 + multikills: + double: 14 + triple: 5 + quadra: 2 + penta: 1 + - player: + id: b2c3d4e5-f6a7-8901-bcde-f12345678901 + summoner_name: Grevthar + role: support + games_played: 40 + kda: 4.33 + avg_damage: 9800 + avg_gold: 9100 + avg_cs: 1.2 + avg_vision_score: 54.8 + avg_performance_score: 82.0 + multikills: + double: 2 + triple: 0 + quadra: 0 + penta: 0 + team_averages: + kda: 3.21 + avg_damage: 18600 + avg_vision_score: 36.4 + role_rankings: + adc: Ranger + support: Grevthar '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/team-comparison \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/analytics/team-comparison") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/team-comparison`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/champions/{player_id}": parameters: - name: player_id @@ -645,6 +1320,7 @@ paths: type: string get: summary: Get player champion statistics + description: "Returns champion-level performance breakdown for a specific player, including win rate, KDA, and games played per champion. Player must belong to the current organization." tags: - Analytics security: @@ -699,12 +1375,85 @@ paths: average_games: type: number format: float + example: + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + champion_stats: + - champion: Jinx + games_played: 22 + win_rate: 72.7 + avg_kda: 6.14 + mastery_grade: S + - champion: Caitlyn + games_played: 10 + win_rate: 60.0 + avg_kda: 4.22 + mastery_grade: A + - champion: Jhin + games_played: 6 + win_rate: 50.0 + avg_kda: 3.78 + mastery_grade: B + top_champions: + - Jinx + - Caitlyn + - Jhin + champion_diversity: + total_champions: 8 + highly_played: 3 + average_games: 4.75 '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 404 + error: Not Found + message: Record not found + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/champions/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + player_id = '' + + response = conn.get("/api/v1/analytics/champions/#{player_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const player_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/champions/${player_id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/kda-trend/{player_id}": parameters: - name: player_id @@ -715,6 +1464,7 @@ paths: type: string get: summary: Get player KDA trend over recent matches + description: "Returns a time-series of KDA values across the player's recent match history. Useful for spotting performance trends in the analytics dashboard." tags: - Analytics security: @@ -768,12 +1518,78 @@ paths: overall: type: number format: float + example: + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + kda_by_match: + - match_id: e5f6a7b8-c9d0-1234-efab-345678901234 + date: '2026-04-20T22:15:00.000Z' + kills: 12 + deaths: 2 + assists: 8 + kda: 10.0 + champion: Jinx + victory: true + - match_id: f6a7b8c9-d0e1-2345-fabc-456789012345 + date: '2026-04-19T21:30:00.000Z' + kills: 7 + deaths: 3 + assists: 11 + kda: 6.0 + champion: Caitlyn + victory: true + averages: + last_10_games: 5.87 + last_20_games: 4.92 + overall: 5.12 '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/kda-trend/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + player_id = '' + + response = conn.get("/api/v1/analytics/kda-trend/#{player_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const player_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/kda-trend/${player_id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/laning/{player_id}": parameters: - name: player_id @@ -784,6 +1600,7 @@ paths: type: string get: summary: Get player laning phase statistics + description: "Returns laning-phase metrics (CS difference at 10, gold difference, first blood rate) for a specific player. Player must belong to the current organization." tags: - Analytics security: @@ -826,12 +1643,72 @@ paths: type: integer cs_by_match: type: array + example: + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + cs_performance: + avg_cs_total: 278.4 + avg_cs_per_min: 8.3 + best_cs_game: 342 + worst_cs_game: 201 + gold_performance: + avg_gold: 14200 + best_gold_game: 17800 + worst_gold_game: 10400 + cs_by_match: + - match_id: e5f6a7b8-c9d0-1234-efab-345678901234 + date: '2026-04-20T22:15:00.000Z' + cs_total: 312 + cs_per_min: 9.1 + gold_earned: 15600 '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/laning/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + player_id = '' + + response = conn.get("/api/v1/analytics/laning/#{player_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const player_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/laning/${player_id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/teamfights/{player_id}": parameters: - name: player_id @@ -842,6 +1719,7 @@ paths: type: string get: summary: Get player teamfight performance + description: "Returns teamfight participation rate, multi-kill frequency, and kill contribution metrics for a specific player. Player must belong to the current organization." tags: - Analytics security: @@ -896,12 +1774,77 @@ paths: type: integer by_match: type: array + example: + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + damage_performance: + avg_damage_dealt: 28400 + avg_damage_taken: 18200 + best_damage_game: 42100 + avg_damage_per_min: 847 + participation: + avg_kills: 9.4 + avg_assists: 7.2 + avg_deaths: 3.1 + multikill_stats: + double_kills: 14 + triple_kills: 5 + quadra_kills: 2 + penta_kills: 1 + by_match: + - match_id: e5f6a7b8-c9d0-1234-efab-345678901234 + damage_dealt: 38200 + kills: 12 + deaths: 2 + assists: 8 '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/teamfights/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + player_id = '' + + response = conn.get("/api/v1/analytics/teamfights/#{player_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const player_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/teamfights/${player_id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/analytics/vision/{player_id}": parameters: - name: player_id @@ -912,6 +1855,7 @@ paths: type: string get: summary: Get player vision control statistics + description: "Returns vision score, wards placed, wards destroyed, and control ward purchase rate for a specific player. Player must belong to the current organization." tags: - Analytics security: @@ -964,15 +1908,78 @@ paths: format: float percentile: type: integer + example: + data: + player: + id: b2c3d4e5-f6a7-8901-bcde-f12345678901 + summoner_name: Grevthar + role: support + vision_stats: + avg_vision_score: 54.8 + avg_wards_placed: 12.4 + avg_wards_killed: 6.2 + best_vision_game: 78 + total_wards_placed: 496 + total_wards_killed: 248 + vision_per_min: 1.63 + by_match: + - match_id: e5f6a7b8-c9d0-1234-efab-345678901234 + vision_score: 62 + wards_placed: 15 + wards_killed: 8 + role_comparison: + player_avg: 54.8 + role_avg: 48.2 + percentile: 76 '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/analytics/vision/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + player_id = '' + + response = conn.get("/api/v1/analytics/vision/#{player_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const player_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/analytics/vision/${player_id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/register": post: summary: Register new organization and admin user + description: "Creates a new organization and an owner-role user in a single transaction. Sends a welcome email asynchronously via Sidekiq (falls back to synchronous if Redis is unavailable). Returns access and refresh tokens for immediate authentication. Registration starts a trial period." tags: - Authentication parameters: [] @@ -999,12 +2006,39 @@ paths: type: string expires_in: type: integer + example: + message: Organization and user created successfully + data: + user: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + email: admin@teamprostaff.gg + full_name: Rafael Costa + role: owner + notifications_enabled: true + created_at: '2026-04-21T10:00:00.000Z' + organization: + id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + name: Team ProStaff BR + slug: team-prostaff-br + region: BR + tier: semi_pro + access_token: EXAMPLE_ACCESS_TOKEN + refresh_token: EXAMPLE_REFRESH_TOKEN + expires_in: 86400 '422': description: validation errors content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + email: + - has already been taken + organization_name: + - can't be blank requestBody: content: application/json: @@ -1058,9 +2092,59 @@ paths: required: - organization - user + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/auth/register \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"organization":{"name":"Team Alpha","region":"BR","tier":"semi_pro"},"user":{"email":"admin@teamalpha.gg","password":"password123","full_name":"John Doe"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/auth/register") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"organization" => {"name" => "Team Alpha", "region" => "BR", "tier" => "semi_pro"}, "user" => {"email" => "admin@teamalpha.gg", "password" => "password123", "full_name" => "John Doe"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/register`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "organization": { + "name": "Team Alpha", + "region": "BR", + "tier": "semi_pro" + }, + "user": { + "email": "admin@teamalpha.gg", + "password": "password123", + "full_name": "John Doe" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/login": post: summary: Login user + description: "Validates credentials and returns JWT access and refresh tokens. Updates last_login_at and writes an audit log entry. The access token expires after 24 hours; use the refresh endpoint to obtain a new one." tags: - Authentication parameters: [] @@ -1087,12 +2171,40 @@ paths: type: string expires_in: type: integer + example: + message: Login successful + data: + user: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + email: admin@teamprostaff.gg + full_name: Rafael Costa + role: owner + notifications_enabled: true + last_login_at: '2026-04-21T10:00:00.000Z' + permissions: + can_manage_users: true + can_manage_players: true + can_view_analytics: true + is_admin_or_owner: true + organization: + id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + name: Team ProStaff BR + slug: team-prostaff-br + region: BR + tier: semi_pro + access_token: EXAMPLE_ACCESS_TOKEN + refresh_token: EXAMPLE_REFRESH_TOKEN + expires_in: 86400 '401': description: invalid credentials content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 401 + error: Unauthorized + message: Invalid email or password requestBody: content: application/json: @@ -1110,9 +2222,51 @@ paths: required: - email - password + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/auth/login \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@teamalpha.gg","password":"password123"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/auth/login") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"email" => "admin@teamalpha.gg", "password" => "password123"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/login`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "email": "admin@teamalpha.gg", + "password": "password123" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/refresh": post: summary: Refresh access token + description: "Issues a new access token using a valid refresh token. The old refresh token is blacklisted in Redis immediately after use. Refresh tokens expire after 7 days." tags: - Authentication parameters: [] @@ -1135,12 +2289,22 @@ paths: type: string expires_in: type: integer + example: + message: Token refreshed successfully + data: + access_token: EXAMPLE_ACCESS_TOKEN + refresh_token: EXAMPLE_REFRESH_TOKEN + expires_in: 86400 '401': description: invalid refresh token content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 401 + error: Unauthorized + message: Invalid or expired token requestBody: content: application/json: @@ -1152,9 +2316,50 @@ paths: example: eyJhbGciOiJIUzI1NiJ9... required: - refresh_token + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/auth/refresh \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"refresh_token":"eyJhbGciOiJIUzI1NiJ9..."}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/auth/refresh") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"refresh_token" => "eyJhbGciOiJIUzI1NiJ9..."} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/refresh`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "refresh_token": "eyJhbGciOiJIUzI1NiJ9..." + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/me": get: summary: Get current user info + description: "Returns the authenticated user's profile and their organization data. Requires a user-type JWT (player tokens are rejected by require_user_auth! concern)." tags: - Authentication security: @@ -1174,15 +2379,78 @@ paths: "$ref": "#/components/schemas/User" organization: "$ref": "#/components/schemas/Organization" + example: + data: + user: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + email: admin@teamprostaff.gg + full_name: Rafael Costa + role: owner + timezone: America/Sao_Paulo + language: pt-BR + notifications_enabled: true + permissions: + can_manage_users: true + can_manage_players: true + can_view_analytics: true + is_admin_or_owner: true + organization: + id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + name: Team ProStaff BR + slug: team-prostaff-br + region: BR + tier: semi_pro + statistics: + total_players: 5 + active_players: 5 + total_matches: 42 + recent_matches: 10 + total_users: 3 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/auth/me \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/auth/me") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/me`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/logout": post: summary: Logout user + description: "Blacklists the current access token in Redis immediately. Optionally blacklists the refresh token if included in the request body, preventing session reuse. The client must discard both tokens." tags: - Authentication security: @@ -1199,9 +2467,48 @@ paths: type: string data: type: object + example: + message: Logged out successfully + data: {} + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/auth/logout \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/auth/logout") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/forgot-password": post: summary: Request password reset + description: "Generates a one-time reset token and sends it via email. Always returns HTTP 200 regardless of whether the email exists, to prevent email enumeration. Supports both user and player accounts." tags: - Authentication parameters: [] @@ -1217,6 +2524,10 @@ paths: type: string data: type: object + example: + message: If this email is registered, a password reset link has been + sent + data: {} requestBody: content: application/json: @@ -1229,9 +2540,50 @@ paths: example: user@example.com required: - email + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/auth/forgot-password \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/auth/forgot-password") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"email" => "user@example.com"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/forgot-password`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "email": "user@example.com" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/auth/reset-password": post: summary: Reset password with token + description: "Validates the reset token (single-use, time-limited), updates the password, marks the token as used, and sends a confirmation email. Works for both user and player accounts." tags: - Authentication parameters: [] @@ -1247,6 +2599,9 @@ paths: type: string data: type: object + example: + message: Password reset successfully + data: {} '400': description: invalid or expired token content: @@ -1274,9 +2629,52 @@ paths: - token - password - password_confirmation + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/auth/reset-password \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"token":"reset_token_here","password":"newpassword123","password_confirmation":"newpassword123"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/auth/reset-password") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"token" => "reset_token_here", "password" => "newpassword123", "password_confirmation" => "newpassword123"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/auth/reset-password`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "token": "reset_token_here", + "password": "newpassword123", + "password_confirmation": "newpassword123" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive-matches": get: summary: List competitive matches + description: "Returns professional match history stored in the organization's competitive_matches table. Data originates from PandaScore. Supports filtering by tournament, region, patch, and date range." tags: - Competitive security: @@ -1315,15 +2713,84 @@ paths: type: object pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + matches: + - id: t0u1v2w3-x4y5-6789-zabc-234567890123 + tournament_name: CBLOL 2026 Split 1 + tournament_stage: Playoffs β€” Semifinal + our_team_name: Team ProStaff BR + opponent_team_name: paiN Gaming + victory: true + match_date: '2026-04-18' + match_format: bo5 + game_number: 3 + side: blue + patch_version: 14.8.1 + our_picks: + - Jinx + - Thresh + - Orianna + - Lee Sin + - Garen + opponent_picks: + - Caitlyn + - Nautilus + - Azir + - Vi + - Renekton + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive-matches \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/competitive-matches") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive-matches`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive-matches/{id}": get: summary: Get competitive match details + description: "Returns full details of a single competitive match stored locally. Match must belong to the current organization's competitive data." tags: - Competitive security: @@ -1344,9 +2811,80 @@ paths: properties: data: type: object + example: + data: + id: t0u1v2w3-x4y5-6789-zabc-234567890123 + tournament_name: CBLOL 2026 Split 1 + tournament_stage: Playoffs β€” Semifinal + our_team_name: Team ProStaff BR + opponent_team_name: paiN Gaming + victory: true + match_date: '2026-04-18' + match_format: bo5 + game_number: 3 + side: blue + patch_version: 14.8.1 + our_picks: + - Jinx + - Thresh + - Orianna + - Lee Sin + - Garen + opponent_picks: + - Caitlyn + - Nautilus + - Azir + - Vi + - Renekton + our_bans: + - Zed + - Katarina + - LeBlanc + has_complete_draft: true + meta_relevant: true + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive-matches/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/competitive-matches/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive-matches/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/pro-matches": get: summary: List all pro matches + description: "Returns professional match records stored in the database, sourced from PandaScore. Scoped to the current organization. Supports filtering by tournament, region, and patch." tags: - Competitive security: @@ -1395,9 +2933,45 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/pro-matches \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/competitive/pro-matches") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/pro-matches`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/pro-matches/upcoming": get: summary: Get upcoming pro matches + description: "Fetches upcoming matches directly from the PandaScore API (live data, not from local DB). Results may be cached by the PandascoreService. Rate-limited by PandaScore." tags: - Competitive security: @@ -1420,9 +2994,45 @@ paths: type: array items: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/pro-matches/upcoming \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/competitive/pro-matches/upcoming") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/pro-matches/upcoming`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/pro-matches/past": get: summary: Get past pro matches + description: "Fetches recent past matches directly from the PandaScore API (live data, not local DB). Results may be cached by the PandascoreService. Rate-limited by PandaScore." tags: - Competitive security: @@ -1445,9 +3055,45 @@ paths: type: array items: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/pro-matches/past \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/competitive/pro-matches/past") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/pro-matches/past`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/pro-matches/{id}": get: summary: Get pro match details + description: "Returns a specific professional match record from the local database. The match must belong to the current organization's competitive dataset." tags: - Competitive security: @@ -1468,9 +3114,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/pro-matches/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/competitive/pro-matches/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/pro-matches/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/pro-matches/refresh": post: summary: Refresh pro matches from PandaScore + description: "Clears the PandaScore service cache, forcing fresh data on the next fetch. Restricted to organization owners. Does not trigger a database import β€” use the import endpoint to persist matches." tags: - Competitive security: @@ -1485,9 +3171,45 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/competitive/pro-matches/refresh \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/competitive/pro-matches/refresh") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/pro-matches/refresh`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/pro-matches/import": post: summary: Import a specific pro match from PandaScore + description: "Fetches a single match from PandaScore by match_id and persists it to the local competitive_matches table. Import logic is currently a placeholder and will raise NotImplementedError." tags: - Competitive security: @@ -1514,9 +3236,50 @@ paths: example: '12345' required: - match_id + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/competitive/pro-matches/import \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"match_id":"12345"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/competitive/pro-matches/import") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"match_id" => "12345"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/pro-matches/import`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "match_id": "12345" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/draft-comparison": post: summary: Compare two team compositions + description: "Scores the submitted picks and bans against professional meta data via DraftComparatorService. Data is sourced from competitive matches stored in the database." tags: - Competitive security: @@ -1539,6 +3302,14 @@ paths: type: number analysis: type: string + example: + data: + team_a_score: 72.4 + team_b_score: 65.8 + analysis: Team A has a stronger teamfight composition with better + engage tools. Jinx + Lulu is a powerful late-game combination + that outscales Team B. Team B has better early pressure but lacks + sustained teamfight damage. requestBody: content: application/json: @@ -1568,9 +3339,63 @@ paths: required: - team_a - team_b + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/competitive/draft-comparison \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"team_a":["Jinx","Lulu","Thresh","Orianna","Garen"],"team_b":["Caitlyn","Zyra","Renekton","Azir","Lee Sin"]}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/competitive/draft-comparison") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"team_a" => ["Jinx", "Lulu", "Thresh", "Orianna", "Garen"], "team_b" => ["Caitlyn", "Zyra", "Renekton", "Azir", "Lee Sin"]} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/draft-comparison`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "team_a": [ + "Jinx", + "Lulu", + "Thresh", + "Orianna", + "Garen" + ], + "team_b": [ + "Caitlyn", + "Zyra", + "Renekton", + "Azir", + "Lee Sin" + ] + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/meta/{role}": get: summary: Get meta champions by role + description: "Returns the current meta picks and ban priorities for a specific in-game role, based on professional match data stored in the database." tags: - Competitive security: @@ -1601,15 +3426,66 @@ paths: type: number win_rate: type: number + example: + data: + - champion: Jinx + pick_rate: 34.2 + win_rate: 53.8 + - champion: Caitlyn + pick_rate: 28.7 + win_rate: 51.2 + - champion: Jhin + pick_rate: 22.1 + win_rate: 49.6 '422': description: invalid role content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/meta/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + role = '' + + response = conn.get("/api/v1/competitive/meta/#{role}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const role = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/meta/${role}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/composition-winrate": get: summary: Get composition win rate statistics + description: "Calculates historical win rate for a set of champions played together, based on competitive matches in the database. Results reflect the current patch filter if provided." tags: - Competitive security: @@ -1631,9 +3507,45 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/composition-winrate \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/competitive/composition-winrate") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/composition-winrate`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/competitive/counters": get: summary: Get champion counter suggestions + description: "Returns champion counter suggestions for a given opponent pick and role, derived from competitive match data in the database." tags: - Competitive security: @@ -1667,9 +3579,53 @@ paths: type: string win_rate_vs: type: number + example: + data: + - counter_champion: Caitlyn + win_rate_vs: 57.3 + - counter_champion: Miss Fortune + win_rate_vs: 54.8 + - counter_champion: Draven + win_rate_vs: 52.1 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/competitive/counters \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/competitive/counters") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/competitive/counters`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/constants": get: summary: Get application constants and enumerations + description: "Returns static application constants such as player roles, statuses, regions, and tier lists. These values are used to populate dropdowns and validate inputs on the frontend. No authentication required." tags: - Constants security: @@ -1750,9 +3706,45 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/constants \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/constants") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/constants`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/dashboard": get: summary: Get dashboard overview + description: "Aggregates stats, recent matches, upcoming events, active goals, and roster status into a single response. Stats sub-section is cached per organization for 5 minutes; other sections are live queries." tags: - Dashboard security: @@ -1813,15 +3805,95 @@ paths: type: object contracts_expiring: type: integer + example: + data: + stats: + total_players: 5 + active_players: 5 + total_matches: 42 + wins: 28 + losses: 14 + win_rate: 66.67 + recent_form: WWLWW + avg_kda: 3.21 + active_goals: 3 + completed_goals: 8 + upcoming_matches: 2 + recent_matches: + - id: e5f6a7b8-c9d0-1234-efab-345678901234 + match_type: scrim + opponent_name: LOUD Esports + victory: true + game_start: '2026-04-20T22:00:00.000Z' + duration_formatted: '32:14' + game_version: 14.8.1 + our_side: blue + upcoming_events: + - id: f6a7b8c9-d0e1-2345-fabc-456789012345 + title: Scrim vs. paiN Gaming + event_type: scrim + start_time: '2026-04-22T20:00:00.000Z' + active_goals: + - id: a7b8c9d0-e1f2-3456-abcd-567890123456 + title: Atingir 70% de win rate no patch 14.8 + status: in_progress + progress: 66.67 + roster_status: + by_role: + top: 1 + jungle: 1 + mid: 1 + adc: 1 + support: 1 + by_status: + active: 5 + benched: 0 + contracts_expiring: 1 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/dashboard \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/dashboard") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/dashboard`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/dashboard/stats": get: summary: Get dashboard statistics + description: "Returns key team metrics (win rate, KDA, player counts, goal counts). Cached per organization for 5 minutes in Redis. Cache is shared with the dashboard overview endpoint." tags: - Dashboard security: @@ -1862,9 +3934,58 @@ paths: type: integer upcoming_matches: type: integer + example: + data: + total_players: 5 + active_players: 5 + total_matches: 42 + wins: 28 + losses: 14 + win_rate: 66.67 + recent_form: WWLWW + avg_kda: 3.21 + active_goals: 3 + completed_goals: 8 + upcoming_matches: 2 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/dashboard/stats \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/dashboard/stats") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/dashboard/stats`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/dashboard/activities": get: summary: Get recent activities + description: "Returns the last 20 audit log entries for the current organization, formatted as activity feed items. Useful for the activity timeline widget." tags: - Dashboard security: @@ -1905,9 +4026,66 @@ paths: nullable: true count: type: integer + example: + data: + activities: + - id: b8c9d0e1-f2a3-4567-bcde-678901234567 + action: player_synced + entity_type: Player + entity_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + user: Coach Rafael + timestamp: '2026-04-21T14:00:00.000Z' + changes: + solo_queue_lp: + from: 800 + to: 842 + - id: c9d0e1f2-a3b4-5678-cdef-789012345678 + action: match_created + entity_type: Match + entity_id: e5f6a7b8-c9d0-1234-efab-345678901234 + user: Coach Rafael + timestamp: '2026-04-20T23:45:00.000Z' + changes: + count: 2 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/dashboard/activities \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/dashboard/activities") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/dashboard/activities`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/dashboard/schedule": get: summary: Get upcoming schedule + description: "Returns the next 10 scheduled events with start_time >= now, ordered chronologically. Feeds the dashboard schedule widget." tags: - Dashboard security: @@ -1927,9 +4105,62 @@ paths: type: array count: type: integer + example: + data: + events: + - id: f6a7b8c9-d0e1-2345-fabc-456789012345 + title: Scrim vs. paiN Gaming + event_type: scrim + start_time: '2026-04-22T20:00:00.000Z' + end_time: '2026-04-22T23:00:00.000Z' + status: scheduled + opponent_name: paiN Gaming + - id: a7b8c9d0-e1f2-3456-abcd-567890123456 + title: Treino β€” Revisao de draft + event_type: practice + start_time: '2026-04-23T18:00:00.000Z' + end_time: '2026-04-23T21:00:00.000Z' + status: scheduled + count: 2 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/dashboard/schedule \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/dashboard/schedule") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/dashboard/schedule`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/fantasy/waitlist": post: summary: Join the fantasy feature waitlist + description: "Registers an email address for the Fantasy League early-access waitlist. Idempotent β€” re-submitting an existing email returns 200 instead of 422. No authentication required." tags: - Fantasy security: @@ -1955,12 +4186,25 @@ paths: created_at: type: string format: date-time + example: + message: Added to fantasy waitlist + data: + id: d0e1f2a3-b4c5-6789-defa-890123456789 + email: coach@teamprostaff.gg + position: 47 + created_at: '2026-04-21T10:30:00.000Z' '422': description: already on waitlist or validation error content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + email: + - has already been added to the waitlist '401': description: unauthorized content: @@ -1983,9 +4227,50 @@ paths: example: Interested in using fantasy for team building decisions required: - email + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/fantasy/waitlist \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email":"coach@team.gg"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/fantasy/waitlist") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"email" => "coach@team.gg"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/fantasy/waitlist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "email": "coach@team.gg" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/fantasy/waitlist/stats": get: summary: Get fantasy waitlist statistics + description: "Returns total sign-up count and sign-ups in the last 7 days. Public endpoint, no authentication required." tags: - Fantasy security: @@ -2008,15 +4293,56 @@ paths: launch_target: type: integer nullable: true + example: + data: + total_signups: 312 + signups_this_week: 24 + launch_target: 500 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/fantasy/waitlist/stats \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/fantasy/waitlist/stats") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/fantasy/waitlist/stats`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/matches": get: summary: List all matches + description: "Returns paginated match history for the current organization. Results include a summary with total/win/loss counts. Cached per organization for 5 minutes; cache is invalidated on update or delete." tags: - Matches security: @@ -2121,14 +4447,98 @@ paths: type: object avg_duration: type: integer + example: + data: + matches: + - id: e5f6a7b8-c9d0-1234-efab-345678901234 + match_type: scrim + opponent_name: LOUD Esports + victory: true + game_start: '2026-04-20T22:00:00.000Z' + game_end: '2026-04-20T22:32:14.000Z' + game_duration: 1934 + duration_formatted: '32:14' + game_version: 14.8.1 + our_side: blue + our_score: + opponent_score: + our_towers: 9 + opponent_towers: 3 + our_dragons: 3 + opponent_dragons: 1 + kda_summary: + kills: 22 + deaths: 8 + assists: 41 + - id: f6a7b8c9-d0e1-2345-fabc-456789012345 + match_type: official + opponent_name: paiN Gaming + victory: false + game_start: '2026-04-18T20:00:00.000Z' + duration_formatted: '41:07' + game_version: 14.8.1 + our_side: red + pagination: + current_page: 1 + per_page: 20 + total_pages: 3 + total_count: 42 + has_next_page: true + has_prev_page: false + summary: + total: 42 + victories: 28 + defeats: 14 + win_rate: 66.67 + by_type: + scrim: 30 + official: 10 + tournament: 2 + avg_duration: 1922 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/matches \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/matches") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/matches`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a match + description: "Creates a match record for the current organization. Logs the creation to the audit trail. Does not trigger Riot API sync β€” use the import endpoint for Riot-sourced matches." tags: - Matches security: @@ -2149,12 +4559,31 @@ paths: properties: match: "$ref": "#/components/schemas/Match" + example: + message: Match created successfully + data: + match: + id: e5f6a7b8-c9d0-1234-efab-345678901234 + match_type: scrim + opponent_name: LOUD Esports + victory: true + game_start: '2026-04-20T22:00:00.000Z' + our_side: blue + game_version: 14.8.1 + has_vod: false + has_replay: false '422': description: invalid request content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + match_type: + - can't be blank requestBody: content: application/json: @@ -2208,6 +4637,50 @@ paths: - match_type - game_start - victory + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/matches \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"match":{"match_type":"string","game_start":"string","victory":true}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/matches") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"match" => {"match_type" => "string", "game_start" => "string", "victory" => true}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/matches`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "match": { + "match_type": "string", + "game_start": "string", + "victory": true + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/matches/{id}": parameters: - name: id @@ -2218,6 +4691,7 @@ paths: type: string get: summary: Show match details + description: "Returns match data with per-player stats, team composition, and MVP. Cached per match for 5 minutes. Cache is invalidated when the match is updated or deleted." tags: - Matches security: @@ -2244,14 +4718,110 @@ paths: mvp: "$ref": "#/components/schemas/Player" nullable: true + example: + data: + match: + id: e5f6a7b8-c9d0-1234-efab-345678901234 + match_type: scrim + opponent_name: LOUD Esports + victory: true + game_start: '2026-04-20T22:00:00.000Z' + game_duration: 1934 + duration_formatted: '32:14' + game_version: 14.8.1 + our_side: blue + our_towers: 9 + opponent_towers: 3 + our_dragons: 3 + opponent_dragons: 1 + our_barons: 1 + opponent_barons: 0 + player_stats: + - id: g7h8i9j0-k1l2-3456-mnop-901234567890 + role: adc + champion: Jinx + kills: 12 + deaths: 2 + assists: 8 + kda: 10.0 + cs_total: 312 + gold_earned: 15600 + damage_dealt_total: 38200 + vision_score: 22 + performance_score: 94.2 + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + team_composition: + our_picks: + - Jinx + - Thresh + - Orianna + - Lee Sin + - Garen + opponent_picks: + - Caitlyn + - Nautilus + - Azir + - Vi + - Renekton + mvp: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc '404': description: match not found content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 404 + error: Not Found + message: Record not found + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/matches/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/matches/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/matches/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a match + description: "Updates the match record and invalidates the match list and individual match caches. Action is logged to the audit trail." tags: - Matches security: @@ -2272,6 +4842,15 @@ paths: properties: match: "$ref": "#/components/schemas/Match" + example: + message: Match updated successfully + data: + match: + id: e5f6a7b8-c9d0-1234-efab-345678901234 + match_type: scrim + opponent_name: LOUD Esports + victory: true + notes: Great blue side execution. Thresh hooks were on point. requestBody: content: application/json: @@ -2289,8 +4868,57 @@ paths: type: string vod_url: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/matches/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"match":{"match_type":"string","victory":true,"notes":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/matches/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"match" => {"match_type" => "string", "victory" => true, "notes" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/matches/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "match": { + "match_type": "string", + "victory": true, + "notes": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a match + description: "Permanently deletes the match record and all associated player_match_stats. Invalidates the match list and individual match caches. Action is logged." tags: - Matches security: @@ -2305,6 +4933,47 @@ paths: properties: message: type: string + example: + message: Match deleted successfully + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/matches/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/matches/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/matches/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/matches/{id}/stats": parameters: - name: id @@ -2315,6 +4984,7 @@ paths: type: string get: summary: Get match statistics + description: "Returns aggregated team and per-player statistics for a specific match, including gold totals, damage dealt, and vision scores." tags: - Matches security: @@ -2352,9 +5022,66 @@ paths: avg_kda: type: number format: float + example: + data: + match: + id: e5f6a7b8-c9d0-1234-efab-345678901234 + match_type: scrim + opponent_name: LOUD Esports + victory: true + duration_formatted: '32:14' + team_stats: + total_kills: 22 + total_deaths: 8 + total_assists: 41 + total_gold: 72400 + total_damage: 142000 + total_cs: 1302 + total_vision_score: 182 + avg_kda: 7.88 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/matches//stats \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/matches/#{id}/stats") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/matches/${id}/stats`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/matches/import": post: summary: Import matches from Riot API + description: "Fetches and imports match history for a specific player from the Riot API using their PUUID. The player must already have a riot_puuid (sync from Riot first). Runs synchronously β€” for large imports consider the bulk_sync player endpoint." tags: - Matches security: @@ -2379,6 +5106,12 @@ paths: type: string count: type: integer + example: + message: Match import started + data: + job_id: sidekiq-abc123def456 + player_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + count: 20 '400': description: player missing PUUID content: @@ -2400,9 +5133,50 @@ paths: default: 20 required: - player_id + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/matches/import \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"player_id":"string"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/matches/import") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"player_id" => "string"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/matches/import`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "player_id": "string" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/messages": get: summary: List messages for the current user + description: "Returns the paginated conversation history between the authenticated user and the specified recipient. Both users must belong to the same organization. Supports cursor-based pagination via the `before` timestamp parameter." tags: - Messages security: @@ -2449,15 +5223,71 @@ paths: format: date-time pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + messages: + - id: u1v2w3x4-y5z6-7890-abcd-345678901234 + subject: Revisao de draft para amanha + body: Precisamos revisar o draft para o scrim de amanha contra + a paiN. Sugestao de picks para blue side. + sender: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + name: Rafael Costa + role: owner + read: false + created_at: '2026-04-21T14:00:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/messages") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/messages`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/messages/{id}": delete: summary: Delete a message + description: "Soft-deletes the message (sets deleted_at). Only the message author or an admin/owner can delete. Soft-deleted messages are excluded from future conversation queries." tags: - Messages security: @@ -2484,9 +5314,49 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/messages/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/messages/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/messages/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/notifications": get: summary: List notifications for the current user + description: "Returns paginated notifications for the authenticated user, newest first. Supports filtering by read status and type. Unread count is included in the response for badge display." tags: - Notifications security: @@ -2543,15 +5413,76 @@ paths: format: date-time pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + notifications: + - id: h8i9j0k1-l2m3-4567-nopq-012345678901 + title: Sync concluido + body: O jogador Ranger foi sincronizado com sucesso com o Riot + API. + notification_type: player_sync + read: false + read_at: + created_at: '2026-04-21T08:05:00.000Z' + - id: i9j0k1l2-m3n4-5678-opqr-123456789012 + title: Partida registrada + body: Vitoria contra LOUD Esports foi registrada no historico. + notification_type: match_result + read: true + read_at: '2026-04-21T09:00:00.000Z' + created_at: '2026-04-20T23:45:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 2 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/notifications \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/notifications") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/notifications`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/notifications/unread_count": get: summary: Get the count of unread notifications + description: "Returns the current unread notification count for the authenticated user. Lightweight endpoint suitable for polling from the frontend badge." tags: - Notifications security: @@ -2569,15 +5500,54 @@ paths: properties: unread_count: type: integer + example: + data: + unread_count: 3 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/notifications/unread_count \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/notifications/unread_count") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/notifications/unread_count`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/notifications/mark_all_as_read": patch: summary: Mark all notifications as read + description: "Bulk-marks all unread notifications for the current user as read using a single SQL UPDATE. Returns the count of affected records." tags: - Notifications security: @@ -2595,15 +5565,54 @@ paths: properties: updated_count: type: integer + example: + data: + updated_count: 3 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/notifications/mark_all_as_read \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.patch("/api/v1/notifications/mark_all_as_read") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/notifications/mark_all_as_read`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/notifications/{id}": get: summary: Get a specific notification + description: "Returns a single notification. The notification must belong to the authenticated user." tags: - Notifications security: @@ -2630,8 +5639,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/notifications/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/notifications/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/notifications/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a notification + description: "Permanently deletes the notification. The notification must belong to the authenticated user." tags: - Notifications security: @@ -2658,9 +5707,49 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/notifications/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/notifications/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/notifications/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/notifications/{id}/mark_as_read": patch: summary: Mark a notification as read + description: "Marks a single notification as read and sets the read_at timestamp. The notification must belong to the authenticated user." tags: - Notifications security: @@ -2687,9 +5776,49 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/notifications//mark_as_read \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/notifications/#{id}/mark_as_read") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/notifications/${id}/mark_as_read`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/players": get: summary: List all players + description: "Returns players scoped to the authenticated organization. Supports filtering by role, status, and line (position group). Results are cached per organization for 5 minutes. Soft-deleted players are excluded." tags: - Players security: @@ -2742,14 +5871,107 @@ paths: "$ref": "#/components/schemas/Player" pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + players: + - id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + real_name: Carlos Henrique + role: adc + status: active + solo_queue_tier: CHALLENGER + solo_queue_rank: I + solo_queue_lp: 842 + solo_queue_wins: 312 + solo_queue_losses: 178 + win_rate: 63.7 + main_champions: + - Jinx + - Caitlyn + - Jhin + needs_sync: false + player_access_enabled: false + - id: b2c3d4e5-f6a7-8901-bcde-f12345678901 + summoner_name: Grevthar + real_name: Gustavo Ferreira + role: support + status: active + solo_queue_tier: GRANDMASTER + solo_queue_rank: I + solo_queue_lp: 312 + win_rate: 58.1 + main_champions: + - Thresh + - Nautilus + - Alistar + needs_sync: false + player_access_enabled: false + - id: c3d4e5f6-a7b8-9012-cdef-234567890123 + summoner_name: Titan + real_name: Matheus Silva + role: top + status: active + solo_queue_tier: DIAMOND + solo_queue_rank: I + solo_queue_lp: 78 + win_rate: 55.4 + main_champions: + - Garen + - Darius + - Fiora + needs_sync: true + player_access_enabled: false + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 3 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/players \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/players") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/players`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a player + description: "Creates a player record within the current organization. Does not fetch Riot data β€” use sync_from_riot or the import endpoint to populate Riot-specific fields. Action is logged to the audit trail." tags: - Players security: @@ -2770,12 +5992,35 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player created successfully + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: RedBert + real_name: Roberto Silva + role: jungle + status: trial + solo_queue_tier: + solo_queue_rank: + solo_queue_lp: + needs_sync: true + player_access_enabled: false + created_at: '2026-04-21T10:00:00.000Z' '422': description: invalid request content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + summoner_name: + - can't be blank + role: + - is not included in the list requestBody: content: application/json: @@ -2814,6 +6059,49 @@ paths: required: - summoner_name - role + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/players \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"player":{"summoner_name":"string","role":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/players") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"player" => {"summoner_name" => "string", "role" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/players`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "player": { + "summoner_name": "string", + "role": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/players/{id}": parameters: - name: id @@ -2824,6 +6112,7 @@ paths: type: string get: summary: Show player details + description: "Returns player details cached per player for 5 minutes. Cache is invalidated on update or delete." tags: - Players security: @@ -2841,14 +6130,87 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + real_name: Carlos Henrique + role: adc + status: active + jersey_number: 7 + birth_date: '2002-03-15' + age: 24 + country: BR + solo_queue_tier: CHALLENGER + solo_queue_rank: I + solo_queue_lp: 842 + solo_queue_wins: 312 + solo_queue_losses: 178 + win_rate: 63.7 + current_rank: CHALLENGER I - 842 LP + main_champions: + - Jinx + - Caitlyn + - Jhin + sync_status: synced + last_sync_at: '2026-04-21T08:00:00.000Z' + needs_sync: false + player_access_enabled: false + created_at: '2025-01-10T09:00:00.000Z' + updated_at: '2026-04-21T08:00:00.000Z' '404': description: player not found content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 404 + error: Not Found + message: Record not found + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/players/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/players/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/players/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a player + description: "Updates player attributes and invalidates the player list and individual player caches. riot_puuid and riot_summoner_id cannot be set via this endpoint β€” they are managed by the Riot sync service. Action is logged." tags: - Players security: @@ -2869,6 +6231,16 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player updated successfully + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + real_name: Carlos Henrique + role: adc + status: active + updated_at: '2026-04-21T12:00:00.000Z' requestBody: content: application/json: @@ -2884,8 +6256,57 @@ paths: type: string status: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/players/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"player":{"summoner_name":"string","real_name":"string","status":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/players/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"player" => {"summoner_name" => "string", "real_name" => "string", "status" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/players/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "player": { + "summoner_name": "string", + "real_name": "string", + "status": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a player + description: "Permanently deletes the player record. Consider using admin soft_delete for archival instead. Invalidates caches. Action is logged." tags: - Players security: @@ -2900,6 +6321,47 @@ paths: properties: message: type: string + example: + message: Player deleted successfully + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/players/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/players/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/players/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/players/{id}/stats": parameters: - name: id @@ -2910,6 +6372,7 @@ paths: type: string get: summary: Get player statistics + description: "Returns aggregated stats from all matches for the player, including recent form, champion pool performance, and role-based breakdown." tags: - Players security: @@ -2935,9 +6398,83 @@ paths: type: array performance_by_role: type: array + example: + data: + player: + id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + overall: + total_matches: 38 + wins: 24 + losses: 14 + win_rate: 63.16 + avg_kills: 9.4 + avg_deaths: 3.1 + avg_assists: 7.2 + avg_kda: 5.12 + avg_cs_per_min: 8.3 + avg_damage: 28400 + avg_vision_score: 22.1 + recent_form: + last_5: WWWLW + last_10_win_rate: 70.0 + champion_pool: + - champion: Jinx + games_played: 22 + win_rate: 72.7 + avg_kda: 6.14 + - champion: Caitlyn + games_played: 10 + win_rate: 60.0 + avg_kda: 4.22 + performance_by_role: + - role: adc + games: 38 + win_rate: 63.16 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/players//stats \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/players/#{id}/stats") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/players/${id}/stats`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/profile": get: summary: Get current user profile + description: "Returns the full profile of the authenticated user, including notification preferences and organization data." tags: - Profile security: @@ -2972,14 +6509,70 @@ paths: created_at: type: string format: date-time + example: + data: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + name: Rafael Costa + email: admin@teamprostaff.gg + role: owner + avatar_url: + organization: + id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + name: Team ProStaff BR + slug: team-prostaff-br + region: BR + tier: semi_pro + notification_preferences: + email_match_results: true + email_scrim_reminders: true + email_player_updates: false + push_match_results: true + push_scrim_reminders: true + created_at: '2025-01-10T09:00:00.000Z' '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/profile \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/profile") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/profile`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update current user profile + description: "Updates the authenticated user's profile fields. Email and role changes are restricted and may require additional permissions." tags: - Profile security: @@ -2995,12 +6588,26 @@ paths: properties: data: "$ref": "#/components/schemas/User" + example: + message: Profile updated successfully + data: + id: d4e5f6a7-b8c9-0123-defa-234567890123 + email: admin@teamprostaff.gg + full_name: Rafael Costa + role: owner + updated_at: '2026-04-21T12:00:00.000Z' '422': description: validation error content: application/json: schema: "$ref": "#/components/schemas/Error" + example: + status: 422 + error: Unprocessable Entity + errors: + email: + - is not valid '401': description: unauthorized content: @@ -3026,9 +6633,54 @@ paths: avatar_url: type: string nullable: true + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/profile \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"user":{"name":"John Doe","email":"john@team.gg","avatar_url":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.patch("/api/v1/profile") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"user" => {"name" => "John Doe", "email" => "john@team.gg", "avatar_url" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/profile`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "user": { + "name": "John Doe", + "email": "john@team.gg", + "avatar_url": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/profile/password": patch: summary: Change current user password + description: "Updates the authenticated user's password. Requires the current password for verification. Does not invalidate existing tokens." tags: - Profile security: @@ -3044,6 +6696,8 @@ paths: properties: message: type: string + example: + message: Password changed successfully '422': description: validation error β€” wrong current password or mismatch content: @@ -3072,9 +6726,52 @@ paths: - current_password - password - password_confirmation + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/profile/password \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"current_password":"OldPass123!","password":"NewPass456!","password_confirmation":"NewPass456!"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.patch("/api/v1/profile/password") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"current_password" => "OldPass123!", "password" => "NewPass456!", "password_confirmation" => "NewPass456!"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/profile/password`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "current_password": "OldPass123!", + "password": "NewPass456!", + "password_confirmation": "NewPass456!" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/profile/notifications": patch: summary: Update notification preferences + description: "Saves the user's notification preference settings (email, in-app, push). Changes take effect immediately for future notifications." tags: - Profile security: @@ -3090,6 +6787,14 @@ paths: properties: data: type: object + example: + message: Notification preferences updated + data: + email_match_results: true + email_scrim_reminders: true + email_player_updates: false + push_match_results: true + push_scrim_reminders: true '422': description: validation error content: @@ -3120,9 +6825,54 @@ paths: push_scrim_reminders: type: boolean example: true + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/profile/notifications \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"notification_preferences":{"email_match_results":true,"email_scrim_reminders":true,"email_player_updates":true}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.patch("/api/v1/profile/notifications") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"notification_preferences" => {"email_match_results" => true, "email_scrim_reminders" => true, "email_player_updates" => true}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/profile/notifications`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "notification_preferences": { + "email_match_results": true, + "email_scrim_reminders": true, + "email_player_updates": true + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/champions": get: summary: Get champions ID map + description: "Returns a map of champion integer IDs to champion names, sourced from the Data Dragon CDN cache. Public endpoint, no authentication required. Champions/items/spells are cached in Redis." tags: - Riot Data responses: @@ -3140,12 +6890,57 @@ paths: type: object count: type: integer + example: + data: + champions: + '1': Annie + '2': Olaf + '222': Jinx + '412': Thresh + '64': Lee Sin + '86': Garen + count: 167 '503': description: service unavailable content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-data/champions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/riot-data/champions") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/champions`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/champions/{champion_key}": parameters: - name: champion_key @@ -3156,6 +6951,7 @@ paths: type: string get: summary: Get champion details by key + description: "Returns full champion data for a specific champion key (e.g. 'Ahri', 'LeeSin'). Sourced from Data Dragon cache. Public endpoint." tags: - Riot Data responses: @@ -3171,15 +6967,70 @@ paths: properties: champion: type: object + example: + data: + champion: + id: Jinx + key: '222' + name: Jinx + title: the Loose Cannon + image: + full: Jinx.png + sprite: champion4.png + tags: + - Marksman + stats: + attackdamage: 57.0 + attackrange: 525.0 '404': description: champion not found content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-data/champions/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + champion_key = '' + + response = conn.get("/api/v1/riot-data/champions/#{champion_key}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const champion_key = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/champions/${champion_key}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/all-champions": get: summary: Get all champions details + description: "Returns the full champion roster with all attributes from Data Dragon. Requires authentication. Larger payload than the ID map endpoint." tags: - Riot Data security: @@ -3207,9 +7058,45 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-data/all-champions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/riot-data/all-champions") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/all-champions`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/items": get: summary: Get all items + description: "Returns all current items from the Data Dragon CDN cache. Public endpoint, no authentication required." tags: - Riot Data responses: @@ -3233,9 +7120,45 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-data/items \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/riot-data/items") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/items`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/summoner-spells": get: summary: Get all summoner spells + description: "Returns all summoner spells from the Data Dragon CDN cache. Public endpoint, no authentication required." tags: - Riot Data security: @@ -3261,9 +7184,45 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-data/summoner-spells \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/riot-data/summoner-spells") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/summoner-spells`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/version": get: summary: Get current Data Dragon version + description: "Returns the current League of Legends patch version cached from Data Dragon. Public endpoint. Used to validate cache freshness." tags: - Riot Data responses: @@ -3279,15 +7238,54 @@ paths: properties: version: type: string + example: + data: + version: 14.8.1 '503': description: service unavailable content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-data/version \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/riot-data/version") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/version`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/clear-cache": post: summary: Clear Data Dragon cache + description: "Clears the local Data Dragon Redis cache, forcing fresh data on next request. Restricted to admin/owner roles. Does not re-fetch data β€” use update-cache to warm the cache immediately." tags: - Riot Data security: @@ -3305,15 +7303,54 @@ paths: properties: message: type: string + example: + data: + message: Data Dragon cache cleared successfully '403': description: forbidden content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/riot-data/clear-cache \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/riot-data/clear-cache") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/clear-cache`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-data/update-cache": post: summary: Update Data Dragon cache + description: "Clears and re-warms the Data Dragon cache by fetching champions, items, and summoner spells from the CDN. Restricted to admin/owner roles. Response includes counts of fetched entities." tags: - Riot Data security: @@ -3342,15 +7379,59 @@ paths: type: integer summoner_spells: type: integer + example: + data: + message: Data Dragon cache updated successfully + version: 14.8.1 + data: + champions: 167 + items: 248 + summoner_spells: 20 '403': description: forbidden content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/riot-data/update-cache \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/riot-data/update-cache") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-data/update-cache`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/riot-integration/sync-status": get: summary: Get Riot API synchronization status + description: "Returns sync status statistics for all players in the current organization, including counts by sync_status (success/pending/error) and a list of the 10 most recently synced players." tags: - Riot Integration security: @@ -3408,15 +7489,73 @@ paths: - pending - success - error + example: + data: + stats: + total_players: 5 + synced_players: 4 + pending_sync: 1 + failed_sync: 0 + recently_synced: 4 + needs_sync: 1 + recent_syncs: + - id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + last_sync_at: '2026-04-21T08:00:00.000Z' + sync_status: success + - id: b2c3d4e5-f6a7-8901-bcde-f12345678901 + summoner_name: Grevthar + last_sync_at: '2026-04-21T08:01:00.000Z' + sync_status: success + - id: c3d4e5f6-a7b8-9012-cdef-234567890123 + summoner_name: Titan + last_sync_at: + sync_status: pending '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/riot-integration/sync-status \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/riot-integration/sync-status") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/riot-integration/sync-status`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/rosters/free-agents": get: summary: List free agents available for hiring + description: "Returns players with no organization (free agents). Supports filtering by role, tier, and search via Meilisearch (falls back to SQL ILIKE). Includes previous organization name for context." tags: - Rosters security: @@ -3459,15 +7598,74 @@ paths: "$ref": "#/components/schemas/Player" pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + free_agents: + - id: j0k1l2m3-n4o5-6789-pqrs-234567890123 + summoner_name: Aegis + real_name: Felipe Andrade + role: top + status: active + solo_queue_tier: MASTER + solo_queue_rank: I + solo_queue_lp: 124 + win_rate: 52.3 + main_champions: + - Garen + - Malphite + - Renekton + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/rosters/free-agents \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/rosters/free-agents") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/rosters/free-agents`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/rosters/statistics": get: summary: Get roster statistics for the organization + description: "Returns active, inactive, benched, and removed player counts, roster composition by role, and count of contracts expiring within 30 days." tags: - Rosters security: @@ -3498,15 +7696,65 @@ paths: nullable: true roles_breakdown: type: object + example: + data: + total_players: 5 + active: 5 + inactive: 0 + benched: 0 + trial: 0 + avg_age: 21.4 + roles_breakdown: + top: 1 + jungle: 1 + mid: 1 + adc: 1 + support: 1 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/rosters/statistics \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/rosters/statistics") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/rosters/statistics`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/rosters/hire/{scouting_target_id}": post: summary: Hire a scouted player to the roster + description: "Converts a global scouting target into an organization player via RosterManagementService. Requires coach role or above. Contract start/end dates are required." tags: - Rosters security: @@ -3530,6 +7778,16 @@ paths: properties: player: "$ref": "#/components/schemas/Player" + example: + message: Player hired successfully + data: + player: + id: j0k1l2m3-n4o5-6789-pqrs-234567890123 + summoner_name: Aegis + real_name: Felipe Andrade + role: top + status: trial + created_at: '2026-04-21T10:00:00.000Z' '404': description: scouting target not found content: @@ -3560,9 +7818,54 @@ paths: nullable: true required: - status + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/rosters/hire/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"status":"trial"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + scouting_target_id = '' + + response = conn.post("/api/v1/rosters/hire/#{scouting_target_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"status" => "trial"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const scouting_target_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/rosters/hire/${scouting_target_id}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "status": "trial" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/rosters/remove/{player_id}": post: summary: Remove a player from the roster + description: "Marks the player as removed and converts them back to a scouting target for the free agent pool. Requires coach role or above. Action is logged to the audit trail." tags: - Rosters security: @@ -3600,9 +7903,54 @@ paths: example: Contract ended required: - reason + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/rosters/remove/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason":"Contract ended"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + player_id = '' + + response = conn.post("/api/v1/rosters/remove/#{player_id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"reason" => "Contract ended"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const player_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/rosters/remove/${player_id}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "reason": "Contract ended" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/schedules": get: summary: List all schedules + description: "Returns schedule events for the current organization. Supports filtering by event_type, status, date range, and time period shortcuts (upcoming, today, this_week)." tags: - Schedules security: @@ -3691,14 +8039,78 @@ paths: "$ref": "#/components/schemas/Schedule" pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + schedules: + - id: f6a7b8c9-d0e1-2345-fabc-456789012345 + event_type: scrim + title: Scrim vs. paiN Gaming + start_time: '2026-04-22T20:00:00.000Z' + end_time: '2026-04-22T23:00:00.000Z' + status: scheduled + opponent_name: paiN Gaming + location: + all_day: false + is_recurring: false + - id: a7b8c9d0-e1f2-3456-abcd-567890123456 + event_type: practice + title: Treino β€” Revisao de draft + start_time: '2026-04-23T18:00:00.000Z' + end_time: '2026-04-23T21:00:00.000Z' + status: scheduled + all_day: false + is_recurring: false + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 2 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/schedules \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/schedules") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/schedules`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a schedule + description: "Creates a new schedule event. Action is logged. The event can optionally reference an existing match via match_id." tags: - Schedules security: @@ -3719,6 +8131,19 @@ paths: properties: schedule: "$ref": "#/components/schemas/Schedule" + example: + message: Schedule created successfully + data: + schedule: + id: f6a7b8c9-d0e1-2345-fabc-456789012345 + event_type: scrim + title: Scrim vs. paiN Gaming + start_time: '2026-04-22T20:00:00.000Z' + end_time: '2026-04-22T23:00:00.000Z' + status: scheduled + opponent_name: paiN Gaming + all_day: false + is_recurring: false '422': description: invalid request content: @@ -3800,6 +8225,50 @@ paths: - title - start_time - end_time + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/schedules \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"schedule":{"event_type":"string","title":"string","start_time":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/schedules") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"schedule" => {"event_type" => "string", "title" => "string", "start_time" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/schedules`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "schedule": { + "event_type": "string", + "title": "string", + "start_time": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/schedules/{id}": parameters: - name: id @@ -3810,6 +8279,7 @@ paths: type: string get: summary: Show schedule details + description: "Returns a single schedule event. Scoped to the current organization." tags: - Schedules security: @@ -3833,8 +8303,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/schedules/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/schedules/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/schedules/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a schedule + description: "Updates an existing schedule event. Action is logged." tags: - Schedules security: @@ -3874,8 +8384,57 @@ paths: type: string meeting_url: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/schedules/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"schedule":{"title":"string","description":"string","status":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/schedules/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"schedule" => {"title" => "string", "description" => "string", "status" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/schedules/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "schedule": { + "title": "string", + "description": "string", + "status": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a schedule + description: "Permanently deletes the schedule event. Action is logged." tags: - Schedules security: @@ -3890,9 +8449,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/schedules/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/schedules/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/schedules/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scouting/players": get: summary: List all scouting targets + description: "Returns global scouting targets from the shared pool. Use `my_watchlist=true` to filter to the current org's watchlist. Supports filtering by role, region, tier range, and LP range. Search is backed by Meilisearch with SQL fallback." tags: - Scouting security: @@ -4000,14 +8599,74 @@ paths: type: integer total_pages: type: integer + example: + data: + players: + - id: k1l2m3n4-o5p6-7890-qrst-345678901234 + summoner_name: Aegis + real_name: Felipe Andrade + role: top + region: BR + status: watching + priority: high + current_tier: MASTER + current_rank: I + current_lp: 124 + current_rank_display: MASTER I - 124 LP + champion_pool: + - Garen + - Malphite + - Renekton + in_watchlist: true + priority_text: High Priority + total: 1 + page: 1 + per_page: 20 + total_pages: 1 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scouting/players \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scouting/players") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/players`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a scouting target + description: "Creates or finds a global scouting target (deduplicated by riot_puuid) and adds it to the current organization's watchlist. Runs in a transaction. Action is logged." tags: - Scouting security: @@ -4028,6 +8687,20 @@ paths: properties: scouting_target: "$ref": "#/components/schemas/ScoutingTarget" + example: + message: Scouting target created successfully + data: + scouting_target: + id: k1l2m3n4-o5p6-7890-qrst-345678901234 + summoner_name: Aegis + real_name: Felipe Andrade + role: top + region: BR + status: watching + priority: high + current_tier: MASTER + in_watchlist: true + created_at: '2026-04-21T10:00:00.000Z' '422': description: invalid request content: @@ -4115,6 +8788,50 @@ paths: - summoner_name - region - role + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/scouting/players \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"scouting_target":{"summoner_name":"string","region":"string","role":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/scouting/players") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"scouting_target" => {"summoner_name" => "string", "region" => "string", "role" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/players`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "scouting_target": { + "summoner_name": "string", + "region": "string", + "role": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/scouting/players/{id}": parameters: - name: id @@ -4125,6 +8842,7 @@ paths: type: string get: summary: Show scouting target details + description: "Returns a global scouting target with the current organization's watchlist metadata (priority, status, notes) if the org has watched this player." tags: - Scouting security: @@ -4148,8 +8866,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scouting/players/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/scouting/players/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/players/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a scouting target + description: "Updates global target data and/or the organization's watchlist entry in a single transaction. Both sets of fields can be updated in one request." tags: - Scouting security: @@ -4187,8 +8945,57 @@ paths: type: string contact_notes: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/scouting/players/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"scouting_target":{"status":"string","priority":"string","scouting_notes":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/scouting/players/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"scouting_target" => {"status" => "string", "priority" => "string", "scouting_notes" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/players/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "scouting_target": { + "status": "string", + "priority": "string", + "scouting_notes": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a scouting target + description: "Removes the scouting target from the current organization's watchlist. Does not delete the global scouting target record, which may be used by other organizations." tags: - Scouting security: @@ -4203,9 +9010,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/scouting/players/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/scouting/players/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/players/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scouting/regions": get: summary: Get scouting statistics by region + description: "Returns aggregated scouting target counts and metrics grouped by region. Useful for analytics on the scouting geographic distribution." tags: - Scouting security: @@ -4236,9 +9083,45 @@ paths: type: object avg_tier: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scouting/regions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scouting/regions") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/regions`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scouting/watchlist": get: summary: Get watchlist (active scouting targets) + description: "Returns high-priority and critical-priority scouting targets in the current organization's watchlist with status in 'watching', 'contacted', or 'negotiating'. Ordered by priority descending." tags: - Scouting security: @@ -4274,9 +9157,60 @@ paths: type: integer high_priority: type: integer + example: + data: + watchlist: + - id: k1l2m3n4-o5p6-7890-qrst-345678901234 + summoner_name: Aegis + role: top + region: BR + status: watching + priority: high + current_rank_display: MASTER I - 124 LP + in_watchlist: true + stats: + total: 1 + needs_review: 0 + high_priority: 1 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scouting/watchlist \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scouting/watchlist") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scouting/watchlist`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/scrims": get: summary: List all scrims + description: "Returns paginated scrims for the current organization. Supports filtering by scrim_type, focus_area, opponent_team_id, and status (upcoming/past/completed/in_progress)." tags: - Scrims security: @@ -4324,14 +9258,72 @@ paths: type: object pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + scrims: + - id: l2m3n4o5-p6q7-8901-rstu-456789012345 + scheduled_at: '2026-04-22T20:00:00.000Z' + status: upcoming + opponent_team: + id: m3n4o5p6-q7r8-9012-stuv-567890123456 + name: paiN Gaming + tier: professional + region: BR + format: bo3 + games_planned: 3 + games_completed: 0 + win_rate: + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/scrims \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scrims/scrims") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a new scrim + description: "Creates a scrim for the current organization. If `opponent_team_name` is provided, finds or creates the opponent team record. Respects monthly scrim creation limits based on organization tier." tags: - Scrims security: @@ -4347,6 +9339,17 @@ paths: properties: data: type: object + example: + message: Scrim created successfully + data: + id: l2m3n4o5-p6q7-8901-rstu-456789012345 + scheduled_at: '2026-04-22T20:00:00.000Z' + status: upcoming + format: bo3 + games_planned: 3 + games_completed: 0 + opponent_name: paiN Gaming + created_at: '2026-04-21T10:00:00.000Z' '422': description: validation error content: @@ -4386,9 +9389,53 @@ paths: required: - scheduled_at - format + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/scrims/scrims \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"scrim":{"scheduled_at":"2026-03-01T18:00:00Z","format":"bo3"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/scrims/scrims") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"scrim" => {"scheduled_at" => "2026-03-01T18:00:00Z", "format" => "bo3"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "scrim": { + "scheduled_at": "2026-03-01T18:00:00Z", + "format": "bo3" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/scrims/calendar": get: summary: Get scrims calendar + description: "Returns scrims within a date range formatted for calendar display. Defaults to the current month if no date range is provided." tags: - Scrims security: @@ -4416,9 +9463,45 @@ paths: type: array items: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/scrims/calendar \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scrims/scrims/calendar") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims/calendar`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/scrims/analytics": get: summary: Get scrims analytics + description: "Computes scrim performance statistics via ScrimAnalyticsService, including overall stats, results by opponent, results by focus area, and improvement trends." tags: - Scrims security: @@ -4449,9 +9532,51 @@ paths: type: integer win_rate: type: number + example: + data: + total_scrims: 24 + wins: 16 + losses: 8 + win_rate: 66.67 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/scrims/analytics \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scrims/scrims/analytics") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims/analytics`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/scrims/{id}": get: summary: Get scrim details + description: "Returns full scrim details including game results. Scoped to the current organization." tags: - Scrims security: @@ -4478,8 +9603,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/scrims/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/scrims/scrims/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a scrim + description: "Updates the scrim record. Scoped to the current organization." tags: - Scrims security: @@ -4519,8 +9684,56 @@ paths: - draw - pending nullable: true + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/scrims/scrims/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"scrim":{"notes":"string","result":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/scrims/scrims/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"scrim" => {"notes" => "string", "result" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "scrim": { + "notes": "string", + "result": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a scrim + description: "Permanently deletes the scrim record. Returns 204 No Content." tags: - Scrims security: @@ -4541,9 +9754,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/scrims/scrims/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/scrims/scrims/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/scrims/{id}/add_game": post: summary: Add a game result to a scrim + description: "Records a single game result within a scrim session and updates opponent team statistics if an opponent team is linked. Use this to log BO3/BO5 results incrementally." tags: - Scrims security: @@ -4595,9 +9848,57 @@ paths: required: - result - side + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/scrims/scrims//add_game \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"game":{"result":"win","side":"blue"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/scrims/scrims/#{id}/add_game") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"game" => {"result" => "win", "side" => "blue"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/scrims/${id}/add_game`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "game": { + "result": "win", + "side": "blue" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/opponent-teams": get: summary: List opponent teams + description: "Returns global opponent teams (shared across organizations). Supports filtering by region, tier, league, and search via Meilisearch. Read access is available to all authenticated users." tags: - Scrims security: @@ -4630,8 +9931,44 @@ paths: type: object pagination: "$ref": "#/components/schemas/Pagination" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/opponent-teams \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/scrims/opponent-teams") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/opponent-teams`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create an opponent team + description: "Creates a new global opponent team record. The team becomes visible to all organizations. Tags are auto-generated from the name if not provided." tags: - Scrims security: @@ -4674,9 +10011,52 @@ paths: nullable: true required: - name + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/scrims/opponent-teams \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"opponent_team":{"name":"Team Rival"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/scrims/opponent-teams") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"opponent_team" => {"name" => "Team Rival"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/opponent-teams`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "opponent_team": { + "name": "Team Rival" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/opponent-teams/{id}": get: summary: Get opponent team details + description: "Returns opponent team details including win/loss statistics. Global resource β€” not scoped to the current organization." tags: - Scrims security: @@ -4697,8 +10077,48 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/opponent-teams/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/scrims/opponent-teams/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/opponent-teams/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update an opponent team + description: "Updates an opponent team record. Restricted to organizations that have scrims recorded against this team. Prevents modification of teams the org has never interacted with." tags: - Scrims security: @@ -4732,8 +10152,56 @@ paths: type: string notes: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/scrims/opponent-teams/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"opponent_team":{"name":"string","notes":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/scrims/opponent-teams/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"opponent_team" => {"name" => "string", "notes" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/opponent-teams/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "opponent_team": { + "name": "string", + "notes": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete an opponent team + description: "Deletes the opponent team only if no other organization has scrims against them. If other orgs reference this team, returns 422. Restricted to orgs that have scrims against this team." tags: - Scrims security: @@ -4754,9 +10222,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/scrims/opponent-teams/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/scrims/opponent-teams/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/opponent-teams/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/scrims/opponent-teams/{id}/scrim-history": get: summary: Get scrim history with a specific opponent + description: "Returns all scrims between the current organization and the specified opponent team, plus aggregated performance stats via ScrimAnalyticsService." tags: - Scrims security: @@ -4796,9 +10304,49 @@ paths: type: integer losses: type: integer + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/scrims/opponent-teams//scrim-history \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/scrims/opponent-teams/#{id}/scrim-history") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/scrims/opponent-teams/${id}/scrim-history`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/draft-plans": get: summary: List draft plans + description: "Returns draft plans for the current organization. Supports filtering by opponent, side (blue/red), patch version, and active status." tags: - Strategy security: @@ -4837,14 +10385,74 @@ paths: type: object pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + draft_plans: + - id: q7r8s9t0-u1v2-3456-wxyz-901234567890 + opponent_team: paiN Gaming + side: blue + side_display: Blue Side + patch_version: 14.8.1 + priority_picks: + - Jinx + - Thresh + our_bans: + - Zed + - Katarina + is_active: true + blind_pick_ready: true + total_scenarios: 3 + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/draft-plans \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/strategy/draft-plans") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a new draft plan + description: "Creates a draft plan with ban lists, priority picks, and if-then scenarios. Tracks created_by and updated_by user references. Action is logged." tags: - Strategy security: @@ -4860,6 +10468,21 @@ paths: properties: data: type: object + example: + message: Draft plan created successfully + data: + id: q7r8s9t0-u1v2-3456-wxyz-901234567890 + opponent_team: paiN Gaming + side: blue + side_display: Blue Side + priority_picks: + - Jinx + - Thresh + our_bans: + - Zed + - Katarina + is_active: false + created_at: '2026-04-21T10:00:00.000Z' '422': description: validation error content: @@ -4910,9 +10533,53 @@ paths: required: - name - side + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/strategy/draft-plans \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"draft_plan":{"name":"vs Tempo Storm β€” Blue Side","side":"blue"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/strategy/draft-plans") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"draft_plan" => {"name" => "vs Tempo Storm β€” Blue Side", "side" => "blue"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "draft_plan": { + "name": "vs Tempo Storm β€” Blue Side", + "side": "blue" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/draft-plans/{id}": get: summary: Get draft plan details + description: "Returns full draft plan including bans, priority picks, opponent comfort picks, and if-then scenarios. Scoped to the current organization." tags: - Strategy security: @@ -4939,8 +10606,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/draft-plans/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/strategy/draft-plans/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a draft plan + description: "Updates the draft plan and sets updated_by to the current user. Action is logged." tags: - Strategy security: @@ -4982,8 +10689,59 @@ paths: type: array items: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/strategy/draft-plans/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"draft_plan":{"name":"string","notes":"string","picks":["string"]}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/strategy/draft-plans/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"draft_plan" => {"name" => "string", "notes" => "string", "picks" => ["string"]}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "draft_plan": { + "name": "string", + "notes": "string", + "picks": [ + "string" + ] + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a draft plan + description: "Permanently deletes the draft plan. Action is logged." tags: - Strategy security: @@ -5004,9 +10762,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/strategy/draft-plans/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/strategy/draft-plans/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/draft-plans/{id}/analyze": post: summary: Analyze a draft plan + description: "Runs DraftAnalyzer against the draft plan's picks and bans, returning synergy scores, threat assessment, and suggested if-then responses based on stored meta data." tags: - Strategy security: @@ -5040,9 +10838,61 @@ paths: type: string score: type: number + example: + data: + strengths: + - Strong teamfight with Orianna and Thresh + - High poke damage with Jinx long range + - Good engage and peel for ADC + weaknesses: + - Weak against split-push compositions + - Limited early game pressure + win_condition: Scale to late game teamfights around Dragon/Baron. + Force 5v5 fights near objectives. + score: 78.4 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/strategy/draft-plans//analyze \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/strategy/draft-plans/#{id}/analyze") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans/${id}/analyze`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/draft-plans/{id}/activate": patch: summary: Activate a draft plan + description: "Sets is_active to true. Only one draft plan is typically active per opponent/side combination β€” verify uniqueness on the client before activating." tags: - Strategy security: @@ -5063,9 +10913,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/strategy/draft-plans//activate \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/strategy/draft-plans/#{id}/activate") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans/${id}/activate`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/draft-plans/{id}/deactivate": patch: summary: Deactivate a draft plan + description: "Sets is_active to false. The draft plan record is retained for historical reference." tags: - Strategy security: @@ -5086,9 +10976,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/strategy/draft-plans//deactivate \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/strategy/draft-plans/#{id}/deactivate") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/draft-plans/${id}/deactivate`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/tactical-boards": get: summary: List tactical boards + description: "Returns tactical board snapshots for the current organization. Supports filtering by match_id, scrim_id, and game_time." tags: - Strategy security: @@ -5121,8 +11051,61 @@ paths: type: object pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + tactical_boards: + - id: r8s9t0u1-v2w3-4567-xyza-012345678901 + title: Dragon Control Setup + auto_title: Board + game_time: 1200 + total_players: 10 + total_annotations: 5 + created_at: '2026-04-21T10:00:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/tactical-boards \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/strategy/tactical-boards") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/tactical-boards`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a tactical board + description: "Creates a tactical board snapshot capturing player positions, champion selections, and map annotations. Tracks created_by and updated_by. Action is logged." tags: - Strategy security: @@ -5158,9 +11141,52 @@ paths: description: JSON state of the board canvas required: - name + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/strategy/tactical-boards \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tactical_board":{"name":"Dragon Control Setup"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/strategy/tactical-boards") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"tactical_board" => {"name" => "Dragon Control Setup"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/tactical-boards`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "tactical_board": { + "name": "Dragon Control Setup" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/tactical-boards/{id}": get: summary: Get tactical board details + description: "Returns the full tactical board including map state, player positions, and annotations. Scoped to the current organization." tags: - Strategy security: @@ -5181,8 +11207,48 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/tactical-boards/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/strategy/tactical-boards/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/tactical-boards/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a tactical board + description: "Updates map state, annotations, or champion selections. Sets updated_by to the current user. Action is logged." tags: - Strategy security: @@ -5218,8 +11284,57 @@ paths: type: string board_data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/strategy/tactical-boards/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tactical_board":{"name":"string","description":"string","board_data":{}}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/strategy/tactical-boards/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"tactical_board" => {"name" => "string", "description" => "string", "board_data" => {}}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/tactical-boards/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "tactical_board": { + "name": "string", + "description": "string", + "board_data": {} + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a tactical board + description: "Permanently deletes the tactical board snapshot. Action is logged." tags: - Strategy security: @@ -5240,9 +11355,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/strategy/tactical-boards/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/strategy/tactical-boards/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/tactical-boards/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/tactical-boards/{id}/statistics": get: summary: Get tactical board usage statistics + description: "Returns usage statistics for the tactical board such as access frequency and edit history." tags: - Strategy security: @@ -5269,9 +11424,49 @@ paths: last_modified: type: string format: date-time + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/tactical-boards//statistics \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/strategy/tactical-boards/#{id}/statistics") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/tactical-boards/${id}/statistics`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/assets/champion/{champion_name}": get: summary: Get champion assets for the tactical board + description: "Returns champion splash art, icon, and loading screen asset URLs from Data Dragon CDN. Public endpoint β€” no authentication required." tags: - Strategy security: @@ -5300,9 +11495,49 @@ paths: type: string splash_url: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/assets/champion/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + champion_name = '' + + response = conn.get("/api/v1/strategy/assets/champion/#{champion_name}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const champion_name = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/assets/champion/${champion_name}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/strategy/assets/map": get: summary: Get Summoners Rift map assets + description: "Returns the Summoners Rift minimap and zone asset URLs for use in the tactical board canvas. Public endpoint β€” no authentication required." tags: - Strategy security: @@ -5324,9 +11559,45 @@ paths: type: integer height: type: integer + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/strategy/assets/map \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/strategy/assets/map") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/strategy/assets/map`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/tickets": get: summary: List user's support tickets + description: "Returns tickets for the current user. Admin and support_staff roles see all tickets across all users. Includes a summary of counts by status." tags: - Support security: @@ -5365,14 +11636,66 @@ paths: type: object pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + tickets: + - id: s9t0u1v2-w3x4-5678-yzab-123456789012 + subject: Cannot import matches from Riot API + category: bug + priority: high + status: open + created_at: '2026-04-21T10:00:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/support/tickets \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/support/tickets") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a support ticket + description: "Creates a support ticket and optionally invokes the ChatbotService (OpenAI) to generate automated suggestions if a description is provided. Triggers a TicketNotificationJob asynchronously via Sidekiq." tags: - Support security: @@ -5388,6 +11711,16 @@ paths: properties: data: type: object + example: + message: Support ticket created successfully + data: + id: s9t0u1v2-w3x4-5678-yzab-123456789012 + subject: Cannot import matches from Riot API + description: When I try to import matches, I get a 500 error. + category: bug + priority: high + status: open + created_at: '2026-04-21T10:00:00.000Z' '422': description: validation error content: @@ -5429,9 +11762,54 @@ paths: - subject - description - category + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/tickets \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"ticket":{"subject":"Cannot import matches from Riot API","description":"When I try to import matches, I get a 500 error.","category":"bug"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/support/tickets") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"ticket" => {"subject" => "Cannot import matches from Riot API", "description" => "When I try to import matches, I get a 500 error.", "category" => "bug"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "ticket": { + "subject": "Cannot import matches from Riot API", + "description": "When I try to import matches, I get a 500 error.", + "category": "bug" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/tickets/{id}": get: summary: Get support ticket details + description: "Returns full ticket details including messages and chatbot suggestions. Access restricted to the ticket owner, assigned staff, or admins." tags: - Support security: @@ -5452,8 +11830,48 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/support/tickets/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/support/tickets/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a support ticket + description: "Updates ticket priority or status. Access restricted to ticket owner, assigned staff, or admins." tags: - Support security: @@ -5492,8 +11910,56 @@ paths: - medium - high - urgent + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/support/tickets/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"ticket":{"description":"string","priority":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/support/tickets/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"ticket" => {"description" => "string", "priority" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "ticket": { + "description": "string", + "priority": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a support ticket + description: "Permanently deletes the ticket and all associated messages." tags: - Support security: @@ -5514,9 +11980,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/support/tickets/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/support/tickets/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/tickets/{id}/close": post: summary: Close a support ticket + description: "Transitions the ticket status to 'closed'. Restricted to ticket owner, assigned staff, or admins." tags: - Support security: @@ -5537,9 +12043,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/tickets//close \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/support/tickets/#{id}/close") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets/${id}/close`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/tickets/{id}/reopen": post: summary: Reopen a closed support ticket + description: "Transitions a closed ticket back to 'open'. Restricted to ticket owner, assigned staff, or admins." tags: - Support security: @@ -5560,9 +12106,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/tickets//reopen \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/support/tickets/#{id}/reopen") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets/${id}/reopen`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/tickets/{id}/messages": post: summary: Add a message to a support ticket + description: "Adds a message to an existing ticket thread. Message type is set to 'staff' for support staff users or 'user' for regular users. Access restricted to ticket participants." tags: - Support security: @@ -5597,9 +12183,56 @@ paths: example: Here is additional context about the issue. required: - body + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/tickets//messages \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message":{"body":"Here is additional context about the issue."}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/support/tickets/#{id}/messages") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"message" => {"body" => "Here is additional context about the issue."}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/tickets/${id}/messages`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "message": { + "body": "Here is additional context about the issue." + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/faq": get: summary: List all FAQs + description: "Returns published FAQs for the given locale (defaults to pt-BR). Search is backed by Meilisearch with SQL fallback. Public endpoint β€” no authentication required." tags: - Support security: @@ -5638,9 +12271,60 @@ paths: type: string helpful_count: type: integer + example: + data: + - slug: how-to-sync-riot-data + question: How do I sync my players with Riot API? + answer: Go to Players, select a player, and click "Sync from Riot + API". The sync process runs in the background and usually takes + 10-30 seconds. + category: players + helpful_count: 42 + - slug: how-to-import-matches + question: How do I import match history from Riot API? + answer: Go to Matches > Import and enter the player ID. You can + import up to 100 recent matches at once. + category: matches + helpful_count: 28 + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/support/faq \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/support/faq") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/support/faq`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/faq/{slug}": get: summary: Get a FAQ by slug + description: "Returns full FAQ content and increments the view counter. Public endpoint β€” no authentication required." tags: - Support security: @@ -5667,9 +12351,49 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/support/faq/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + slug = '' + + response = conn.get("/api/v1/support/faq/#{slug}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const slug = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/faq/${slug}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/faq/{slug}/helpful": post: summary: Mark a FAQ as helpful + description: "Increments the helpful_count for the FAQ. No authentication required. Used to collect helpfulness feedback." tags: - Support security: @@ -5690,9 +12414,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/faq//helpful \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + slug = '' + + response = conn.post("/api/v1/support/faq/#{slug}/helpful") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const slug = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/faq/${slug}/helpful`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/faq/{slug}/not-helpful": post: summary: Mark a FAQ as not helpful + description: "Increments the not_helpful_count for the FAQ. No authentication required. Used to identify FAQs that need improvement." tags: - Support security: @@ -5713,9 +12477,49 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/faq//not-helpful \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + slug = '' + + response = conn.post("/api/v1/support/faq/#{slug}/not-helpful") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const slug = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/faq/${slug}/not-helpful`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/staff/dashboard": get: summary: Support staff dashboard + description: "Returns real-time ticket queue metrics for support staff: open/in-progress/resolved counts, high-priority tickets, unassigned tickets, and personal queue. Restricted to support_staff and admin roles." tags: - Support β€” Staff security: @@ -5739,15 +12543,57 @@ paths: type: integer avg_response_time_hours: type: number + example: + data: + open_tickets: 5 + in_progress: 3 + resolved_today: 8 + avg_response_time_hours: 2.4 '403': description: forbidden β€” staff role required content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/support/staff/dashboard \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/support/staff/dashboard") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/support/staff/dashboard`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/staff/analytics": get: summary: Support analytics for staff + description: "Returns ticket volume, resolution rates, response times, and trending issues for a date range. Defaults to last 30 days. Restricted to support_staff and admin roles." tags: - Support β€” Staff security: @@ -5773,9 +12619,45 @@ paths: properties: data: type: object + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/support/staff/analytics \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/support/staff/analytics") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/support/staff/analytics`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/staff/tickets/{id}/assign": post: summary: Assign a ticket to a staff member + description: "Assigns the ticket to a specific user. The assignee must be a support_staff or admin. Action is logged to the audit trail." tags: - Support β€” Staff security: @@ -5808,9 +12690,54 @@ paths: example: user-uuid-here required: - assignee_id + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/staff/tickets//assign \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"assignee_id":"user-uuid-here"}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/support/staff/tickets/#{id}/assign") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"assignee_id" => "user-uuid-here"} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/staff/tickets/${id}/assign`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "assignee_id": "user-uuid-here" + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/support/staff/tickets/{id}/resolve": post: summary: Resolve a ticket + description: "Transitions the ticket to 'resolved' status with an optional resolution note. Action is logged to the audit trail. Restricted to support_staff and admin roles." tags: - Support β€” Staff security: @@ -5840,9 +12767,54 @@ paths: resolution_note: type: string example: Resolved by updating the API key. + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/support/staff/tickets//resolve \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"resolution_note":"Resolved by updating the API key."}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.post("/api/v1/support/staff/tickets/#{id}/resolve") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"resolution_note" => "Resolved by updating the API key."} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/support/staff/tickets/${id}/resolve`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "resolution_note": "Resolved by updating the API key." + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/team-goals": get: summary: List all team goals + description: "Returns goals for the current organization with a summary (counts by status/category, average progress). Supports filtering by status, category, player, and deadline proximity." tags: - Team Goals security: @@ -5963,14 +12935,100 @@ paths: avg_progress: type: number format: float + example: + data: + goals: + - id: a7b8c9d0-e1f2-3456-abcd-567890123456 + title: Atingir 70% de win rate no patch 14.8 + description: Meta de desempenho para o mes de abril + category: performance + metric_type: win_rate + target_value: 70.0 + current_value: 66.67 + status: in_progress + progress: 95.24 + start_date: '2026-04-01' + end_date: '2026-04-30' + days_remaining: 9 + is_overdue: false + is_team_goal: true + - id: b8c9d0e1-f2a3-4567-bcde-678901234567 + title: Titular Ranger atingir Diamond I + category: development + metric_type: rank + target_value: 1.0 + current_value: 3.0 + status: in_progress + progress: 25.0 + start_date: '2026-03-01' + end_date: '2026-06-30' + days_remaining: 70 + is_overdue: false + is_team_goal: false + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 2 + has_next_page: false + has_prev_page: false + summary: + total: 2 + by_status: + in_progress: 2 + completed: 0 + not_started: 0 + by_category: + performance: 1 + development: 1 + active_count: 2 + completed_count: 0 + overdue_count: 0 + avg_progress: 60.12 '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/team-goals \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/team-goals") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/team-goals`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a team goal + description: "Creates a team or individual player goal. Sets created_by to the current user. Action is logged." tags: - Team Goals security: @@ -5991,6 +13049,22 @@ paths: properties: goal: "$ref": "#/components/schemas/TeamGoal" + example: + message: Team goal created successfully + data: + goal: + id: a7b8c9d0-e1f2-3456-abcd-567890123456 + title: Atingir 70% de win rate no patch 14.8 + category: performance + metric_type: win_rate + target_value: 70.0 + current_value: 0.0 + status: not_started + progress: 0.0 + start_date: '2026-04-01' + end_date: '2026-04-30' + is_team_goal: true + created_at: '2026-04-01T00:00:00.000Z' '422': description: invalid request content: @@ -6066,6 +13140,50 @@ paths: - target_value - start_date - end_date + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/team-goals \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"team_goal":{"title":"string","category":"string","metric_type":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/team-goals") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"team_goal" => {"title" => "string", "category" => "string", "metric_type" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/team-goals`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "team_goal": { + "title": "string", + "category": "string", + "metric_type": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/team-goals/{id}": parameters: - name: id @@ -6076,6 +13194,7 @@ paths: type: string get: summary: Show team goal details + description: "Returns goal details including player and assignee references. Scoped to the current organization." tags: - Team Goals security: @@ -6099,8 +13218,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/team-goals/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/team-goals/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/team-goals/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a team goal + description: "Updates goal fields including progress value and status. Action is logged." tags: - Team Goals security: @@ -6143,8 +13302,57 @@ paths: type: integer notes: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/team-goals/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"team_goal":{"title":"string","description":"string","status":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/team-goals/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"team_goal" => {"title" => "string", "description" => "string", "status" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/team-goals/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "team_goal": { + "title": "string", + "description": "string", + "status": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a team goal + description: "Permanently deletes the goal. Action is logged." tags: - Team Goals security: @@ -6159,9 +13367,49 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/team-goals/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/team-goals/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/team-goals/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/team-members": get: summary: List all team members (staff) for the organization + description: "Returns all users in the current organization except the current user. Used by the messaging UI to populate the recipient list. Only accepts user-type JWT tokens (player tokens are rejected)." tags: - Team Members security: @@ -6215,15 +13463,73 @@ paths: format: date-time pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + team_members: + - id: d4e5f6a7-b8c9-0123-defa-234567890123 + name: Rafael Costa + email: admin@teamprostaff.gg + role: owner + avatar_url: + created_at: '2025-01-10T09:00:00.000Z' + - id: e5f6a7b8-c9d0-1234-efab-345678901234 + name: Bruno Lima + email: coach@teamprostaff.gg + role: coach + avatar_url: + created_at: '2025-01-15T09:00:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 2 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/team-members \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/team-members") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/team-members`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/vod-reviews": get: summary: List all VOD reviews + description: "Returns VOD review sessions for the current organization. Supports filtering by status, match_id, reviewer_id, and title search. Includes a timestamp count per review." tags: - VOD Reviews security: @@ -6276,14 +13582,73 @@ paths: "$ref": "#/components/schemas/VodReview" pagination: "$ref": "#/components/schemas/Pagination" + example: + data: + vod_reviews: + - id: n4o5p6q7-r8s9-0123-tuvw-678901234567 + title: Analise VOD β€” vs LOUD (vitoria blue side) + status: published + vod_url: https://www.youtube.com/watch?v=abc123 + vod_platform: youtube + summary: Excelente execucao de early game. Ranger dominou o lane + de bot. + timestamps_count: 8 + shared_with_players: true + tags: + - early_game + - bot_lane + created_at: '2026-04-21T14:00:00.000Z' + pagination: + current_page: 1 + per_page: 20 + total_pages: 1 + total_count: 1 + has_next_page: false + has_prev_page: false '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/vod-reviews \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.get("/api/v1/vod-reviews") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a VOD review + description: "Creates a VOD review session. Sets the reviewer to the current user. Action is logged." tags: - VOD Reviews security: @@ -6304,6 +13669,18 @@ paths: properties: vod_review: "$ref": "#/components/schemas/VodReview" + example: + message: VOD review created successfully + data: + vod_review: + id: n4o5p6q7-r8s9-0123-tuvw-678901234567 + title: Analise VOD β€” vs LOUD (vitoria blue side) + status: draft + vod_url: https://www.youtube.com/watch?v=abc123 + vod_platform: youtube + timestamps_count: 0 + shared_with_players: false + created_at: '2026-04-21T14:00:00.000Z' '422': description: invalid request content: @@ -6348,6 +13725,49 @@ paths: required: - title - vod_url + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/vod-reviews \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"vod_review":{"title":"string","vod_url":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + response = conn.post("/api/v1/vod-reviews") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"vod_review" => {"title" => "string", "vod_url" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "vod_review": { + "title": "string", + "vod_url": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/vod-reviews/{id}": parameters: - name: id @@ -6358,6 +13778,7 @@ paths: type: string get: summary: Show VOD review details + description: "Returns the full VOD review with all associated timestamps ordered by video position. Supports both UUID and HashID lookup." tags: - VOD Reviews security: @@ -6379,14 +13800,85 @@ paths: type: array items: "$ref": "#/components/schemas/VodTimestamp" + example: + data: + vod_review: + id: n4o5p6q7-r8s9-0123-tuvw-678901234567 + title: Analise VOD β€” vs LOUD (vitoria blue side) + status: published + vod_url: https://www.youtube.com/watch?v=abc123 + vod_platform: youtube + summary: Excelente execucao de early game. + timestamps_count: 2 + shared_with_players: true + tags: + - early_game + - bot_lane + timestamps: + - id: o5p6q7r8-s9t0-1234-uvwx-789012345678 + timestamp_seconds: 312 + formatted_timestamp: '05:12' + category: good_play + importance: high + title: Ranger pega double kill no lane swap + description: Excelente posicionamento e kiting para garantir dois + abates. + - id: p6q7r8s9-t0u1-2345-vwxy-890123456789 + timestamp_seconds: 1140 + formatted_timestamp: '19:00' + category: objective + importance: critical + title: Baron takedown com 5 players vivos + description: Setup perfeito de baron com visao completa antes + da luta. '404': description: VOD review not found content: application/json: schema: "$ref": "#/components/schemas/Error" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/vod-reviews/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.get("/api/v1/vod-reviews/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); patch: summary: Update a VOD review + description: "Updates the VOD review metadata. Action is logged." tags: - VOD Reviews security: @@ -6422,8 +13914,57 @@ paths: type: string status: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/vod-reviews/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"vod_review":{"title":"string","summary":"string","status":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/vod-reviews/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"vod_review" => {"title" => "string", "summary" => "string", "status" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "vod_review": { + "title": "string", + "summary": "string", + "status": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a VOD review + description: "Permanently deletes the VOD review and all associated timestamps. Action is logged." tags: - VOD Reviews security: @@ -6438,6 +13979,45 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/vod-reviews/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/vod-reviews/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); "/api/v1/vod-reviews/{vod_review_id}/timestamps": parameters: - name: vod_review_id @@ -6448,6 +14028,7 @@ paths: type: string get: summary: List timestamps for a VOD review + description: "Returns all timestamps for the VOD review ordered by position. Supports filtering by category, importance, and target player." tags: - VOD Reviews security: @@ -6481,8 +14062,48 @@ paths: type: array items: "$ref": "#/components/schemas/VodTimestamp" + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X GET https://api.prostaff.gg/api/v1/vod-reviews//timestamps \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + vod_review_id = '' + + response = conn.get("/api/v1/vod-reviews/#{vod_review_id}/timestamps") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const vod_review_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews/${vod_review_id}/timestamps`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); post: summary: Create a timestamp + description: "Adds a timestamped annotation to the VOD review. Sets created_by to the current user. Action is logged." tags: - VOD Reviews security: @@ -6503,6 +14124,19 @@ paths: properties: timestamp: "$ref": "#/components/schemas/VodTimestamp" + example: + message: Timestamp created successfully + data: + timestamp: + id: o5p6q7r8-s9t0-1234-uvwx-789012345678 + timestamp_seconds: 312 + formatted_timestamp: '05:12' + category: good_play + importance: high + title: Ranger pega double kill no lane swap + description: Excelente posicionamento e kiting para garantir dois + abates. + created_at: '2026-04-21T15:00:00.000Z' requestBody: content: application/json: @@ -6552,6 +14186,54 @@ paths: - title - category - importance + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X POST https://api.prostaff.gg/api/v1/vod-reviews//timestamps \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"vod_timestamp":{"timestamp_seconds":1,"title":"string","category":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + vod_review_id = '' + + response = conn.post("/api/v1/vod-reviews/#{vod_review_id}/timestamps") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"vod_timestamp" => {"timestamp_seconds" => 1, "title" => "string", "category" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const vod_review_id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-reviews/${vod_review_id}/timestamps`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "vod_timestamp": { + "timestamp_seconds": 1, + "title": "string", + "category": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); "/api/v1/vod-timestamps/{id}": parameters: - name: id @@ -6562,6 +14244,7 @@ paths: type: string patch: summary: Update a timestamp + description: "Updates a VOD timestamp annotation. Requires update access to the parent VOD review. Action is logged." tags: - VOD Reviews security: @@ -6597,8 +14280,57 @@ paths: type: string importance: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X PATCH https://api.prostaff.gg/api/v1/vod-timestamps/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"vod_timestamp":{"title":"string","description":"string","importance":"string"}}' + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.patch("/api/v1/vod-timestamps/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + req.body = {"vod_timestamp" => {"title" => "string", "description" => "string", "importance" => "string"}} + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-timestamps/${id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + "vod_timestamp": { + "title": "string", + "description": "string", + "importance": "string" + } + }) + }); + + const data = await response.json(); + console.log(data); delete: summary: Delete a timestamp + description: "Permanently deletes the timestamp annotation. Requires update access to the parent VOD review. Action is logged." tags: - VOD Reviews security: @@ -6613,6 +14345,45 @@ paths: properties: message: type: string + x-codeSamples: + - lang: Shell + label: cURL + source: |- + curl -X DELETE https://api.prostaff.gg/api/v1/vod-timestamps/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" + - lang: Ruby + label: Ruby (Faraday) + source: |- + require 'faraday' + + conn = Faraday.new('https://api.prostaff.gg') do |f| + f.request :json + f.response :json + end + + id = '' + + response = conn.delete("/api/v1/vod-timestamps/#{id}") do |req| + req.headers['Authorization'] = "Bearer #{token}" + end + + puts response.body + - lang: JavaScript + label: JavaScript (fetch) + source: |- + const id = ''; + + const response = await fetch(`https://api.prostaff.gg/api/v1/vod-timestamps/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + console.log(data); components: securitySchemes: bearerAuth: @@ -6657,10 +14428,45 @@ components: - coach - analyst - viewer + role_display: + type: string + nullable: true + avatar_url: + type: string + format: uri + nullable: true timezone: type: string + nullable: true language: type: string + nullable: true + notifications_enabled: + type: boolean + notification_preferences: + type: object + nullable: true + discord_user_id: + type: string + nullable: true + last_login_at: + type: string + format: date-time + nullable: true + last_login_display: + type: string + nullable: true + permissions: + type: object + properties: + can_manage_users: + type: boolean + can_manage_players: + type: boolean + can_view_analytics: + type: boolean + is_admin_or_owner: + type: boolean created_at: type: string format: date-time @@ -6680,14 +14486,128 @@ components: format: uuid name: type: string + slug: + type: string + team_tag: + type: string + nullable: true region: type: string + enum: + - BR + - NA + - EUW + - EUNE + - KR + - LAN + - LAS + - OCE + - RU + - TR + - JP + region_display: + type: string + nullable: true tier: type: string enum: - amateur - semi_pro - professional + nullable: true + tier_display: + type: string + nullable: true + subscription_plan: + type: string + nullable: true + subscription_status: + type: string + nullable: true + subscription_display: + type: string + nullable: true + logo_url: + type: string + format: uri + nullable: true + settings: + type: object + nullable: true + enabled_lines: + type: array + items: + type: string + nullable: true + trial_expires_at: + type: string + format: date-time + nullable: true + trial_started_at: + type: string + format: date-time + nullable: true + trial_info: + type: object + properties: + on_trial: + type: boolean + trial_expired: + type: boolean + days_remaining: + type: integer + nullable: true + has_active_access: + type: boolean + statistics: + type: object + properties: + total_players: + type: integer + active_players: + type: integer + total_matches: + type: integer + recent_matches: + type: integer + total_users: + type: integer + features: + type: object + properties: + can_access_scrims: + type: boolean + can_access_competitive_data: + type: boolean + can_access_predictive_analytics: + type: boolean + available_features: + type: array + items: + type: string + available_data_sources: + type: array + items: + type: string + available_analytics: + type: array + items: + type: string + limits: + type: object + properties: + max_players: + type: integer + max_matches_per_month: + type: integer + current_players: + type: integer + current_monthly_matches: + type: integer + players_remaining: + type: integer + matches_remaining: + type: integer created_at: type: string format: date-time @@ -6698,7 +14618,6 @@ components: - id - name - region - - tier Player: type: object properties: @@ -6710,6 +14629,9 @@ components: real_name: type: string nullable: true + professional_name: + type: string + nullable: true role: type: string enum: @@ -6718,6 +14640,10 @@ components: - mid - adc - support + nullable: true + line: + type: string + nullable: true status: type: string enum: @@ -6725,26 +14651,152 @@ components: - inactive - benched - trial + - archived jersey_number: type: integer nullable: true + birth_date: + type: string + format: date + nullable: true + age: + type: integer + nullable: true country: type: string nullable: true + contract_start_date: + type: string + format: date + nullable: true + contract_end_date: + type: string + format: date + nullable: true + contract_status: + type: string + nullable: true solo_queue_tier: type: string + enum: + - IRON + - BRONZE + - SILVER + - GOLD + - PLATINUM + - EMERALD + - DIAMOND + - MASTER + - GRANDMASTER + - CHALLENGER nullable: true solo_queue_rank: type: string + enum: + - I + - II + - III + - IV nullable: true solo_queue_lp: type: integer nullable: true + solo_queue_wins: + type: integer + nullable: true + solo_queue_losses: + type: integer + nullable: true + flex_queue_tier: + type: string + nullable: true + flex_queue_rank: + type: string + nullable: true + flex_queue_lp: + type: integer + nullable: true + peak_tier: + type: string + nullable: true + peak_rank: + type: string + nullable: true + peak_season: + type: string + nullable: true + riot_puuid: + type: string + nullable: true + riot_summoner_id: + type: string + nullable: true + profile_icon_id: + type: integer + nullable: true + avatar_url: + type: string + format: uri + nullable: true current_rank: type: string + nullable: true win_rate: type: number format: float + nullable: true + main_champions: + type: array + items: + type: string + nullable: true + social_links: + type: object + nullable: true + twitter_handle: + type: string + nullable: true + twitch_channel: + type: string + nullable: true + instagram_handle: + type: string + nullable: true + kick_url: + type: string + format: uri + nullable: true + notes: + type: string + nullable: true + sync_status: + type: string + enum: + - pending + - synced + - error + nullable: true + last_sync_at: + type: string + format: date-time + nullable: true + needs_sync: + type: boolean + player_access_enabled: + type: boolean + player_email: + type: string + format: email + nullable: true + deleted_at: + type: string + format: date-time + nullable: true + removed_reason: + type: string + nullable: true + organization: + "$ref": "#/components/schemas/Organization" created_at: type: string format: date-time @@ -6754,7 +14806,6 @@ components: required: - id - summoner_name - - role - status Match: type: object @@ -6771,12 +14822,40 @@ components: game_start: type: string format: date-time + nullable: true + game_end: + type: string + format: date-time + nullable: true game_duration: type: integer + nullable: true + duration_formatted: + type: string + nullable: true + riot_match_id: + type: string + nullable: true + game_version: + type: string + nullable: true + opponent_name: + type: string + nullable: true + opponent_tag: + type: string + nullable: true victory: type: boolean - opponent_name: + nullable: true + result: + type: string + nullable: true + our_side: type: string + enum: + - blue + - red nullable: true our_score: type: integer @@ -6784,8 +14863,53 @@ components: opponent_score: type: integer nullable: true - result: + score_display: + type: string + nullable: true + our_towers: + type: integer + nullable: true + opponent_towers: + type: integer + nullable: true + our_dragons: + type: integer + nullable: true + opponent_dragons: + type: integer + nullable: true + our_barons: + type: integer + nullable: true + opponent_barons: + type: integer + nullable: true + our_inhibitors: + type: integer + nullable: true + opponent_inhibitors: + type: integer + nullable: true + kda_summary: + type: object + nullable: true + vod_url: + type: string + format: uri + nullable: true + replay_file_url: + type: string + format: uri + nullable: true + has_vod: + type: boolean + has_replay: + type: boolean + notes: type: string + nullable: true + organization: + "$ref": "#/components/schemas/Organization" created_at: type: string format: date-time @@ -6816,26 +14940,152 @@ components: id: type: string format: uuid - player_id: - type: string - format: uuid - match_id: + role: type: string - format: uuid - kills: + enum: + - top + - jungle + - mid + - adc + - support + nullable: true + champion: + type: string + nullable: true + champion_icon_url: + type: string + format: uri + nullable: true + kills: type: integer deaths: type: integer assists: type: integer - cs: + kda: + type: number + format: float + cs_total: + type: integer + gold_earned: type: integer + nullable: true + damage_dealt_total: + type: integer + nullable: true + damage_taken: + type: integer + nullable: true vision_score: type: integer - champion: - type: string - role: - type: string + nullable: true + wards_placed: + type: integer + nullable: true + wards_destroyed: + type: integer + nullable: true + first_blood: + type: boolean + nullable: true + double_kills: + type: integer + nullable: true + triple_kills: + type: integer + nullable: true + quadra_kills: + type: integer + nullable: true + penta_kills: + type: integer + nullable: true + performance_score: + type: number + format: float + nullable: true + neutral_minions_killed: + type: integer + nullable: true + objectives_stolen: + type: integer + nullable: true + crowd_control_score: + type: integer + nullable: true + total_time_dead: + type: integer + nullable: true + damage_to_turrets: + type: integer + nullable: true + turret_plates_destroyed: + type: integer + nullable: true + damage_shielded_teammates: + type: integer + nullable: true + healing_to_teammates: + type: integer + nullable: true + cs_at_10: + type: integer + nullable: true + spell_q_casts: + type: integer + nullable: true + spell_w_casts: + type: integer + nullable: true + spell_e_casts: + type: integer + nullable: true + spell_r_casts: + type: integer + nullable: true + summoner_spell_1_casts: + type: integer + nullable: true + summoner_spell_2_casts: + type: integer + nullable: true + pings: + type: integer + nullable: true + items: + type: array + items: + type: object + properties: + id: + type: integer + icon_url: + type: string + format: uri + runes: + type: array + items: + type: object + properties: + id: + type: integer + icon_url: + type: string + format: uri + summoner_spells: + type: array + items: + type: object + properties: + name: + type: string + icon_url: + type: string + format: uri + player: + "$ref": "#/components/schemas/Player" + match: + "$ref": "#/components/schemas/Match" created_at: type: string format: date-time @@ -6850,19 +15100,70 @@ components: format: uuid title: type: string - vod_url: + description: + type: string + nullable: true + review_type: type: string - vod_platform: + nullable: true + review_date: type: string - summary: + format: date + nullable: true + video_url: + type: string + format: uri + nullable: true + thumbnail_url: + type: string + format: uri + nullable: true + duration: + type: integer + nullable: true + is_public: + type: boolean + share_link: + type: string + nullable: true + shared_with_players: + type: boolean + hashid: + type: string + nullable: true + public_url: + type: string + format: uri + nullable: true + public_hashid_url: type: string + format: uri + nullable: true + timestamps_count: + type: integer + nullable: true status: type: string - enum: [draft, published, archived] + enum: + - draft + - published + - archived tags: type: array items: type: string + nullable: true + metadata: + type: object + nullable: true + organization: + "$ref": "#/components/schemas/Organization" + match: + "$ref": "#/components/schemas/Match" + nullable: true + reviewer: + "$ref": "#/components/schemas/User" + nullable: true created_at: type: string format: date-time @@ -6875,17 +15176,31 @@ components: id: type: string format: uuid - vod_review_id: - type: string - format: uuid - timecode: + timestamp_seconds: type: integer - label: - type: string - note: + formatted_timestamp: type: string category: type: string + nullable: true + importance: + type: string + nullable: true + title: + type: string + nullable: true + description: + type: string + nullable: true + vod_review: + "$ref": "#/components/schemas/VodReview" + nullable: true + target_player: + "$ref": "#/components/schemas/Player" + nullable: true + created_by: + "$ref": "#/components/schemas/User" + nullable: true created_at: type: string format: date-time @@ -6898,19 +15213,82 @@ components: id: type: string format: uuid - title: + event_type: type: string - schedule_type: + nullable: true + title: type: string description: type: string - scheduled_at: + nullable: true + start_time: type: string format: date-time - duration: - type: integer + nullable: true + end_time: + type: string + format: date-time + nullable: true + duration_hours: + type: number + format: float + nullable: true + location: + type: string + nullable: true + opponent_name: + type: string + nullable: true status: type: string + nullable: true + meeting_url: + type: string + format: uri + nullable: true + timezone: + type: string + nullable: true + all_day: + type: boolean + tags: + type: array + items: + type: string + nullable: true + color: + type: string + nullable: true + is_recurring: + type: boolean + recurrence_rule: + type: string + nullable: true + recurrence_end_date: + type: string + format: date + nullable: true + reminder_minutes: + type: integer + nullable: true + required_players: + type: array + items: + type: string + nullable: true + optional_players: + type: array + items: + type: string + nullable: true + metadata: + type: object + nullable: true + organization: + "$ref": "#/components/schemas/Organization" + match: + "$ref": "#/components/schemas/Match" + nullable: true created_at: type: string format: date-time @@ -6925,53 +15303,2075 @@ components: format: uuid summoner_name: type: string - region: + real_name: type: string + nullable: true role: type: string - tier: + enum: + - top + - jungle + - mid + - adc + - support + nullable: true + region: type: string + nullable: true status: type: string - notes: + nullable: true + status_text: type: string - created_at: + nullable: true + age: + type: integer + nullable: true + riot_puuid: type: string - format: date-time - updated_at: + nullable: true + current_tier: type: string - format: date-time - TeamGoal: - type: object - properties: - id: + nullable: true + current_rank: type: string - format: uuid - title: + nullable: true + current_lp: + type: integer + nullable: true + current_rank_display: type: string - description: + nullable: true + peak_tier: type: string - category: + nullable: true + peak_rank: type: string - metric_type: + nullable: true + champion_pool: + type: array + items: + type: string + nullable: true + playstyle: type: string - target_value: - type: number - current_value: - type: number - status: + nullable: true + strengths: + type: array + items: + type: string + nullable: true + weaknesses: + type: array + items: + type: string + nullable: true + recent_performance: + type: object + nullable: true + performance_trend: type: string - start_date: + nullable: true + season_history: + type: array + items: + type: object + nullable: true + email: type: string - format: date - end_date: + format: email + nullable: true + phone: type: string - format: date - created_at: + nullable: true + discord_username: type: string - format: date-time - updated_at: + nullable: true + twitter_handle: + type: string + nullable: true + avatar_url: + type: string + format: uri + nullable: true + profile_icon_id: + type: integer + nullable: true + notes: + type: string + nullable: true + metadata: + type: object + nullable: true + last_api_sync_at: + type: string + format: date-time + nullable: true + in_watchlist: + type: boolean + priority: + type: string + nullable: true + priority_text: + type: string + nullable: true + watchlist_status: + type: string + nullable: true + watchlist_notes: + type: string + nullable: true + last_reviewed: + type: string + format: date-time + nullable: true + added_by_id: + type: string + format: uuid + nullable: true + assigned_to_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TeamGoal: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + description: + type: string + nullable: true + category: + type: string + nullable: true + metric_type: + type: string + nullable: true + target_value: + type: number + nullable: true + current_value: + type: number + nullable: true + target_display: + type: string + nullable: true + current_display: + type: string + nullable: true + status: + type: string + nullable: true + progress: + type: number + format: float + nullable: true + completion_percentage: + type: number + format: float + nullable: true + start_date: + type: string + format: date + nullable: true + end_date: + type: string + format: date + nullable: true + days_remaining: + type: integer + nullable: true + days_total: + type: integer + nullable: true + time_progress_percentage: + type: number + format: float + nullable: true + is_overdue: + type: boolean + is_team_goal: + type: boolean + organization: + "$ref": "#/components/schemas/Organization" + player: + "$ref": "#/components/schemas/Player" + nullable: true + assigned_to: + "$ref": "#/components/schemas/User" + nullable: true + created_by: + "$ref": "#/components/schemas/User" + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ChampionPool: + type: object + properties: + id: + type: string + format: uuid + champion: + type: string + games_played: + type: integer + games_won: + type: integer + losses: + type: integer + win_rate: + type: number + format: float + average_kda: + type: number + format: float + nullable: true + average_cs_per_min: + type: number + format: float + nullable: true + mastery_level: + type: integer + nullable: true + last_played: + type: string + format: date + nullable: true + player: + "$ref": "#/components/schemas/Player" + created_at: + type: string + format: date-time + updated_at: type: string format: date-time + Notification: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + message: + type: string + type: + type: string + nullable: true + link_url: + type: string + format: uri + nullable: true + link_type: + type: string + nullable: true + link_id: + type: string + nullable: true + is_read: + type: boolean + read_at: + type: string + format: date-time + nullable: true + channels: + type: array + items: + type: string + nullable: true + email_sent: + type: boolean + discord_sent: + type: boolean + metadata: + type: object + nullable: true + time_ago: + type: string + user: + "$ref": "#/components/schemas/User" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + DraftPlan: + type: object + properties: + id: + type: string + format: uuid + opponent_team: + type: string + nullable: true + side: + type: string + enum: + - blue + - red + nullable: true + side_display: + type: string + nullable: true + patch_version: + type: string + nullable: true + notes: + type: string + nullable: true + our_bans: + type: array + items: + type: string + nullable: true + opponent_bans: + type: array + items: + type: string + nullable: true + priority_picks: + type: array + items: + type: string + nullable: true + priority_champions: + type: array + items: + type: string + nullable: true + if_then_scenarios: + type: array + items: + type: object + nullable: true + total_scenarios: + type: integer + blind_pick_ready: + type: boolean + is_active: + type: boolean + organization: + "$ref": "#/components/schemas/Organization" + created_by: + "$ref": "#/components/schemas/User" + nullable: true + updated_by: + "$ref": "#/components/schemas/User" + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TacticalBoard: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + nullable: true + auto_title: + type: string + nullable: true + game_time: + type: integer + nullable: true + match_id: + type: string + format: uuid + nullable: true + scrim_id: + type: string + format: uuid + nullable: true + map_state: + type: object + nullable: true + annotations: + type: array + items: + type: object + nullable: true + total_players: + type: integer + total_annotations: + type: integer + organization: + "$ref": "#/components/schemas/Organization" + created_by: + "$ref": "#/components/schemas/User" + nullable: true + updated_by: + "$ref": "#/components/schemas/User" + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ScrimRequest: + type: object + properties: + id: + type: string + format: uuid + status: + type: string + enum: + - pending + - accepted + - declined + - cancelled + - expired + game: + type: string + nullable: true + message: + type: string + nullable: true + proposed_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + games_planned: + type: integer + nullable: true + draft_type: + type: string + nullable: true + requesting_scrim_id: + type: string + format: uuid + nullable: true + target_scrim_id: + type: string + format: uuid + nullable: true + pending: + type: boolean + expired: + type: boolean + requesting_organization: + type: object + nullable: true + target_organization: + type: object + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + AvailabilityWindow: + type: object + properties: + id: + type: string + format: uuid + day_of_week: + type: integer + minimum: 0 + maximum: 6 + day_name: + type: string + start_hour: + type: integer + minimum: 0 + maximum: 23 + end_hour: + type: integer + minimum: 0 + maximum: 23 + time_range: + type: string + duration_hours: + type: number + format: float + timezone: + type: string + nullable: true + game: + type: string + nullable: true + region: + type: string + nullable: true + tier_preference: + type: string + nullable: true + focus_area: + type: string + nullable: true + draft_type: + type: string + nullable: true + active: + type: boolean + expired: + type: boolean + expires_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Scrim: + type: object + properties: + id: + type: string + format: uuid + organization_id: + type: string + format: uuid + opponent_team: + type: object + nullable: true + properties: + id: + type: string + format: uuid + name: + type: string + tag: + type: string + nullable: true + tier: + type: string + nullable: true + region: + type: string + nullable: true + scrims_won: + type: integer + scrims_lost: + type: integer + logo_url: + type: string + format: uri + nullable: true + scheduled_at: + type: string + format: date-time + nullable: true + scrim_type: + type: string + nullable: true + focus_area: + type: string + nullable: true + draft_type: + type: string + nullable: true + games_planned: + type: integer + nullable: true + games_completed: + type: integer + nullable: true + completion_percentage: + type: number + format: float + nullable: true + status: + type: string + enum: + - upcoming + - in_progress + - completed + - cancelled + nullable: true + win_rate: + type: number + format: float + nullable: true + is_confidential: + type: boolean + visibility: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ScrimOpponentTeam: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + tag: + type: string + nullable: true + full_name: + type: string + nullable: true + region: + type: string + nullable: true + tier: + type: string + nullable: true + tier_display: + type: string + nullable: true + league: + type: string + nullable: true + logo_url: + type: string + format: uri + nullable: true + total_scrims: + type: integer + scrim_record: + type: object + nullable: true + scrim_win_rate: + type: number + format: float + nullable: true + known_players: + type: array + items: + type: object + nullable: true + recent_performance: + type: object + nullable: true + playstyle_notes: + type: string + nullable: true + strengths: + type: array + items: + type: string + nullable: true + weaknesses: + type: array + items: + type: string + nullable: true + preferred_champions: + type: array + items: + type: string + nullable: true + contact_email: + type: string + format: email + nullable: true + discord_server: + type: string + nullable: true + contact_available: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Tournament: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + game: + type: string + format: + type: string + enum: + - double_elimination + - single_elimination + status: + type: string + enum: + - draft + - registration_open + - seeding + - in_progress + - finished + - cancelled + max_teams: + type: integer + enrolled_teams_count: + type: integer + slots_available: + type: boolean + bracket_generated: + type: boolean + bo_format: + type: integer + current_round_label: + type: string + nullable: true + rules: + type: string + nullable: true + entry_fee_cents: + type: integer + nullable: true + prize_pool_cents: + type: integer + nullable: true + registration_closes_at: + type: string + format: date-time + nullable: true + scheduled_start_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + finished_at: + type: string + format: date-time + nullable: true + matches: + type: array + items: + "$ref": "#/components/schemas/TournamentMatch" + nullable: true + created_at: + type: string + format: date-time + TournamentMatch: + type: object + properties: + id: + type: string + format: uuid + tournament_id: + type: string + format: uuid + bracket_side: + type: string + enum: + - winners + - losers + - grand_final + nullable: true + round_label: + type: string + nullable: true + round_order: + type: integer + nullable: true + match_number: + type: integer + nullable: true + bo_format: + type: integer + status: + type: string + enum: + - pending + - checkin_open + - in_progress + - completed + - walkover + - disputed + nullable: true + next_match_winner_id: + type: string + format: uuid + nullable: true + next_match_loser_id: + type: string + format: uuid + nullable: true + team_a_id: + type: string + format: uuid + nullable: true + team_a_name: + type: string + nullable: true + team_a_tag: + type: string + nullable: true + team_a_logo: + type: string + format: uri + nullable: true + team_a_score: + type: integer + nullable: true + team_b_id: + type: string + format: uuid + nullable: true + team_b_name: + type: string + nullable: true + team_b_tag: + type: string + nullable: true + team_b_logo: + type: string + format: uri + nullable: true + team_b_score: + type: integer + nullable: true + winner_id: + type: string + format: uuid + nullable: true + loser_id: + type: string + format: uuid + nullable: true + scheduled_at: + type: string + format: date-time + nullable: true + checkin_opens_at: + type: string + format: date-time + nullable: true + checkin_deadline_at: + type: string + format: date-time + nullable: true + wo_deadline_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + TournamentTeam: + type: object + properties: + id: + type: string + format: uuid + tournament_id: + type: string + format: uuid + organization_id: + type: string + format: uuid + team_name: + type: string + team_tag: + type: string + nullable: true + logo_url: + type: string + format: uri + nullable: true + status: + type: string + enum: + - pending + - approved + - rejected + - disqualified + seed: + type: integer + nullable: true + bracket_side: + type: string + nullable: true + enrolled_at: + type: string + format: date-time + nullable: true + approved_at: + type: string + format: date-time + nullable: true + rejected_at: + type: string + format: date-time + nullable: true + roster: + type: array + nullable: true + items: + type: object + properties: + player_id: + type: string + format: uuid + summoner_name: + type: string + role: + type: string + position: + type: string + nullable: true + locked_at: + type: string + format: date-time + MatchReport: + type: object + properties: + id: + type: string + format: uuid + tournament_match_id: + type: string + format: uuid + tournament_team_id: + type: string + format: uuid + team_a_score: + type: integer + nullable: true + team_b_score: + type: integer + nullable: true + evidence_url: + type: string + format: uri + nullable: true + status: + type: string + enum: + - submitted + - confirmed + - disputed + nullable: true + submitted_at: + type: string + format: date-time + nullable: true + confirmed_at: + type: string + format: date-time + nullable: true + deadline_at: + type: string + format: date-time + nullable: true + ProMatch: + type: object + properties: + id: + type: string + format: uuid + tournament_name: + type: string + nullable: true + tournament_stage: + type: string + nullable: true + tournament_region: + type: string + nullable: true + tournament_display: + type: string + nullable: true + match_date: + type: string + format: date + nullable: true + match_format: + type: string + nullable: true + game_number: + type: integer + nullable: true + game_label: + type: string + nullable: true + our_team_name: + type: string + nullable: true + opponent_team_name: + type: string + nullable: true + victory: + type: boolean + nullable: true + result: + type: string + nullable: true + series_score: + type: string + nullable: true + side: + type: string + enum: + - blue + - red + nullable: true + patch_version: + type: string + nullable: true + our_picks: + type: array + items: + type: string + opponent_picks: + type: array + items: + type: string + our_bans: + type: array + items: + type: string + opponent_bans: + type: array + items: + type: string + has_complete_draft: + type: boolean + meta_relevant: + type: boolean + vod_url: + type: string + format: uri + nullable: true + external_stats_url: + type: string + format: uri + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + DraftComparison: + type: object + properties: + similarity_score: + type: number + format: float + nullable: true + composition_winrate: + type: number + format: float + nullable: true + meta_score: + type: number + format: float + nullable: true + insights: + type: array + items: + type: string + nullable: true + patch: + type: string + nullable: true + analyzed_at: + type: string + format: date-time + nullable: true + similar_matches: + type: array + items: + type: object + nullable: true + summary: + type: object + properties: + total_similar_matches: + type: integer + avg_similarity: + type: number + format: float + nullable: true + meta_alignment: + type: number + format: float + nullable: true + expected_winrate: + type: number + format: float + nullable: true + DraftAnalysis: + type: object + properties: + win_probability: + type: number + format: float + nullable: true + confidence: + type: number + format: float + nullable: true + low_sample: + type: boolean + top_synergies: + type: array + items: + type: object + properties: + pair: + type: array + items: + type: string + score: + type: number + format: float + games: + type: integer + top_counters: + type: array + items: + type: object + properties: + matchup: + type: array + items: + type: string + advantage: + type: number + format: float + games: + type: integer + confidence: + type: number + format: float + suggested_picks: + type: array + items: + type: string + SavedBuild: + type: object + properties: + id: + type: string + format: uuid + champion: + type: string + role: + type: string + enum: + - top + - jungle + - mid + - adc + - support + nullable: true + patch_version: + type: string + nullable: true + title: + type: string + nullable: true + notes: + type: string + nullable: true + is_public: + type: boolean + data_source: + type: string + nullable: true + games_played: + type: integer + nullable: true + win_rate: + type: number + format: float + win_rate_display: + type: string + nullable: true + average_kda: + type: number + format: float + average_cs_per_min: + type: number + format: float + average_damage_share: + type: number + format: float + items: + type: array + items: + type: integer + nullable: true + item_build_order: + type: array + items: + type: integer + nullable: true + trinket: + type: integer + nullable: true + runes: + type: array + items: + type: integer + nullable: true + primary_rune_tree: + type: string + nullable: true + secondary_rune_tree: + type: string + nullable: true + summoner_spell_1: + type: string + nullable: true + summoner_spell_2: + type: string + nullable: true + created_by_id: + type: string + format: uuid + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + CompetitiveMatch: + type: object + properties: + id: + type: string + format: uuid + organization_id: + type: string + format: uuid + tournament_name: + type: string + nullable: true + tournament_display: + type: string + nullable: true + tournament_stage: + type: string + nullable: true + tournament_region: + type: string + nullable: true + match_date: + type: string + format: date + nullable: true + match_format: + type: string + nullable: true + game_number: + type: integer + nullable: true + game_label: + type: string + nullable: true + our_team_name: + type: string + nullable: true + opponent_team_name: + type: string + nullable: true + opponent_team: + type: object + nullable: true + properties: + id: + type: string + format: uuid + name: + type: string + tag: + type: string + nullable: true + tier: + type: string + nullable: true + logo_url: + type: string + format: uri + nullable: true + victory: + type: boolean + nullable: true + result_text: + type: string + nullable: true + series_score: + type: string + nullable: true + side: + type: string + enum: + - blue + - red + nullable: true + patch_version: + type: string + nullable: true + meta_relevant: + type: boolean + external_match_id: + type: string + nullable: true + draft_summary: + type: object + nullable: true + our_composition: + type: array + items: + type: string + nullable: true + opponent_composition: + type: array + items: + type: string + nullable: true + our_banned_champions: + type: array + items: + type: string + nullable: true + opponent_banned_champions: + type: array + items: + type: string + nullable: true + our_picked_champions: + type: array + items: + type: string + nullable: true + opponent_picked_champions: + type: array + items: + type: string + nullable: true + has_complete_draft: + type: boolean + meta_champions: + type: array + items: + type: string + nullable: true + game_stats: + type: object + nullable: true + vod_url: + type: string + format: uri + nullable: true + external_stats_url: + type: string + format: uri + nullable: true + draft_phase_sequence: + type: array + items: + type: object + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + "/api/v1/tournaments": + get: + summary: List active tournaments + description: "Returns all active tournaments ordered by scheduled start date. Cached per organization for 30 minutes. Public endpoint β€” no authentication required." + tags: + - Tournaments + description: Returns all tournaments in registration_open, seeding or in_progress + status. Public endpoint. + responses: + '200': + description: tournaments returned + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + "$ref": "#/components/schemas/Tournament" + example: + data: + - id: v2w3x4y5-z6a7-8901-bcde-456789012345 + name: CBLOL Community Cup Abril 2026 + game: league_of_legends + format: double_elimination + status: registration_open + max_teams: 16 + enrolled_teams_count: 7 + slots_available: true + bracket_generated: false + bo_format: 3 + current_round_label: + registration_closes_at: '2026-04-28T23:59:00.000Z' + scheduled_start_at: '2026-05-03T14:00:00.000Z' + post: + summary: Create a tournament + description: "Creates a tournament in draft status. Restricted to admin and owner roles. Cache is not pre-warmed β€” first read after create will miss the cache." + tags: + - Tournaments + description: Admin only. Creates a new tournament in draft status. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + game: + type: string + default: league_of_legends + max_teams: + type: integer + default: 16 + entry_fee_cents: + type: integer + prize_pool_cents: + type: integer + bo_format: + type: integer + default: 3 + scheduled_start_at: + type: string + format: date-time + registration_closes_at: + type: string + format: date-time + rules: + type: string + responses: + '201': + description: tournament created + content: + application/json: + schema: + type: object + example: + message: Tournament created successfully + data: + id: v2w3x4y5-z6a7-8901-bcde-456789012345 + name: CBLOL Community Cup Abril 2026 + game: league_of_legends + format: double_elimination + status: draft + max_teams: 16 + enrolled_teams_count: 0 + bo_format: 3 + created_at: '2026-04-21T10:00:00.000Z' + '422': + description: validation error + '403': + description: admin access required + "/api/v1/tournaments/{id}": + get: + summary: Show tournament with bracket + description: "Returns tournament details including the full double-elimination bracket. Cached for 30 minutes. Cache is invalidated on update. Public endpoint β€” no authentication required." + tags: + - Tournaments + description: Returns tournament data including all bracket matches. Public endpoint. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: tournament returned + content: + application/json: + schema: + type: object + example: + data: + id: v2w3x4y5-z6a7-8901-bcde-456789012345 + name: CBLOL Community Cup Abril 2026 + game: league_of_legends + format: double_elimination + status: in_progress + max_teams: 16 + enrolled_teams_count: 16 + slots_available: false + bracket_generated: true + bo_format: 3 + current_round_label: Winners Round 2 + scheduled_start_at: '2026-05-03T14:00:00.000Z' + started_at: '2026-05-03T14:10:00.000Z' + matches: + - id: w3x4y5z6-a7b8-9012-cdef-567890123456 + bracket_side: winners + round_label: Winners Round 1 + round_order: 1 + match_number: 1 + status: completed + team_a_name: Team ProStaff BR + team_b_name: LOUD Esports + team_a_score: 2 + team_b_score: 0 + bo_format: 3 + '404': + description: not found + patch: + summary: Update a tournament + description: "Updates tournament metadata and invalidates the tournament list and individual tournament caches. Restricted to admin and owner roles." + tags: + - Tournaments + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + status: + type: string + enum: + - draft + - registration_open + - seeding + - in_progress + - finished + - cancelled + responses: + '200': + description: tournament updated + content: + application/json: + schema: + type: object + example: + message: Tournament updated successfully + data: + id: v2w3x4y5-z6a7-8901-bcde-456789012345 + name: CBLOL Community Cup Abril 2026 + status: registration_open + '403': + description: admin access required + "/api/v1/tournaments/{id}/generate_bracket": + post: + summary: Generate double-elimination bracket + description: "Generates the double-elimination bracket from enrolled and approved teams. Can only be called once per tournament. Sets status to 'in_progress'. Restricted to admin and owner roles." + tags: + - Tournaments + description: Admin only. Creates all 30 TournamentMatch records and wires FK + self-references for bracket progression. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: bracket generated, tournament returned with matches + content: + application/json: + schema: + type: object + example: + message: Bracket generated successfully + data: + id: v2w3x4y5-z6a7-8901-bcde-456789012345 + name: CBLOL Community Cup Abril 2026 + status: seeding + bracket_generated: true + matches_count: 30 + '422': + description: bracket already exists + '403': + description: admin access required + "/api/v1/tournaments/{tournament_id}/teams": + get: + summary: List enrolled teams + description: "Returns all tournament teams with their roster snapshots. Public endpoint β€” no authentication required." + tags: + - Tournaments + description: Returns all teams enrolled in the tournament with roster snapshot. + Public endpoint. + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: teams returned + content: + application/json: + schema: + type: object + example: + data: + - id: x4y5z6a7-b8c9-0123-defa-678901234567 + tournament_id: v2w3x4y5-z6a7-8901-bcde-456789012345 + organization_id: 3b334bac-5ca2-4405-bf73-deac8a3e7ceb + team_name: Team ProStaff BR + team_tag: PSB + status: approved + seed: 1 + enrolled_at: '2026-04-22T10:00:00.000Z' + approved_at: '2026-04-23T09:00:00.000Z' + roster: + - player_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + position: starter + post: + summary: Enroll organization as a team + description: "Enrolls the current organization in the tournament. Registration must be open and slots must be available. Prevents duplicate enrollment." + tags: + - Tournaments + description: Enrolls the authenticated organization into the tournament. Status + starts as pending. + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + team_name: + type: string + team_tag: + type: string + maxLength: 5 + logo_url: + type: string + responses: + '201': + description: enrollment pending admin approval + content: + application/json: + schema: + type: object + example: + message: Enrollment submitted, pending admin approval + data: + id: x4y5z6a7-b8c9-0123-defa-678901234567 + tournament_id: v2w3x4y5-z6a7-8901-bcde-456789012345 + team_name: Team ProStaff BR + team_tag: PSB + status: pending + enrolled_at: '2026-04-22T10:00:00.000Z' + '422': + description: already enrolled, tournament full, or registration closed + "/api/v1/tournaments/{tournament_id}/teams/{id}/approve": + patch: + summary: Approve team enrollment and lock roster + description: "Approves a pending team enrollment and takes an immutable snapshot of the organization's active/rostered players as of approval time. Roster snapshot cannot be modified after creation. Restricted to admin and owner roles." + tags: + - Tournaments + description: Admin only. Sets team status to approved and creates immutable + TournamentRosterSnapshot from current org players. + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: team approved, roster locked + content: + application/json: + schema: + type: object + example: + message: Team approved and roster locked + data: + id: x4y5z6a7-b8c9-0123-defa-678901234567 + team_name: Team ProStaff BR + status: approved + approved_at: '2026-04-23T09:00:00.000Z' + roster: + - player_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + summoner_name: Ranger + role: adc + locked_at: '2026-04-23T09:00:00.000Z' + '403': + description: admin access required + "/api/v1/tournaments/{tournament_id}/teams/{id}/reject": + patch: + summary: Reject team enrollment + description: "Rejects a pending team enrollment. The organization remains enrolled but with 'rejected' status. Restricted to admin and owner roles." + tags: + - Tournaments + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: team rejected + '403': + description: admin access required + "/api/v1/tournaments/{tournament_id}/matches": + get: + summary: List all bracket matches + description: "Returns all bracket matches for the tournament ordered by round. Public endpoint β€” no authentication required. Includes team references and scores." + tags: + - Tournaments + description: Returns all matches ordered by round. Public endpoint. + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: matches returned + content: + application/json: + schema: + type: object + example: + data: + - id: w3x4y5z6-a7b8-9012-cdef-567890123456 + tournament_id: v2w3x4y5-z6a7-8901-bcde-456789012345 + bracket_side: winners + round_label: Winners Round 1 + round_order: 1 + match_number: 1 + bo_format: 3 + status: completed + team_a_name: Team ProStaff BR + team_a_tag: PSB + team_a_score: 2 + team_b_name: LOUD Esports + team_b_tag: LOUD + team_b_score: 0 + scheduled_at: '2026-05-03T14:00:00.000Z' + completed_at: '2026-05-03T16:30:00.000Z' + "/api/v1/tournaments/{tournament_id}/matches/{id}": + get: + summary: Show match detail with checkin status + description: "Returns match details with the current organization's check-in status and whether the opponent has checked in. Available when the match status is 'scheduled' or later." + tags: + - Tournaments + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: match detail with checkin flags + content: + application/json: + schema: + type: object + example: + data: + id: w3x4y5z6-a7b8-9012-cdef-567890123456 + tournament_id: v2w3x4y5-z6a7-8901-bcde-456789012345 + bracket_side: winners + round_label: Winners Round 1 + match_number: 1 + bo_format: 3 + status: checkin_open + team_a_name: Team ProStaff BR + team_b_name: paiN Gaming + team_a_score: + team_b_score: + checkin_opens_at: '2026-05-04T13:45:00.000Z' + checkin_deadline_at: '2026-05-04T14:15:00.000Z' + scheduled_at: '2026-05-04T14:00:00.000Z' + my_team_checked_in: false + opponent_checked_in: false + "/api/v1/tournaments/{tournament_id}/matches/{id}/checkin": + post: + summary: Captain checks in for their team + description: "Records a check-in for the current organization's team. When both teams check in, the match status transitions to 'in_progress' and an update is broadcast via Action Cable (TournamentChannel)." + tags: + - Tournaments + description: Confirms presence for the authenticated org's team. Both teams + checking in transitions match to in_progress. + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: checked in successfully + content: + application/json: + schema: + type: object + example: + message: Checked in successfully + data: + match_id: w3x4y5z6-a7b8-9012-cdef-567890123456 + my_team_checked_in: true + opponent_checked_in: false + match_status: checkin_open + '422': + description: already checked in or checkin not open + "/api/v1/tournaments/{tournament_id}/matches/{match_id}/report": + get: + summary: Get match report status + description: "Returns the current organization's submitted report and whether the opponent has reported. Opponent scores are hidden until both teams have submitted reports, preventing oracle attacks." + tags: + - Tournaments + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: match_id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: report status for current org + content: + application/json: + schema: + type: object + example: + data: + match_id: w3x4y5z6-a7b8-9012-cdef-567890123456 + my_report: + id: y5z6a7b8-c9d0-1234-efab-789012345678 + team_a_score: 2 + team_b_score: 0 + status: submitted + submitted_at: '2026-05-04T16:35:00.000Z' + opponent_report: + dispute_status: + post: + summary: Submit match result report + description: "Submits the match result on behalf of the current organization. If both reports agree the match is confirmed and the bracket is advanced. Diverging scores trigger 'disputed' status requiring admin resolution." + tags: + - Tournaments + description: Captain submits scores and evidence screenshot. Dual-validation + β€” if both sides match, bracket advances automatically. + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: match_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - team_a_score + - team_b_score + - evidence_url + properties: + team_a_score: + type: integer + team_b_score: + type: integer + evidence_url: + type: string + responses: + '200': + description: report submitted (status submitted, confirmed, or disputed) + content: + application/json: + schema: + type: object + example: + message: Match report submitted + data: + id: y5z6a7b8-c9d0-1234-efab-789012345678 + team_a_score: 2 + team_b_score: 0 + status: confirmed + submitted_at: '2026-05-04T16:35:00.000Z' + confirmed_at: '2026-05-04T16:40:00.000Z' + bracket_advanced: true + '422': + description: validation error + "/api/v1/tournaments/{tournament_id}/matches/{match_id}/report/admin_resolve": + post: + summary: Admin resolves a disputed match + description: "Overrides disputed reports with the admin-provided winner and scores. Advances the bracket via BracketProgressionService. Restricted to admin and owner roles." + tags: + - Tournaments + security: + - bearerAuth: [] + parameters: + - name: tournament_id + in: path + required: true + schema: + type: string + format: uuid + - name: match_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - winner_team_id + properties: + winner_team_id: + type: string + format: uuid + team_a_score: + type: integer + team_b_score: + type: integer + responses: + '200': + description: dispute resolved, bracket advanced + content: + application/json: + schema: + type: object + example: + message: Dispute resolved and bracket advanced + data: + match_id: w3x4y5z6-a7b8-9012-cdef-567890123456 + winner_team_id: x4y5z6a7-b8c9-0123-defa-678901234567 + team_a_score: 2 + team_b_score: 1 + status: completed + bracket_advanced: true + '422': + description: match is not in disputed state + '403': + description: admin access required security: - bearerAuth: []