diff --git a/.env.example b/.env.example index 9337a9a..c44fdb3 100644 --- a/.env.example +++ b/.env.example @@ -47,8 +47,14 @@ SMTP_PORT=587 SMTP_USERNAME=your_email@gmail.com SMTP_PASSWORD=your_app_password SMTP_DOMAIN=gmail.com +SMTP_AUTHENTICATION=plain +SMTP_ENABLE_STARTTLS_AUTO=true -# Frontend URL (for email links) +# Mailer Settings +MAILER_FROM_EMAIL=noreply@prostaff.gg +MAILER_DELIVERY_METHOD=smtp + +# Frontend URL (for email links and password reset) FRONTEND_URL=http://localhost:8888 # =========================================== @@ -65,4 +71,12 @@ RACK_ATTACK_PERIOD=300 # Set these in GitHub Secrets for CI/CD workflows TEST_EMAIL=test@prostaff.gg -TEST_PASSWORD=Test123!@# \ No newline at end of file +TEST_PASSWORD=Test123!@# + +# =========================================== +# PandaScore API Integration +# =========================================== + +PANDASCORE_API_KEY=your_pandascore_api_key_here +PANDASCORE_BASE_URL=https://api.pandascore.co +PANDASCORE_CACHE_TTL=3600 diff --git a/.env.production.example b/.env.production.example index f7be876..31d40b9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -27,7 +27,7 @@ SECRET_KEY_BASE=CHANGE_ME_GENERATE_WITH_rails_secret DEVISE_JWT_SECRET_KEY=CHANGE_ME_GENERATE_WITH_rails_secret # CORS -CORS_ORIGINS=https://prostaff.gg,https://api.prostaff.gg,https://www.prostaff.gg +CORS_ORIGINS=https://prostaff.gg,https://www.prostaff.gg,https://prostaffgg.netlify.app # External APIs RIOT_API_KEY=RGAPI-YOUR-PRODUCTION-KEY-HERE diff --git a/.github/BRANCH_PROTECTION_GUIDE.md b/.github/BRANCH_PROTECTION_GUIDE.md new file mode 100644 index 0000000..db9c24a --- /dev/null +++ b/.github/BRANCH_PROTECTION_GUIDE.md @@ -0,0 +1,196 @@ +# Branch Protection Ruleset - Master Branch + +Este documento explica as regras de proteção configuradas para a branch `master` do ProStaff API. + +## Regras Configuradas + +### 1. **Pull Request Reviews** +- **Aprovações necessárias**: 1 reviewer +- **Dismiss stale reviews**: Reviews antigas são descartadas quando novo código é pushed +- **Thread resolution**: Todos os comentários devem ser resolvidos antes do merge + +**Por quê?** Garante revisão de código e discussão de qualidade antes das mudanças irem para produção. + +### 2. **Required Status Checks** +- **Security Scan**: Workflow obrigatório que deve passar + - Brakeman (análise estática de segurança) + - Dependency check (vulnerabilidades em gems) +- **Strict mode**: Branch deve estar atualizada com master antes do merge + +**Por quê?** Garante que nenhum código com vulnerabilidades de segurança seja mergeado. + +### 3. **Linear History** +- Apenas fast-forward merges ou squash merges permitidos +- Histórico de commits limpo e linear + +**Por quê?** Facilita navegação no histórico e rollbacks se necessário. + +### 4. **Required Signatures** +- Commits devem ser assinados com GPG +- Garante autenticidade do autor + +**Por quê?** Segurança adicional contra commits não autorizados. + +### 5. **Deletion Protection** +- Branch master não pode ser deletada + +**Por quê?** Proteção contra acidentes catastróficos. + +### 6. **Force Push Protection** +- Force pushes não são permitidos +- Histórico não pode ser reescrito + +**Por quê?** Preserva integridade do histórico compartilhado. + +### 7. **Creation Protection** +- Apenas administradores podem criar a branch master + +**Por quê?** Controle total sobre a branch principal. + + +## Workflow para Desenvolvedores + +### Fluxo de trabalho padrão: + +1. **Criar feature branch** + ```bash + git checkout -b feature/PS-12345-new-feature + ``` + +2. **Fazer commits assinados** + ```bash + git commit -S -m "feat: add new feature" + ``` + +3. **Push para origin** + ```bash + git push origin feature/PS-123-new-feature + ``` + +4. **Criar Pull Request** + - Aguardar Security Scan passar + - Solicitar review de pelo menos 1 pessoa + - Resolver todos os comentários + +5. **Atualizar branch se necessário** + ```bash + git checkout master + git pull + git checkout feature/PS-123-new-feature + git rebase master + git push --force-with-lease + ``` + +6. **Merge após aprovação** + - Use "Squash and merge" ou "Rebase and merge" + - Evite "Merge commit" para manter histórico linear + +## Configuração de Commits Assinados + +### Gerar chave GPG: + +```bash +# Gerar chave +gpg --full-generate-key + +# Listar chaves +gpg --list-secret-keys --keyid-format=long + +# Exportar chave pública +gpg --armor --export YOUR_KEY_ID + +# Adicionar ao GitHub +# Settings → SSH and GPG keys → New GPG key +``` + +### Configurar Git: + +```bash +git config --global user.signingkey YOUR_KEY_ID +git config --global commit.gpgsign true +git config --global gpg.program gpg +``` + +## 🚨 Troubleshooting + +### Security Scan falhando +```bash +# Rodar localmente antes do push +./security_tests/scripts/brakeman-scan.sh +bundle audit check --update +``` + +### Branch desatualizada +```bash +git fetch origin +git rebase origin/master +``` + +### Commit não assinado +```bash +# Assinar último commit +git commit --amend --no-edit -S + +# Push forçado (apenas em feature branches!) +git push --force-with-lease +``` + +## Status Checks Configurados + +| Check | Descrição | Timeout | +|-------|-----------|---------| +| Security Scan | Brakeman + bundle audit | ~5min | + +### Futuras Status Checks (Recomendadas): + +Adicione estas ao ruleset conforme necessário: + +```json +{ + "context": "RSpec Tests", + "integration_id": null +}, +{ + "context": "Rubocop", + "integration_id": null +}, +{ + "context": "Load Tests", + "integration_id": null +} +``` + +## 🔄 Manutenção + +### Revisar regras trimestralmente +- Avaliar se as regras estão muito restritivas ou permissivas +- Adicionar novos status checks conforme o projeto evolui +- Revisar lista de bypass actors + +### Métricas para monitorar +- Tempo médio de merge de PRs +- Taxa de PRs bloqueados por security scan +- Número de force pushes tentados (e bloqueados) + +## Exceções + +### Quando bypassar regras? + +**NUNCA**, exceto em emergências críticas de produção. + +Para emergências: +1. Adicione temporariamente um bypass actor +2. Faça a correção +3. Remova o bypass imediatamente +4. Crie um post-mortem documentando o ocorrido + +## 📚 Referências + +- [GitHub Rulesets Documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) +- [GPG Signing Guide](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) +- [ProStaff Security Guide](security_tests/README.md) + +--- + +**Última atualização**: 2025-10-13 +**Versão do ruleset**: 1.0.0 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b260542 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: bulletdev diff --git a/.github/branch-protection-ruleset.json b/.github/branch-protection-ruleset.json new file mode 100644 index 0000000..b924059 --- /dev/null +++ b/.github/branch-protection-ruleset.json @@ -0,0 +1,59 @@ +{ + "name": "Master Branch Protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/master" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "Security Scan", + "integration_id": null + } + ], + "strict_required_status_checks_policy": true + } + }, + { + "type": "required_linear_history" + }, + { + "type": "required_signatures" + }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "creation" + } + ], + "bypass_actors": [ + { + "actor_id": 1, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ] +} diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index fab5426..b4340f9 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -158,10 +158,9 @@ jobs: --config=auto \ --json \ --output=semgrep-report.json \ - --exclude='scripts/*.rb' \ - --exclude='scripts/*.sh' \ - --exclude='load_tests/**' \ - --exclude='security_tests/**' \ + --exclude 'scripts/' \ + --exclude 'load_tests/' \ + --exclude 'security_tests/' \ || true - name: Parse Results diff --git a/.github/workflows/update-architecture-diagram.yml b/.github/workflows/update-architecture-diagram.yml index c4d295f..7117abf 100644 --- a/.github/workflows/update-architecture-diagram.yml +++ b/.github/workflows/update-architecture-diagram.yml @@ -39,8 +39,11 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.1.7' - bundler-cache: false + ruby-version: '3.3' + bundler-cache: true + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 - name: Update architecture diagram run: | @@ -57,9 +60,12 @@ jobs: - name: Commit and push if changed if: steps.verify_diff.outputs.changed == 'true' + env: + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout "$BRANCH_NAME" git add README.md git commit -m "docs: auto-update architecture diagram [skip ci]" git push diff --git a/.gitignore b/.gitignore index d5db0aa..f856ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -192,7 +192,7 @@ DOCS/optimization/OPTIMIZATION_SUMMARY.md DOCS/optimization/PERFORMANCE_OPTIMIZATION.md DOCS/setup/QUICK_START.md .claude.md - +MVP_ACTION_PLAN.md @@ -233,7 +233,8 @@ DOCS/RIOT_INTEGRATION_IMPLEMENTATION_PLAN.md DOCS/FRONTEND_RIOT_INTEGRATION_PLAN.md DOCS/FRONTEND_RIOT_MISSING_FEATURES.md DOCS/JIRA_TASKS.md - +/security_tests/reports/dependency-check/ +/home/bullet/PROJETOS/prostaff-api/home/bullet/PROJETOS/prostaff-api/security_tests/reports/dependency-check/ # Travis CI (deprecated - using GitHub Actions) travis.yml .travis.yml @@ -241,4 +242,19 @@ travis.yml #reports security_tests/scripts/security_tests/reports -./security_tests/scripts/security_tests/reports \ No newline at end of file +./security_tests/scripts/security_tests/reports +/home/bullet/PROJETOS/prostaff-api/security_tests/reports +CHANGELOG_RIOT_IMPLEMENTATION.md +RIOT_API_IMPLEMENTATION.md +SECURITY_AUDIT_REPORT.md +SESSION_SUMMARY.md +TEST_ANALYSIS_REPORT.md +MODULAR_MIGRATION_PHASE1_SUMMARY.md +MODULAR_MONOLITH_MIGRATION_PLAN.md +app/modules/players/README.md +/League-Data-Scraping-And-Analytics-master/jsons +/League-Data-Scraping-And-Analytics-master/Pro/game +/League-Data-Scraping-And-Analytics-master/Pro/timeline +League-Data-Scraping-And-Analytics-master/ProStaff-Scraper/ +DOCS/ELASTICSEARCH_SETUP.md +DOCS/deployment/QUICK_DEPLOY_VPS.md diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..3723d55 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,155 @@ +# RuboCop Configuration for ProStaff API +# Ruby on Rails 7.2+ API with Ruby 3.4.5 +# +# This configuration balances code quality with pragmatic Rails development. +# Generated: 2025-10-23 + +AllCops: + NewCops: enable + TargetRubyVersion: 3.4 + SuggestExtensions: false + Exclude: + - 'bin/**/*' + - 'vendor/**/*' + - 'node_modules/**/*' + - 'db/schema.rb' + - 'tmp/**/*' + - 'log/**/*' + - 'storage/**/*' + - 'spec/**/*' + - 'test/**/*' + +# ============================================ +# METRICS - Complexity and Size Limits +# ============================================ + +Metrics/BlockLength: + Exclude: + - 'config/routes.rb' + - 'config/routes/**/*' + - 'db/migrate/*' + - 'db/seeds.rb' + Max: 50 + +Metrics/MethodLength: + Max: 15 + Exclude: + - 'db/migrate/*' + - 'app/**/services/*_service.rb' # Complex business logic services + - 'app/**/controllers/*_controller.rb' # Controllers with multiple actions + +Metrics/ClassLength: + Max: 150 + Exclude: + - 'app/**/services/*_service.rb' # Allow larger service classes + - 'app/**/controllers/*_controller.rb' # Controllers with many actions + - 'config/initializers/**/*' + +Metrics/AbcSize: + Max: 20 + Exclude: + - 'db/migrate/*' + - 'app/**/services/*_service.rb' # Complex business logic + - 'app/**/controllers/*_controller.rb' # Controller actions + +Metrics/CyclomaticComplexity: + Max: 10 + +Metrics/PerceivedComplexity: + Max: 10 + +Metrics/ModuleLength: + Max: 150 + +Metrics/ParameterLists: + Max: 5 + CountKeywordArgs: false + +# ============================================ +# NAMING - Conventions +# ============================================ + +# Disable predicate prefix checks (has_*, is_* are common in Rails) +Naming/PredicatePrefix: + Enabled: false + +Naming/PredicateMethod: + Enabled: false # Disable predicate method naming (import_*, create_* are not predicates) + +Naming/VariableNumber: + Enabled: false + +# ============================================ +# STYLE - Code Style +# ============================================ + +# Documentation required only for main code +Style/Documentation: + Enabled: true + Exclude: + - 'db/migrate/*' + - 'config/**/*' + - 'lib/tasks/**/*' + - 'app/controllers/concerns/**/*' + - 'app/models/concerns/**/*' + - 'app/channels/**/*' + - 'app/jobs/**/*' + - 'app/mailers/**/*' + +Style/MultilineBlockChain: + Enabled: false + +Style/SymbolProc: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + Exclude: + - 'db/schema.rb' + +Style/FormatStringToken: + EnforcedStyle: template + +Style/StringLiterals: + EnforcedStyle: single_quotes + +Style/WordArray: + MinSize: 3 + +Style/FetchEnvVar: + Enabled: false + +# ============================================ +# LAYOUT - Formatting +# ============================================ + +Layout/LineLength: + Max: 120 + Exclude: + - 'config/**/*' + - 'db/**/*' + +Layout/EmptyLinesAroundBlockBody: + Enabled: true + +Layout/EndOfLine: + EnforcedStyle: lf + +# ============================================ +# LINT - Code Quality +# ============================================ + +Lint/UnusedMethodArgument: + AllowUnusedKeywordArguments: true + IgnoreEmptyMethods: true + +Lint/DuplicateBranch: + Enabled: true + +# ============================================ +# BUNDLER +# ============================================ + +Bundler/OrderedGems: + Enabled: false diff --git a/DOCS/QUICK_START_SIDEKIQ.md b/DOCS/QUICK_START_SIDEKIQ.md new file mode 100644 index 0000000..4a9e986 --- /dev/null +++ b/DOCS/QUICK_START_SIDEKIQ.md @@ -0,0 +1,180 @@ +# Quick Start: Sidekiq Scheduler + +Guia rápido para iniciar o Sidekiq com agendamento de jobs. + +## Pré-requisitos + +- Redis instalado e rodando +- Gems instaladas (`bundle install`) + +## Iniciar em Desenvolvimento + +### 1. Verifique o Redis + +```bash +redis-cli ping +# Deve retornar: PONG +``` + +Se o Redis não estiver rodando: + +```bash +# Linux/Mac +redis-server + +# Com Docker +docker run -d -p 6379:6379 redis:alpine +``` + +### 2. Inicie o Sidekiq + +Em um terminal separado: + +```bash +bundle exec sidekiq +``` + +Você deverá ver: + +``` + _ _ + (_)(_) + ___ _ _ __| | ___| | __(_) __ _ + / __|| |/ _` |/ _ \ |/ / |/ _` | + \__ \| | (_| | __/ <| | (_| | + |___/|_|\__,_|\___|_|\_\_|\__, | + |_| + +📅 Schedule loaded: + - cleanup_expired_tokens (daily at 2:00 AM) +``` + +### 3. Verificar Job Agendado + +No console Rails: + +```ruby +rails console + +# Ver schedule +Sidekiq.schedule +# => {"cleanup_expired_tokens"=>{"cron"=>"0 2 * * *", "class"=>"CleanupExpiredTokensJob", ...}} + +# Ver próxima execução +SidekiqScheduler::Scheduler.instance.rufus_scheduler.jobs.each do |job| + puts "#{job.tags.first}: next run at #{job.next_time}" +end +``` + +### 4. Testar Job Manualmente + +```ruby +# No console Rails +CleanupExpiredTokensJob.perform_now +# => Executa imediatamente + +# Ou em background +CleanupExpiredTokensJob.perform_later +# => Enfileira para execução +``` + +## Verificar Status + +### Via Console + +```ruby +# Ver jobs na fila +Sidekiq::Queue.all.each do |queue| + puts "#{queue.name}: #{queue.size} jobs" +end + +# Ver workers ativos +Sidekiq::Workers.new.size + +# Ver estatísticas +stats = Sidekiq::Stats.new +puts "Processed: #{stats.processed}" +puts "Failed: #{stats.failed}" +puts "Enqueued: #{stats.enqueued}" +``` + +### Via Web UI (Opcional) + +Adicione ao `config/routes.rb`: + +```ruby +require 'sidekiq/web' +require 'sidekiq-scheduler/web' + +mount Sidekiq::Web => '/sidekiq' +``` + +Acesse: http://localhost:3333/sidekiq + +## Logs + +```bash +# Ver logs do Sidekiq +tail -f log/sidekiq.log + +# Ver logs do Rails +tail -f log/development.log +``` + +## Parar o Sidekiq + +```bash +# Graceful shutdown (aguarda jobs terminarem) +Ctrl+C + +# Force shutdown +Ctrl+C (duas vezes) +``` + +## Troubleshooting + +### Redis não conecta + +```bash +# Verifique a URL do Redis +echo $REDIS_URL +# Se vazio, use: redis://localhost:6379/0 + +# Teste a conexão +redis-cli -u redis://localhost:6379/0 ping +``` + +### Schedule não carrega + +```bash +# Verifique o arquivo de configuração +cat config/sidekiq.yml + +# Teste o carregamento manual +bundle exec rails runner "pp YAML.load_file('config/sidekiq.yml')[:schedule]" +``` + +### Jobs não executam + +1. Certifique-se de que o Sidekiq está rodando +2. Verifique os logs: `tail -f log/sidekiq.log` +3. Teste manualmente: `CleanupExpiredTokensJob.perform_now` + +## Próximos Passos + +- Leia a documentação completa: `DOCS/SIDEKIQ_SCHEDULER_GUIDE.md` +- Configure jobs adicionais em `config/sidekiq.yml` +- Adicione monitoramento com Web UI +- Configure systemd/supervisor para produção + +## Jobs Configurados + +### CleanupExpiredTokensJob + +- **Frequência**: Diariamente às 2:00 AM +- **Função**: Limpa tokens expirados (password reset e JWT blacklist) +- **Execução manual**: `CleanupExpiredTokensJob.perform_now` + +--- + +Pronto! Seu Sidekiq com agendamento está configurado e rodando! 🚀 diff --git a/DOCS/SIDEKIQ_SCHEDULER_GUIDE.md b/DOCS/SIDEKIQ_SCHEDULER_GUIDE.md new file mode 100644 index 0000000..4a5af89 --- /dev/null +++ b/DOCS/SIDEKIQ_SCHEDULER_GUIDE.md @@ -0,0 +1,387 @@ +# Sidekiq Scheduler Guide + +Este guia explica como usar o Sidekiq com agendamento de tarefas recorrentes usando `sidekiq-scheduler`. + +## O que foi Configurado + +### 1. Gems Instaladas + +```ruby +gem "sidekiq", "~> 7.0" +gem "sidekiq-scheduler" +``` + +### 2. Arquivo de Configuração + +`config/sidekiq.yml` contém: +- Configurações básicas do Sidekiq (concurrency, queues, etc.) +- Schedule de jobs recorrentes + +### 3. Initializer + +`config/initializers/sidekiq.rb` carrega automaticamente o schedule quando o Sidekiq inicia. + +### 4. Job Agendado + +`CleanupExpiredTokensJob` executa diariamente às 2h da manhã para limpar: +- Tokens de reset de senha expirados ou usados +- Tokens JWT blacklisted expirados + +--- + +## Como Iniciar o Sidekiq + +### Desenvolvimento (Local) + +```bash +# Certifique-se de que o Redis está rodando +redis-cli ping # Deve retornar "PONG" + +# Inicie o Sidekiq +bundle exec sidekiq +``` + +### Com Docker Compose + +O Sidekiq já deve estar configurado no `docker-compose.yml`: + +```yaml +sidekiq: + build: . + command: bundle exec sidekiq + depends_on: + - postgres + - redis + environment: + - REDIS_URL=redis://redis:6379/0 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/prostaff_api_development +``` + +Inicie com: + +```bash +docker-compose up sidekiq +``` + +--- + +## Gerenciamento de Schedule + +### Adicionar Novo Job Agendado + +Edite `config/sidekiq.yml`: + +```yaml +:schedule: + # Job existente + cleanup_expired_tokens: + cron: '0 2 * * *' + class: CleanupExpiredTokensJob + description: 'Clean up expired tokens' + + # Novo job com cron expression + sync_player_stats: + cron: '*/30 * * * *' # A cada 30 minutos + class: SyncPlayerStatsJob + description: 'Sync player stats from Riot API' + + # Novo job com expressão "every" + send_daily_digest: + every: '1d' # Uma vez por dia + class: SendDailyDigestJob + description: 'Send daily digest email to users' + + # Job com expressão "in" + cleanup_old_logs: + in: 1h # Executa 1 hora após o início + class: CleanupOldLogsJob + description: 'Clean up old log files' +``` + +### Formatos de Agendamento + +#### Cron Expression + +```yaml +cron: '0 2 * * *' # Diariamente às 2:00 AM +cron: '*/15 * * * *' # A cada 15 minutos +cron: '0 */6 * * *' # A cada 6 horas +cron: '0 9 * * 1' # Toda segunda-feira às 9:00 AM +cron: '0 0 1 * *' # Primeiro dia de cada mês à meia-noite +``` + +Formato: `minute hour day month day_of_week` + +#### Every Expression + +```yaml +every: '30s' # A cada 30 segundos +every: '5m' # A cada 5 minutos +every: '2h' # A cada 2 horas +every: '1d' # Uma vez por dia +every: '1w' # Uma vez por semana +``` + +#### At Expression + +```yaml +at: '3:41 am' # Diariamente às 3:41 AM +at: 'Tuesday 14:00' # Toda terça às 14:00 +at: 'first day 10:00' # Primeiro dia do mês às 10:00 +``` + +--- + +## Monitoramento + +### Web UI do Sidekiq + +Adicione ao `config/routes.rb`: + +```ruby +require 'sidekiq/web' +require 'sidekiq-scheduler/web' + +Rails.application.routes.draw do + # Proteja em produção! + mount Sidekiq::Web => '/sidekiq' +end +``` + +Acesse: `http://localhost:3333/sidekiq` + +### CLI do Sidekiq + +```bash +# Ver jobs enfileirados +bundle exec sidekiq-cli stats + +# Ver status dos workers +bundle exec sidekiq-cli status + +# Parar Sidekiq gracefully +bundle exec sidekiqctl stop tmp/pids/sidekiq.pid + +# Parar Sidekiq forçadamente +bundle exec sidekiqctl stop tmp/pids/sidekiq.pid 0 +``` + +--- + +## Comandos Úteis + +### Via Rails Console + +```ruby +# Ver schedule carregado +Sidekiq.schedule + +# Ver próximas execuções +SidekiqScheduler::Scheduler.instance.rufus_scheduler.jobs.each do |job| + puts "#{job.tags.first}: next run at #{job.next_time}" +end + +# Recarregar schedule +SidekiqScheduler::Scheduler.instance.reload_schedule! + +# Executar job manualmente (imediatamente) +CleanupExpiredTokensJob.perform_now + +# Enfileirar job para execução em background +CleanupExpiredTokensJob.perform_later + +# Enfileirar job com delay +CleanupExpiredTokensJob.set(wait: 1.hour).perform_later +``` + +### Testar Schedule + +```ruby +# No console Rails +schedule_file = Rails.root.join('config', 'sidekiq.yml') +schedule = YAML.load_file(schedule_file) +pp schedule[:schedule] +``` + +--- + +## Exemplo de Job Completo + +```ruby +# app/jobs/example_scheduled_job.rb +class ExampleScheduledJob < ApplicationJob + queue_as :default + + # Opcional: retry com estratégia customizada + retry_on StandardError, wait: :exponentially_longer, attempts: 5 + + def perform(*args) + Rails.logger.info "Starting ExampleScheduledJob..." + + # Sua lógica aqui + do_something_important + + Rails.logger.info "ExampleScheduledJob completed successfully" + rescue => e + Rails.logger.error "Error in ExampleScheduledJob: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise e # Re-raise para o Sidekiq tentar novamente + end + + private + + def do_something_important + # Implementação + end +end +``` + +--- + +## Configurações de Produção + +### Variáveis de Ambiente + +```env +# Redis +REDIS_URL=redis://redis:6379/0 + +# Sidekiq +SIDEKIQ_CONCURRENCY=10 +SIDEKIQ_TIMEOUT=30 +``` + +### Atualizar Sidekiq Initializer + +```ruby +# config/initializers/sidekiq.rb +Sidekiq.configure_server do |config| + config.redis = { + url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'), + network_timeout: 5 + } + + # Configurações dinâmicas + config.concurrency = ENV.fetch('SIDEKIQ_CONCURRENCY', 5).to_i + config.timeout = ENV.fetch('SIDEKIQ_TIMEOUT', 25).to_i +end +``` + +### systemd Service (Linux) + +Crie `/etc/systemd/system/sidekiq.service`: + +```ini +[Unit] +Description=Sidekiq Background Worker +After=network.target + +[Service] +Type=simple +User=deploy +WorkingDirectory=/var/www/prostaff-api/current +Environment=RAILS_ENV=production +ExecStart=/usr/local/bin/bundle exec sidekiq +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Gerenciar: + +```bash +sudo systemctl enable sidekiq +sudo systemctl start sidekiq +sudo systemctl status sidekiq +sudo systemctl restart sidekiq +``` + +--- + +## Troubleshooting + +### Jobs Não Estão Executando + +1. Verifique se o Sidekiq está rodando: + ```bash + ps aux | grep sidekiq + ``` + +2. Verifique os logs: + ```bash + tail -f log/sidekiq.log + ``` + +3. Verifique o schedule carregado (console Rails): + ```ruby + Sidekiq.schedule + ``` + +### Redis Não Está Conectando + +```bash +# Teste a conexão +redis-cli -u $REDIS_URL ping + +# Verifique as configurações +echo $REDIS_URL +``` + +### Jobs Falhando Silenciosamente + +Adicione logging detalhado: + +```ruby +class MyJob < ApplicationJob + def perform + Rails.logger.info "Job started at #{Time.current}" + # ... seu código ... + rescue => e + Rails.logger.error "Job failed: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise + end +end +``` + +### Schedule Não Atualiza + +Reinicie o Sidekiq após alterar `config/sidekiq.yml`: + +```bash +# Desenvolvimento +pkill -USR1 sidekiq # Graceful restart + +# Produção com systemd +sudo systemctl restart sidekiq +``` + +--- + +## Melhores Práticas + +1. **Jobs Idempotentes**: Jobs devem ser seguros para executar múltiplas vezes +2. **Timeout Apropriado**: Configure timeout adequado para jobs longos +3. **Retry Strategy**: Defina estratégia de retry apropriada +4. **Logging**: Sempre adicione logs detalhados +5. **Monitoramento**: Use a Web UI para monitorar performance +6. **Queues Separadas**: Use queues diferentes para prioridades diferentes +7. **Error Handling**: Sempre capture e logue exceções +8. **Cleanup**: Tenha jobs de limpeza para dados temporários + +--- + +## Referências + +- [Sidekiq Documentation](https://github.com/sidekiq/sidekiq/wiki) +- [Sidekiq-Scheduler Documentation](https://github.com/sidekiq-scheduler/sidekiq-scheduler) +- [Cron Expression Generator](https://crontab.guru/) +- [Fugit (cron parser)](https://github.com/floraison/fugit) + +--- + +## Suporte + +Para questões ou problemas, consulte a documentação oficial ou abra uma issue no repositório. diff --git a/Dockerfile b/Dockerfile index 2f78e0e..583cc6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,8 @@ COPY . . RUN groupadd -g 1000 app && \ useradd -u 1000 -g app -m -s /bin/bash app -# Change ownership of the app directory -RUN chown -R app:app /app +# Change ownership of the app directory and bundle directory +RUN chown -R app:app /app /usr/local/bundle # Switch to the app user USER app diff --git a/Dockerfile.production b/Dockerfile.production index c8dc2c3..6dc33cf 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -11,6 +11,7 @@ RUN apt-get update -qq && \ curl \ libpq-dev \ libyaml-dev \ + postgresql-client \ tzdata && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives diff --git a/Gemfile b/Gemfile index 7de7968..516c9c1 100644 --- a/Gemfile +++ b/Gemfile @@ -1,99 +1,105 @@ -source "https://rubygems.org" -git_source(:github) { |repo| "https://github.com/#{repo}.git" } - -ruby "3.4.5" - -# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 7.2.0" - -# Use postgresql as the database for Active Record -gem "pg", "~> 1.1" - -# Use the Puma web server [https://github.com/puma/puma] -gem "puma", "~> 6.0" - -# Security: Force Rack to safe version to fix CVE-2025-61780 and CVE-2025-61919 -gem "rack", "~> 3.1.18" - -# Build JSON APIs with ease [https://github.com/rails/jbuilder] -# gem "jbuilder" - -# Use Redis adapter to run Action Cable in production -gem "redis", "~> 5.0" - -# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] -# gem "kredis" - -# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -gem "bcrypt", "~> 3.1.7" - -# Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] - -# Reduces boot times through caching; required in config/boot.rb -gem "bootsnap", require: false - -# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] -# gem "image_processing", "~> 1.2" - -# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible -gem "rack-cors" - -# JWT for authentication -gem "jwt" - -# Serializers for API responses -gem "blueprinter" - -# Background jobs -gem "sidekiq", "~> 7.0" - -# Environment variables -gem "dotenv-rails" - -# HTTP client for Riot API -gem "faraday" -gem "faraday-retry" - -# Authorization -gem "pundit" - -# Rate limiting -gem "rack-attack" - -# UUID generation -gem "securerandom" - -# Pagination -gem "kaminari" - -# API documentation -gem "rswag" -gem "rswag-api" -gem "rswag-ui" - -group :development, :test do - # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[ mri mingw x64_mingw ] - gem "rspec-rails" - gem "factory_bot_rails" - gem "faker" - gem "rswag-specs" -end - -group :development do - # Speed up commands on slow machines / big apps [https://github.com/rails/spring] - # gem "spring" - gem "annotate" - gem "rubocop" - gem "rubocop-rails" - gem "rubocop-rspec" -end - -group :test do - gem "shoulda-matchers" - gem "database_cleaner-active_record" - gem "webmock" - gem "vcr" - gem "simplecov", require: false -end \ No newline at end of file +# frozen_string_literal: true + +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +ruby '3.4.5' + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem 'rails', '~> 7.2.0' + +# Use postgresql as the database for Active Record +gem 'pg', '~> 1.1' + +# Use the Puma web server [https://github.com/puma/puma] +gem 'puma', '~> 6.0' + +# Security: Force Rack to safe version to fix CVE-2025-61780 and CVE-2025-61919 +gem 'rack', '~> 3.1.18' + +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +# gem "jbuilder" + +# Use Redis adapter to run Action Cable in production +gem 'redis', '~> 5.0' + +# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] +# gem "kredis" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +gem 'bcrypt', '~> 3.1.7' + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] + +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible +gem 'rack-cors' + +# JWT for authentication +gem 'jwt' + +# Serializers for API responses +gem 'blueprinter' + +# Background jobs +gem 'sidekiq', '~> 7.0' +gem 'sidekiq-scheduler' + +# Environment variables +gem 'dotenv-rails' + +# HTTP client for Riot API +gem 'faraday' +gem 'faraday-retry' + +# Authorization +gem 'pundit' + +# Rate limiting +gem 'rack-attack' + +# UUID generation +gem 'securerandom' + +# Pagination +gem 'kaminari' + +# API documentation +gem 'rswag' +gem 'rswag-api' +gem 'rswag-ui' + +# Elasticsearch client (for analytics queries) +gem 'elasticsearch', '~> 9.1', '>= 9.1.3' + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem 'debug', platforms: %i[mri mingw x64_mingw] + gem 'factory_bot_rails' + gem 'faker' + gem 'rspec-rails' + gem 'rswag-specs' +end + +group :development do + # Speed up commands on slow machines / big apps [https://github.com/rails/spring] + # gem "spring" + gem 'annotate' + gem 'rubocop' + gem 'rubocop-rails' + gem 'rubocop-rspec' +end + +group :test do + gem 'database_cleaner-active_record' + gem 'shoulda-matchers' + gem 'simplecov', require: false + gem 'vcr' + gem 'webmock' +end diff --git a/Gemfile.lock b/Gemfile.lock index 85e2f40..6f85b47 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,8 +107,18 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) + elastic-transport (8.4.1) + faraday (< 3) + multi_json + elasticsearch (9.2.0) + elastic-transport (~> 8.3) + elasticsearch-api (= 9.2.0) + elasticsearch-api (9.2.0) + multi_json erb (5.0.3) erubi (1.13.1) + et-orbi (1.4.0) + tzinfo factory_bot (6.5.5) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) @@ -124,6 +134,9 @@ GEM net-http (>= 0.5.0) faraday-retry (2.3.2) faraday (~> 2.0) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) hashdiff (1.2.1) @@ -167,6 +180,7 @@ GEM mini_mime (1.1.5) minitest (5.26.0) msgpack (1.8.0) + multi_json (1.18.0) net-http (0.6.0) uri net-imap (0.5.12) @@ -198,6 +212,7 @@ GEM nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) + raabro (1.4.0) racc (1.8.1) rack (3.1.18) rack-attack (6.7.0) @@ -311,6 +326,8 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) @@ -320,6 +337,9 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) + sidekiq-scheduler (6.0.1) + rufus-scheduler (~> 3.2) + sidekiq (>= 7.3, < 9) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -360,6 +380,7 @@ DEPENDENCIES database_cleaner-active_record debug dotenv-rails + elasticsearch (~> 9.1, >= 9.1.3) factory_bot_rails faker faraday @@ -385,6 +406,7 @@ DEPENDENCIES securerandom shoulda-matchers sidekiq (~> 7.0) + sidekiq-scheduler simplecov tzinfo-data vcr diff --git a/PLAYER_IMPORT_SECURITY.md b/PLAYER_IMPORT_SECURITY.md new file mode 100644 index 0000000..4fd5148 --- /dev/null +++ b/PLAYER_IMPORT_SECURITY.md @@ -0,0 +1,131 @@ +# 🔒 Player Import Security - Proteção contra Importação Duplicada + +## Visão Geral + +O sistema implementa uma proteção rigorosa contra a importação de jogadores que já pertencem a outras organizações. Esta é uma medida de segurança importante para: + +1. **Prevenir conflitos de dados** - Um jogador só pode estar ativo em uma organização por vez +2. **Proteger a privacidade** - Evitar que organizações vejam/importem jogadores de competidores +3. **Compliance** - Registrar tentativas suspeitas para auditoria + +## Como Funciona + +### Validação no Import + +Quando uma organização tenta importar um jogador: + +1. ✅ Sistema busca o jogador na Riot API +2. ✅ Verifica se o `riot_puuid` já existe no banco de dados +3. ✅ Se existir em **outra organização**, bloqueia a importação +4. ✅ Registra a tentativa no `AuditLog` para auditoria +5. ✅ Retorna erro **403 Forbidden** com mensagem clara + +### Mensagem de Erro + +``` +This player is already registered in another organization. +Players can only be associated with one organization at a time. +Attempting to import players from other organizations may result +in account restrictions. +``` + +### Status HTTP + +- **403 Forbidden** - Jogador pertence a outra organização +- **404 Not Found** - Jogador não encontrado na Riot API +- **422 Unprocessable Entity** - Formato inválido de Riot ID + +## Logs de Auditoria + +Cada tentativa bloqueada gera um registro em `audit_logs` com: + +```ruby +{ + organization: , + action: 'import_attempt_blocked', + entity_type: 'Player', + entity_id: , + new_values: { + attempted_summoner_name: "PlayerName#TAG", + actual_summoner_name: "PlayerName#TAG", + owner_organization_id: "uuid", + owner_organization_name: "Org Name", + reason: "Player already belongs to another organization", + puuid: "riot_puuid" + } +} +``` + +## Logs de Sistema + +Tentativas bloqueadas também geram logs de WARNING: + +``` +⚠️ SECURITY: Attempt to import player (PUUID: ) +that belongs to organization by organization +``` + +## Código de Erro + +```ruby +code: 'PLAYER_BELONGS_TO_OTHER_ORGANIZATION' +``` + +## Exemplo de Uso + +### Request (Frontend) +```javascript +await playersService.importFromRiot({ + summoner_name: "PlayerName#TAG", + role: "mid" +}); +``` + +### Response (Erro - Jogador já existe) +```json +{ + "error": { + "code": "PLAYER_BELONGS_TO_OTHER_ORGANIZATION", + "message": "This player is already registered in another organization. Players can only be associated with one organization at a time. Attempting to import players from other organizations may result in account restrictions.", + "status": 403 + } +} +``` + +## Implicações para Compliance + +- ✅ Todas as tentativas são registradas com timestamp +- ✅ Informações da organização que tentou são armazenadas +- ✅ PUUID do jogador é registrado para rastreamento +- ✅ Logs podem ser usados para identificar padrões suspeitos + +## Notas Importantes + +1. **Unicidade Global**: O `riot_puuid` é único globalmente, não por organização +2. **Auditoria Completa**: Todas as tentativas são registradas, mesmo as bloqueadas +3. **Privacidade**: O sistema NÃO revela qual organização possui o jogador (apenas registra internamente) +4. **Ações Futuras**: Tentativas repetidas podem resultar em bloqueio de conta (a implementar) + +## Implementação Técnica + +### Arquivos Modificados + +1. `app/modules/players/services/riot_sync_service.rb:73-97` + - Validação antes de criar o player + - Log de segurança + - Criação de audit log + +2. `app/modules/players/controllers/players_controller.rb:357-358` + - Mapeamento de código de erro para status HTTP 403 + +## Próximos Passos Sugeridos + +- [ ] Implementar rate limiting para tentativas de import +- [ ] Alertas para administradores após X tentativas bloqueadas +- [ ] Dashboard de segurança mostrando tentativas suspeitas +- [ ] Bloqueio temporário de conta após múltiplas tentativas + +--- + +**Última atualização**: 2025-10-25 +**Versão**: 1.0.0 diff --git a/README.md b/README.md index 740d66a..0202436 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,122 @@ [![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) -[![Ruby Version](https://img.shields.io/badge/ruby-3.4.5-red.svg)](https://www.ruby-lang.org/) -[![Rails Version](https://img.shields.io/badge/rails-7.2-red.svg)](https://rubyonrails.org/) +[![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) + +[![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/) [![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/) # ProStaff API -Ruby on Rails API for the ProStaff.gg esports team management platform. - -## Quick Start +> Ruby on Rails API for the ProStaff.gg esports team management platform. + +
+Key Features (Click to show details) + +- **JWT Authentication** with refresh tokens and token blacklisting +- **Interactive Swagger Documentation** (170+ endpoints documented) +- **Riot Games API Integration** for automatic match import and player sync +- **Advanced Analytics** (KDA trends, champion pools, vision control, etc.) +- **Scouting System** with talent discovery and watchlist management +- **VOD Review System** with timestamp annotations +- ️ **Schedule Management** for matches, scrims, and team events +- **Goal Tracking** for team and player performance objectives +- **Competitive Module** with PandaScore integration and draft analysis +- **Scrims Management** with opponent tracking and analytics +- **Strategy Module** with draft planning and tactical boards +- **Support System** with ticketing and FAQ management +- **Background Jobs** with Sidekiq for async processing +- ️ **Security Hardened** (OWASP Top 10, Brakeman, ZAP tested) +- **High Performance** (p95: ~500ms, with cache: ~50ms) +- ️ **Modular Monolith** architecture for scalability +
+ +## Table of Contents + +- [Quick Start](#quick-start) +- [Technology Stack](#technology-stack) +- [Architecture](#architecture) +- [Setup](#setup) +- [Development Tools](#️-development-tools) +- [API Documentation](#-api-documentation) +- [Testing](#-testing) +- [Performance & Load Testing](#-performance--testing) +- [Security](#security-testing-owasp) +- [Deployment](#deployment) +- [Contributing](#contributing) +- [License](#license) + +
+ Quick Start (Click to show details) + +### Option 1: With Docker (Recommended) ```bash +# Start all services (API, PostgreSQL, Redis, Sidekiq) docker compose up -d +# Create test user docker exec prostaff-api-api-1 rails runner scripts/create_test_user.rb +# Get JWT token for testing +./scripts/get-token.sh + +# Access API docs +open http://localhost:3333/api-docs + +# Run smoke tests ./load_tests/run-tests.sh smoke local + +# Run security scan ./security_tests/scripts/brakeman-scan.sh ``` +### Option 2: Without Docker (Local Development) + +```bash +# Install dependencies +bundle install + +# Generate secrets +./scripts/generate_secrets.sh # Copy output to .env + +# Setup database +rails db:create db:migrate db:seed + +# Start Redis (in separate terminal) +redis-server + +# Start Sidekiq (in separate terminal) +bundle exec sidekiq + +# Start Rails server +rails server -p 3333 + +# Get JWT token for testing +./scripts/get-token.sh + +# Access API docs +open http://localhost:3333/api-docs +``` + +**API will be available at:** `http://localhost:3333` +**Swagger Docs:** `http://localhost:3333/api-docs` +
+ ## Technology Stack - **Ruby**: 3.4.5 -- **Rails**: 7.1+ (API-only mode) +- **Rails**: 7.2.0 (API-only mode) - **Database**: PostgreSQL 14+ -- **Authentication**: JWT +- **Authentication**: JWT (with refresh tokens) - **Background Jobs**: Sidekiq - **Caching**: Redis (port 6380) -- **Testing**: RSpec, k6, OWASP ZAP +- **API Documentation**: Swagger/OpenAPI 3.0 (rswag) +- **Testing**: RSpec, Integration Specs, k6, OWASP ZAP +- **Authorization**: Pundit +- **Serialization**: Blueprinter ## Architecture @@ -44,6 +132,10 @@ This API follows a modular monolith architecture with the following modules: - `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 +- `scrims` - Scrim management and opponent team tracking +- `strategy` - Draft planning and tactical board system +- `support` - Support ticket system with staff and FAQ management ### Architecture Diagram @@ -61,52 +153,88 @@ graph TB end subgraph "Application Layer - Modular Monolith" -subgraph "Authentication Module" - AuthController[Auth Controller] - JWTService[JWT Service] - UserModel[User Model] -end -subgraph "Dashboard Module" - DashboardController[Dashboard Controller] - DashStats[Statistics Service] -end -subgraph "Players Module" - PlayersController[Players Controller] - PlayerModel[Player Model] - ChampionPool[Champion Pool Model] -end -subgraph "Scouting Module" - ScoutingController[Scouting Controller] - ScoutingTarget[Scouting Target Model] - Watchlist[Watchlist Service] -end -subgraph "Analytics Module" - AnalyticsController[Analytics Controller] - PerformanceService[Performance Service] - KDAService[KDA Trend Service] -end -subgraph "Matches Module" - MatchesController[Matches Controller] - MatchModel[Match Model] - PlayerMatchStats[Player Match Stats Model] -end -subgraph "Schedules Module" - SchedulesController[Schedules Controller] - ScheduleModel[Schedule Model] -end -subgraph "VOD Reviews Module" - VODController[VOD Reviews Controller] - VODModel[VOD Review Model] - TimestampModel[Timestamp Model] -end -subgraph "Team Goals Module" - GoalsController[Team Goals Controller] - GoalModel[Team Goal Model] -end -subgraph "Riot Integration Module" - RiotService[Riot API Service] - RiotSync[Sync Service] -end + subgraph "Authentication Module" + AuthController[Auth Controller] + JWTService[JWT Service] + UserModel[User Model] + end + + subgraph "Dashboard Module" + DashboardController[Dashboard Controller] + DashStats[Statistics Service] + end + + subgraph "Players Module" + PlayersController[Players Controller] + PlayerModel[Player Model] + ChampionPoolModel[Champion Pool Model] + end + + subgraph "Scouting Module" + ScoutingController[Scouting Controller] + ScoutingTargetModel[Scouting Target Model] + Watchlist[Watchlist Service] + end + + subgraph "Analytics Module" + AnalyticsController[Analytics Controller] + PerformanceService[Performance Service] + KDAService[KDA Trend Service] + end + + subgraph "Matches Module" + MatchesController[Matches Controller] + MatchModel[Match Model] + PlayerMatchStatModel[Player Match Stat Model] + end + + subgraph "Schedules Module" + SchedulesController[Schedules Controller] + ScheduleModel[Schedule Model] + end + + subgraph "VOD Reviews Module" + VODController[VOD Reviews Controller] + VodReviewModel[VOD Review Model] + VodTimestampModel[VOD Timestamp Model] + end + + subgraph "Team Goals Module" + GoalsController[Team Goals Controller] + TeamGoalModel[Team Goal Model] + end + + subgraph "Riot Integration Module" + RiotService[Riot API Service] + RiotSync[Sync Service] + end + + subgraph "Competitive Module" + CompetitiveController[Competitive Controller] + ProMatchesController[Pro Matches Controller] + PandaScoreService[PandaScore Service] + DraftAnalyzer[Draft Analyzer] + end + + subgraph "Scrims Module" + ScrimsController[Scrims Controller] + OpponentTeamsController[Opponent Teams Controller] + ScrimAnalytics[Scrim Analytics Service] + end + + subgraph "Strategy Module" + DraftPlansController[Draft Plans Controller] + TacticalBoardsController[Tactical Boards Controller] + DraftAnalysisService[Draft Analysis Service] + end + + subgraph "Support Module" + SupportTicketsController[Support Tickets Controller] + SupportFaqsController[Support FAQs Controller] + SupportStaffController[Support Staff Controller] + SupportTicketModel[Support Ticket Model] + SupportFaqModel[Support FAQ Model] + end end subgraph "Data Layer" @@ -121,6 +249,7 @@ end subgraph "External Services" RiotAPI[Riot Games API] + PandaScoreAPI[PandaScore API] end Client -->|HTTP/JSON| CORS @@ -137,49 +266,79 @@ end Router --> SchedulesController Router --> VODController Router --> GoalsController + Router --> CompetitiveController + Router --> ProMatchesController + Router --> ScrimsController + Router --> OpponentTeamsController + Router --> DraftPlansController + Router --> TacticalBoardsController + Router --> SupportTicketsController + Router --> SupportFaqsController + Router --> SupportStaffController AuthController --> JWTService AuthController --> UserModel PlayersController --> PlayerModel - PlayerModel --> ChampionPool - ScoutingController --> ScoutingTarget + PlayerModel --> ChampionPoolModel + ScoutingController --> ScoutingTargetModel ScoutingController --> Watchlist + Watchlist --> PostgreSQL MatchesController --> MatchModel - MatchModel --> PlayerMatchStats + MatchModel --> PlayerMatchStatModel SchedulesController --> ScheduleModel - VODController --> VODModel - VODModel --> TimestampModel - GoalsController --> GoalModel + VODController --> VodReviewModel + VodReviewModel --> VodTimestampModel + GoalsController --> TeamGoalModel AnalyticsController --> PerformanceService AnalyticsController --> KDAService + CompetitiveController --> PandaScoreService + CompetitiveController --> DraftAnalyzer + ScrimsController --> ScrimAnalytics + ScrimAnalytics --> PostgreSQL + DraftPlansController --> DraftAnalysisService + SupportTicketsController --> SupportTicketModel + SupportFaqsController --> SupportFaqModel + SupportStaffController --> UserModel AuditLogModel[AuditLog Model] --> PostgreSQL ChampionPoolModel[ChampionPool Model] --> PostgreSQL + CompetitiveMatchModel[CompetitiveMatch Model] --> PostgreSQL + DraftPlanModel[DraftPlan Model] --> PostgreSQL MatchModel[Match Model] --> PostgreSQL NotificationModel[Notification Model] --> PostgreSQL + OpponentTeamModel[OpponentTeam Model] --> PostgreSQL OrganizationModel[Organization Model] --> PostgreSQL + PasswordResetTokenModel[PasswordResetToken Model] --> PostgreSQL PlayerModel[Player Model] --> PostgreSQL PlayerMatchStatModel[PlayerMatchStat Model] --> PostgreSQL ScheduleModel[Schedule Model] --> PostgreSQL ScoutingTargetModel[ScoutingTarget Model] --> PostgreSQL + ScrimModel[Scrim Model] --> PostgreSQL + SupportFaqModel[SupportFaq Model] --> PostgreSQL + SupportTicketModel[SupportTicket Model] --> PostgreSQL + SupportTicketMessageModel[SupportTicketMessage Model] --> PostgreSQL + TacticalBoardModel[TacticalBoard Model] --> PostgreSQL TeamGoalModel[TeamGoal Model] --> PostgreSQL + TokenBlacklistModel[TokenBlacklist Model] --> PostgreSQL UserModel[User Model] --> PostgreSQL VodReviewModel[VodReview Model] --> PostgreSQL VodTimestampModel[VodTimestamp Model] --> PostgreSQL JWTService --> Redis DashStats --> Redis PerformanceService --> Redis -PlayersController --> RiotService -MatchesController --> RiotService -ScoutingController --> RiotService -RiotService --> RiotAPI - -RiotService --> Sidekiq -Sidekiq --> JobQueue -JobQueue --> Redis + PlayersController --> RiotService + MatchesController --> RiotService + ScoutingController --> RiotService + RiotService --> RiotSync + RiotService --> RiotAPI + + RiotService --> Sidekiq + PandaScoreService --> PandaScoreAPI[PandaScore API] + Sidekiq -- Uses --> Redis style Client fill:#e1f5ff style PostgreSQL fill:#336791 style Redis fill:#d82c20 style RiotAPI fill:#eb0029 + style PandaScoreAPI fill:#ff6b35 style Sidekiq fill:#b1003e ``` @@ -254,7 +413,78 @@ rails server The API will be available at `http://localhost:3333` -## API Documentation +## Development Tools + +### Generate Secrets + +Generate secure secrets for your `.env` file: + +```bash +./scripts/generate_secrets.sh +``` + +This will generate: +- `SECRET_KEY_BASE` - Rails secret key +- `JWT_SECRET_KEY` - JWT signing key + +### Get JWT Token (for API testing) + +Generate a JWT token for testing the API: + +```bash +./scripts/get-token.sh +``` + +This will: +1. Create or find a test user (`test@prostaff.gg`) +2. Generate a valid JWT token +3. Show instructions on how to use it + +**Quick usage:** +```bash +# Export to environment variable +export BEARER_TOKEN=$(./scripts/get-token.sh | grep -oP 'eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*') + +# Use in curl +curl -H "Authorization: Bearer $BEARER_TOKEN" http://localhost:3333/api/v1/players +``` + +**Custom credentials:** +```bash +TEST_EMAIL="admin@example.com" TEST_PASSWORD="MyPass123!" ./scripts/get-token.sh +``` +
+ 📚 API Documentation (Click to show details) + +### Interactive Documentation (Swagger UI) + +The API provides interactive documentation powered by Swagger/OpenAPI 3.0: + +**Access the docs:** +``` +http://localhost:3333/api-docs +``` + +**Features:** +- ✅ Try out endpoints directly from the browser +- ✅ See request/response schemas +- ✅ Authentication support (Bearer token) +- ✅ Complete parameter documentation +- ✅ Example requests and responses + +### Generating/Updating Documentation + +The Swagger documentation is automatically generated from RSpec integration tests: + +```bash +# Run integration specs and generate Swagger docs +bundle exec rake rswag:specs:swaggerize + +# Or run specs individually +bundle exec rspec spec/integration/ +``` + +The generated documentation file is located at `swagger/v1/swagger.yaml`. ### Base URL ``` @@ -269,6 +499,29 @@ All endpoints (except auth endpoints) require a Bearer token in the Authorizatio Authorization: Bearer ``` +**Token Details:** +- **Access Token**: Expires in 24 hours (configurable via `JWT_EXPIRATION_HOURS`) +- **Refresh Token**: Expires in 7 days +- **Token Type**: Bearer (JWT) + +**Getting a token:** +```bash +# Option 1: Use the script +./scripts/get-token.sh + +# Option 2: Login via API +curl -X POST http://localhost:3333/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@prostaff.gg","password":"Test123!@#"}' +``` + +**Refreshing a token:** +```bash +curl -X POST http://localhost:3333/api/v1/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refresh_token":"your-refresh-token"}' +``` + ### Authentication Endpoints - `POST /auth/register` - Register new organization and admin user @@ -308,16 +561,183 @@ Authorization: Bearer - `POST /scouting/players` - Add scouting target #### Analytics -- `GET /analytics/performance` - Player performance data -- `GET /analytics/champions/:player_id` - Champion statistics -- `GET /analytics/kda-trend/:player_id` - KDA trend analysis +- `GET /analytics/performance` - Team performance analytics +- `GET /analytics/team-comparison` - Compare all players +- `GET /analytics/champions/:player_id` - Champion pool statistics +- `GET /analytics/kda-trend/:player_id` - KDA trend over time +- `GET /analytics/laning/:player_id` - Laning phase performance +- `GET /analytics/teamfights/:player_id` - Teamfight performance +- `GET /analytics/vision/:player_id` - Vision control statistics + +#### Schedules +- `GET /schedules` - List all scheduled events +- `GET /schedules/:id` - Get schedule details +- `POST /schedules` - Create new event +- `PATCH /schedules/:id` - Update event +- `DELETE /schedules/:id` - Delete event + +#### Team Goals +- `GET /team-goals` - List all goals +- `GET /team-goals/:id` - Get goal details +- `POST /team-goals` - Create new goal +- `PATCH /team-goals/:id` - Update goal progress +- `DELETE /team-goals/:id` - Delete goal + +#### VOD Reviews +- `GET /vod-reviews` - List VOD reviews +- `GET /vod-reviews/:id` - Get review details +- `POST /vod-reviews` - Create new review +- `PATCH /vod-reviews/:id` - Update review +- `DELETE /vod-reviews/:id` - Delete review +- `GET /vod-reviews/:id/timestamps` - List timestamps +- `POST /vod-reviews/:id/timestamps` - Create timestamp +- `PATCH /vod-timestamps/:id` - Update timestamp +- `DELETE /vod-timestamps/:id` - Delete timestamp + +#### Riot Data +- `GET /riot-data/champions` - Get champions ID map +- `GET /riot-data/champions/:key` - Get champion details +- `GET /riot-data/all-champions` - Get all champions data +- `GET /riot-data/items` - Get all items +- `GET /riot-data/summoner-spells` - Get summoner spells +- `GET /riot-data/version` - Get current Data Dragon version +- `POST /riot-data/clear-cache` - Clear Data Dragon cache (owner only) +- `POST /riot-data/update-cache` - Update Data Dragon cache (owner only) + +#### Riot Integration +- `GET /riot-integration/sync-status` - Get sync status for all players + +#### Competitive (PandaScore Integration) +- `GET /competitive-matches` - List competitive matches +- `GET /competitive-matches/:id` - Get competitive match details +- `GET /competitive/pro-matches` - List all pro matches +- `GET /competitive/pro-matches/:id` - Get pro match details +- `GET /competitive/pro-matches/upcoming` - Get upcoming pro matches +- `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 +- `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 + +#### Scrims Management +- `GET /scrims/scrims` - List all scrims +- `GET /scrims/scrims/:id` - Get scrim details +- `POST /scrims/scrims` - Create new scrim +- `PATCH /scrims/scrims/:id` - Update scrim +- `DELETE /scrims/scrims/:id` - Delete scrim +- `POST /scrims/scrims/:id/add_game` - Add game to scrim +- `GET /scrims/scrims/calendar` - Get scrims calendar +- `GET /scrims/scrims/analytics` - Get scrims analytics +- `GET /scrims/opponent-teams` - List opponent teams +- `GET /scrims/opponent-teams/:id` - Get opponent team details +- `POST /scrims/opponent-teams` - Create opponent team +- `PATCH /scrims/opponent-teams/:id` - Update opponent team +- `DELETE /scrims/opponent-teams/:id` - Delete opponent team +- `GET /scrims/opponent-teams/:id/scrim-history` - Get scrim history with opponent + +#### Strategy Module +- `GET /strategy/draft-plans` - List draft plans +- `GET /strategy/draft-plans/:id` - Get draft plan details +- `POST /strategy/draft-plans` - Create new draft plan +- `PATCH /strategy/draft-plans/:id` - Update draft plan +- `DELETE /strategy/draft-plans/:id` - Delete draft plan +- `POST /strategy/draft-plans/:id/analyze` - Analyze draft plan +- `PATCH /strategy/draft-plans/:id/activate` - Activate draft plan +- `PATCH /strategy/draft-plans/:id/deactivate` - Deactivate draft plan +- `GET /strategy/tactical-boards` - List tactical boards +- `GET /strategy/tactical-boards/:id` - Get tactical board details +- `POST /strategy/tactical-boards` - Create new tactical board +- `PATCH /strategy/tactical-boards/:id` - Update tactical board +- `DELETE /strategy/tactical-boards/:id` - Delete tactical board +- `GET /strategy/tactical-boards/:id/statistics` - Get tactical board statistics +- `GET /strategy/assets/champion/:champion_name` - Get champion assets +- `GET /strategy/assets/map` - Get map assets + +#### Support System +- `GET /support/tickets` - List user's tickets +- `GET /support/tickets/:id` - Get ticket details +- `POST /support/tickets` - Create new support ticket +- `PATCH /support/tickets/:id` - Update ticket +- `DELETE /support/tickets/:id` - Delete ticket +- `POST /support/tickets/:id/close` - Close ticket +- `POST /support/tickets/:id/reopen` - Reopen ticket +- `POST /support/tickets/:id/messages` - Add message to ticket +- `GET /support/faq` - List all FAQs +- `GET /support/faq/:slug` - Get FAQ by slug +- `POST /support/faq/:slug/helpful` - Mark FAQ as helpful +- `POST /support/faq/:slug/not-helpful` - Mark FAQ as not helpful +- `GET /support/staff/dashboard` - Support staff dashboard (staff only) +- `GET /support/staff/analytics` - Support analytics (staff only) +- `POST /support/staff/tickets/:id/assign` - Assign ticket to staff (staff only) +- `POST /support/staff/tickets/:id/resolve` - Resolve ticket (staff only) + +**For complete endpoint documentation with request/response examples, visit `/api-docs`** + +
+ +## 🧪 Testing + +### Unit & Request Tests + +Run the complete test suite: -## Testing +```bash +bundle exec rspec +``` + +Run specific test types: +```bash +# Unit tests (models, services) +bundle exec rspec spec/models +bundle exec rspec spec/services -Run the test suite: +# Request tests (controllers) +bundle exec rspec spec/requests + +# Integration tests (Swagger documentation) +bundle exec rspec spec/integration +``` + +### Integration Tests (Swagger Documentation) + +Integration tests serve dual purpose: +1. **Test API endpoints** with real HTTP requests +2. **Generate Swagger documentation** automatically ```bash -bundle exec rspec +# Run integration tests and generate Swagger docs +bundle exec rake rswag:specs:swaggerize + +# Run specific integration spec +bundle exec rspec spec/integration/players_spec.rb +``` + +**Current coverage:** +- ✅ Authentication (8 endpoints) +- ✅ Players (9 endpoints) +- ✅ Matches (11 endpoints) +- ✅ Scouting (10 endpoints) +- ✅ Schedules (8 endpoints) +- ✅ Team Goals (8 endpoints) +- ✅ VOD Reviews (11 endpoints) +- ✅ Analytics (7 endpoints) +- ✅ Riot Data (14 endpoints) +- ✅ Riot Integration (1 endpoint) +- ✅ Dashboard (4 endpoints) +- ✅ Competitive (14 endpoints) +- ✅ Scrims (14 endpoints) +- ✅ Strategy (16 endpoints) +- ✅ Support (15 endpoints) + +**Total:** 170+ endpoints documented + +### Code Coverage + +View coverage report after running tests: +```bash +open coverage/index.html ``` ## Deployment @@ -461,4 +881,12 @@ This work is licensed under a [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 \ No newline at end of file +[cc-by-nc-sa-shield]: https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg + + + +## Disclaimer + +Prostaff.gg isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot Games or anyone officially involved in producing or managing Riot Games properties. + +Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc. diff --git a/Rakefile b/Rakefile index a0052a2..488c551 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,8 @@ -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. - -require_relative "config/application" - -Rails.application.load_tasks \ No newline at end of file +# frozen_string_literal: true + +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/app/controllers/api/v1/analytics/champions_controller.rb b/app/controllers/api/v1/analytics/champions_controller.rb index 8f2ac88..0930350 100644 --- a/app/controllers/api/v1/analytics/champions_controller.rb +++ b/app/controllers/api/v1/analytics/champions_controller.rb @@ -1,54 +1,82 @@ -class Api::V1::Analytics::ChampionsController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) - - stats = PlayerMatchStat.where(player: player) - .group(:champion) - .select( - 'champion', - 'COUNT(*) as games_played', - 'SUM(CASE WHEN matches.victory THEN 1 ELSE 0 END) as wins', - 'AVG((kills + assists)::float / NULLIF(deaths, 0)) as avg_kda' - ) - .joins(:match) - .order('games_played DESC') - - champion_stats = stats.map do |stat| - win_rate = stat.games_played.zero? ? 0 : (stat.wins.to_f / stat.games_played) - { - champion: stat.champion, - games_played: stat.games_played, - win_rate: win_rate, - avg_kda: stat.avg_kda&.round(2) || 0, - mastery_grade: calculate_mastery_grade(win_rate, stat.avg_kda) - } - end - - champion_data = { - player: PlayerSerializer.render_as_hash(player), - champion_stats: champion_stats, - top_champions: champion_stats.take(5), - champion_diversity: { - total_champions: champion_stats.count, - highly_played: champion_stats.count { |c| c[:games_played] >= 10 }, - average_games: champion_stats.empty? ? 0 : (champion_stats.sum { |c| c[:games_played] } / champion_stats.count.to_f).round(1) - } - } - - render_success(champion_data) - end - - private - - def calculate_mastery_grade(win_rate, avg_kda) - score = (win_rate * 100 * 0.6) + ((avg_kda || 0) * 10 * 0.4) - - case score - when 80..Float::INFINITY then 'S' - when 70...80 then 'A' - when 60...70 then 'B' - when 50...60 then 'C' - else 'D' - end - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # Champion Analytics Controller + # + # Provides detailed champion performance statistics for individual players. + # Analyzes champion pool diversity, mastery levels, and win rates across all champions played. + # + # @example GET /api/v1/analytics/champions/:player_id + # { + # player: { id: 1, name: "Player1" }, + # champion_stats: [{ champion: "Aatrox", games_played: 15, win_rate: 0.6, avg_kda: 3.2, mastery_grade: "A" }], + # champion_diversity: { total_champions: 25, highly_played: 5, average_games: 3.2 } + # } + # + # Main endpoints: + # - GET show: Returns comprehensive champion statistics including mastery grades and diversity metrics + class ChampionsController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.where(player: player) + .group(:champion) + .select( + 'champion', + 'COUNT(*) as games_played', + 'SUM(CASE WHEN matches.victory THEN 1 ELSE 0 END) as wins', + 'AVG((kills + assists)::float / NULLIF(deaths, 0)) as avg_kda' + ) + .joins(:match) + .order('games_played DESC') + + champion_stats = stats.map do |stat| + win_rate = stat.games_played.zero? ? 0 : (stat.wins.to_f / stat.games_played) + { + champion: stat.champion, + games_played: stat.games_played, + win_rate: win_rate, + avg_kda: stat.avg_kda&.round(2) || 0, + mastery_grade: calculate_mastery_grade(win_rate, stat.avg_kda) + } + end + + champion_data = { + player: PlayerSerializer.render_as_hash(player), + champion_stats: champion_stats, + top_champions: champion_stats.take(5), + champion_diversity: { + total_champions: champion_stats.count, + highly_played: champion_stats.count { |c| c[:games_played] >= 10 }, + average_games: if champion_stats.empty? + 0 + else + (champion_stats.sum do |c| + c[:games_played] + end / champion_stats.count.to_f).round(1) + end + } + } + + render_success(champion_data) + end + + private + + def calculate_mastery_grade(win_rate, avg_kda) + score = (win_rate * 100 * 0.6) + ((avg_kda || 0) * 10 * 0.4) + + case score + when 80..Float::INFINITY then 'S' + when 70...80 then 'A' + when 60...70 then 'B' + when 50...60 then 'C' + else 'D' + end + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/kda_trend_controller.rb b/app/controllers/api/v1/analytics/kda_trend_controller.rb index 3fd93bd..a2c71cc 100644 --- a/app/controllers/api/v1/analytics/kda_trend_controller.rb +++ b/app/controllers/api/v1/analytics/kda_trend_controller.rb @@ -1,49 +1,70 @@ -class Api::V1::Analytics::KdaTrendController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) - - # Get recent matches for the player - stats = PlayerMatchStat.joins(:match) - .where(player: player, match: { organization: current_organization }) - .order('matches.game_start DESC') - .limit(50) - .includes(:match) - - trend_data = { - player: PlayerSerializer.render_as_hash(player), - kda_by_match: stats.map do |stat| - kda = stat.deaths.zero? ? (stat.kills + stat.assists).to_f : ((stat.kills + stat.assists).to_f / stat.deaths) - { - match_id: stat.match.id, - date: stat.match.game_start, - kills: stat.kills, - deaths: stat.deaths, - assists: stat.assists, - kda: kda.round(2), - champion: stat.champion, - victory: stat.match.victory - } - end, - averages: { - last_10_games: calculate_kda_average(stats.limit(10)), - last_20_games: calculate_kda_average(stats.limit(20)), - overall: calculate_kda_average(stats) - } - } - - render_success(trend_data) - end - - private - - def calculate_kda_average(stats) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # KDA Trend Analytics Controller + # + # Tracks kill/death/assist performance trends over time for players. + # Analyzes recent match history to identify performance patterns and calculate rolling averages. + # + # @example GET /api/v1/analytics/kda_trend/:player_id + # { + # kda_by_match: [{ match_id: 1, kda: 3.5, kills: 5, deaths: 2, assists: 2, victory: true }], + # averages: { last_10_games: 3.2, last_20_games: 2.9, overall: 2.8 } + # } + # + # 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]) + + # Get recent matches for the player + stats = PlayerMatchStat.joins(:match) + .where(player: player, matches: { organization_id: current_organization.id }) + .order('matches.game_start DESC') + .limit(50) + .includes(:match) + + trend_data = { + player: PlayerSerializer.render_as_hash(player), + kda_by_match: stats.map do |stat| + kda = stat.deaths.zero? ? (stat.kills + stat.assists).to_f : ((stat.kills + stat.assists).to_f / stat.deaths) + { + match_id: stat.match.id, + date: stat.match.game_start, + kills: stat.kills, + deaths: stat.deaths, + assists: stat.assists, + kda: kda.round(2), + champion: stat.champion, + victory: stat.match.victory + } + end, + averages: { + last_10_games: calculate_kda_average(stats.limit(10)), + last_20_games: calculate_kda_average(stats.limit(20)), + overall: calculate_kda_average(stats) + } + } + + render_success(trend_data) + end + + private + + def calculate_kda_average(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/laning_controller.rb b/app/controllers/api/v1/analytics/laning_controller.rb index aac4726..0f0d86a 100644 --- a/app/controllers/api/v1/analytics/laning_controller.rb +++ b/app/controllers/api/v1/analytics/laning_controller.rb @@ -1,61 +1,83 @@ -class Api::V1::Analytics::LaningController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) - - stats = PlayerMatchStat.joins(:match) - .where(player: player, match: { organization: current_organization }) - .order('matches.game_start DESC') - .limit(20) - - laning_data = { - player: PlayerSerializer.render_as_hash(player), - cs_performance: { - avg_cs_total: stats.average('minions_killed + jungle_minions_killed')&.round(1), - avg_cs_per_min: calculate_avg_cs_per_min(stats), - best_cs_game: stats.maximum('minions_killed + jungle_minions_killed'), - worst_cs_game: stats.minimum('minions_killed + jungle_minions_killed') - }, - gold_performance: { - avg_gold: stats.average(:gold_earned)&.round(0), - best_gold_game: stats.maximum(:gold_earned), - worst_gold_game: stats.minimum(:gold_earned) - }, - cs_by_match: stats.map do |stat| - match_duration_mins = stat.match.game_duration ? stat.match.game_duration / 60.0 : 25 - cs_total = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) - cs_per_min = cs_total / match_duration_mins - - { - match_id: stat.match.id, - date: stat.match.game_start, - cs_total: cs_total, - cs_per_min: cs_per_min.round(1), - gold: stat.gold_earned, - champion: stat.champion, - victory: stat.match.victory - } - end - } - - render_success(laning_data) - end - - private - - def calculate_avg_cs_per_min(stats) - total_cs = 0 - total_minutes = 0 - - stats.each do |stat| - if stat.match.game_duration - cs = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) - minutes = stat.match.game_duration / 60.0 - total_cs += cs - total_minutes += minutes - end - end - - return 0 if total_minutes.zero? - (total_cs / total_minutes).round(1) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # Laning Phase Analytics Controller + # + # Provides early game performance metrics focusing on CS (creep score) and gold acquisition. + # Tracks farming efficiency with CS per minute calculations and gold earnings. + # + # @example GET /api/v1/analytics/laning/:player_id + # { + # cs_performance: { avg_cs_total: 185.5, avg_cs_per_min: 7.4, best_cs_game: 245 }, + # gold_performance: { avg_gold: 12500, best_gold_game: 15000 } + # } + # + # Main endpoints: + # - GET show: Returns laning statistics for the last 20 matches with CS and gold metrics + class LaningController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + laning_data = { + player: PlayerSerializer.render_as_hash(player), + cs_performance: { + avg_cs_total: stats.average('minions_killed + jungle_minions_killed')&.round(1), + avg_cs_per_min: calculate_avg_cs_per_min(stats), + best_cs_game: stats.maximum('minions_killed + jungle_minions_killed'), + worst_cs_game: stats.minimum('minions_killed + jungle_minions_killed') + }, + gold_performance: { + avg_gold: stats.average(:gold_earned)&.round(0), + best_gold_game: stats.maximum(:gold_earned), + worst_gold_game: stats.minimum(:gold_earned) + }, + cs_by_match: stats.map do |stat| + match_duration_mins = stat.match.game_duration ? stat.match.game_duration / 60.0 : 25 + cs_total = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + cs_per_min = cs_total / match_duration_mins + + { + match_id: stat.match.id, + date: stat.match.game_start, + cs_total: cs_total, + cs_per_min: cs_per_min.round(1), + gold: stat.gold_earned, + champion: stat.champion, + victory: stat.match.victory + } + end + } + + render_success(laning_data) + end + + private + + def calculate_avg_cs_per_min(stats) + total_cs = 0 + total_minutes = 0 + + stats.each do |stat| + next unless stat.match.game_duration + + cs = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + minutes = stat.match.game_duration / 60.0 + total_cs += cs + total_minutes += minutes + end + + return 0 if total_minutes.zero? + + (total_cs / total_minutes).round(1) + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/performance_controller.rb b/app/controllers/api/v1/analytics/performance_controller.rb index 2585a3b..98df18c 100644 --- a/app/controllers/api/v1/analytics/performance_controller.rb +++ b/app/controllers/api/v1/analytics/performance_controller.rb @@ -1,138 +1,192 @@ -class Api::V1::Analytics::PerformanceController < Api::V1::BaseController - def index - # Team performance analytics - matches = organization_scoped(Match) - players = organization_scoped(Player).active - - # Date range filter - if params[:start_date].present? && params[:end_date].present? - matches = matches.in_date_range(params[:start_date], params[:end_date]) - else - matches = matches.recent(30) # Default to last 30 days - end - - performance_data = { - overview: calculate_team_overview(matches), - win_rate_trend: calculate_win_rate_trend(matches), - performance_by_role: calculate_performance_by_role(matches), - best_performers: identify_best_performers(players, matches), - match_type_breakdown: calculate_match_type_breakdown(matches) - } - - render_success(performance_data) - end - - private - - def calculate_team_overview(matches) - stats = PlayerMatchStat.where(match: matches) - - { - total_matches: matches.count, - wins: matches.victories.count, - losses: matches.defeats.count, - win_rate: calculate_win_rate(matches), - avg_game_duration: matches.average(:game_duration)&.round(0), - avg_kda: calculate_avg_kda(stats), - avg_kills_per_game: stats.average(:kills)&.round(1), - avg_deaths_per_game: stats.average(:deaths)&.round(1), - avg_assists_per_game: stats.average(:assists)&.round(1), - avg_gold_per_game: stats.average(:gold_earned)&.round(0), - avg_damage_per_game: stats.average(:total_damage_dealt)&.round(0), - avg_vision_score: stats.average(:vision_score)&.round(1) - } - end - - def calculate_win_rate_trend(matches) - # Calculate win rate for each week - matches.group_by { |m| m.game_start.beginning_of_week }.map do |week, week_matches| - wins = week_matches.count(&:victory?) - total = week_matches.size - win_rate = total.zero? ? 0 : ((wins.to_f / total) * 100).round(1) - - { - week: week.strftime('%Y-%m-%d'), - matches: total, - wins: wins, - losses: total - wins, - win_rate: win_rate - } - end.sort_by { |d| d[:week] } - end - - def calculate_performance_by_role(matches) - stats = PlayerMatchStat.joins(:player).where(match: matches) - - stats.group('players.role').select( - 'players.role', - 'COUNT(*) as games', - 'AVG(player_match_stats.kills) as avg_kills', - 'AVG(player_match_stats.deaths) as avg_deaths', - 'AVG(player_match_stats.assists) as avg_assists', - 'AVG(player_match_stats.gold_earned) as avg_gold', - 'AVG(player_match_stats.total_damage_dealt) as avg_damage', - 'AVG(player_match_stats.vision_score) as avg_vision' - ).map do |stat| - { - role: stat.role, - games: stat.games, - avg_kda: { - kills: stat.avg_kills&.round(1) || 0, - deaths: stat.avg_deaths&.round(1) || 0, - assists: stat.avg_assists&.round(1) || 0 - }, - avg_gold: stat.avg_gold&.round(0) || 0, - avg_damage: stat.avg_damage&.round(0) || 0, - avg_vision: stat.avg_vision&.round(1) || 0 - } - end - end - - def identify_best_performers(players, matches) - players.map do |player| - stats = PlayerMatchStat.where(player: player, match: matches) - next if stats.empty? - - { - player: PlayerSerializer.render_as_hash(player), - games: stats.count, - avg_kda: calculate_avg_kda(stats), - avg_performance_score: stats.average(:performance_score)&.round(1) || 0, - mvp_count: stats.joins(:match).where(matches: { victory: true }).count - } - end.compact.sort_by { |p| -p[:avg_performance_score] }.take(5) - end - - def calculate_match_type_breakdown(matches) - matches.group(:match_type).select( - 'match_type', - 'COUNT(*) as total', - 'SUM(CASE WHEN victory THEN 1 ELSE 0 END) as wins' - ).map do |stat| - win_rate = stat.total.zero? ? 0 : ((stat.wins.to_f / stat.total) * 100).round(1) - { - match_type: stat.match_type, - total: stat.total, - wins: stat.wins, - losses: stat.total - stat.wins, - win_rate: win_rate - } - end - end - - def calculate_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_avg_kda(stats) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # Performance Analytics Controller + # + # Provides endpoints for viewing team and player performance metrics. + # Delegates complex calculations to PerformanceAnalyticsService. + # + # This controller handles: + # - Team overview statistics (wins, losses, KDA, etc.) + # - Win rate trends over time + # - Performance breakdown by role + # - Top performer identification + # - Individual player statistics + # + # Supports filtering by date range, time period, and individual player stats. + # All calculations are scoped to the current organization. + # + # @example Get team performance for last 30 days + # GET /api/v1/analytics/performance + # + # @example Get performance with player stats + # GET /api/v1/analytics/performance?player_id=123 + # + # @example Get performance for a specific date range + # GET /api/v1/analytics/performance?start_date=2025-01-01&end_date=2025-01-31 + # + # @example Get performance for a time period + # GET /api/v1/analytics/performance?time_period=week + class PerformanceController < Api::V1::BaseController + include Analytics::Concerns::AnalyticsCalculations + + # Returns performance analytics for the organization + # + # Supports filtering by date range and includes individual player stats if requested. + # + # GET /api/v1/analytics/performance + # + # @param start_date [Date] Start date for filtering (optional) + # @param end_date [Date] End date for filtering (optional) + # @param time_period [String] Predefined period: week, month, or season (optional) + # @param player_id [Integer] Player ID for individual stats (optional) + # @return [JSON] Performance analytics data + def index + matches = apply_date_filters(organization_scoped(Match)) + players = organization_scoped(Player).active + + service = Analytics::Services::PerformanceAnalyticsService.new(matches, players) + performance_data = service.calculate_performance_data(player_id: params[:player_id]) + + render_success(performance_data) + end + + private + + # Applies date range filters to matches based on params + # + # @param matches [ActiveRecord::Relation] Matches relation to filter + # @return [ActiveRecord::Relation] Filtered matches + 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[:time_period].present? + days = time_period_to_days(params[:time_period]) + matches.where('game_start >= ?', days.days.ago) + else + matches.recent(30) # Default to last 30 days + end + end + + # Converts time period string to number of days + # + # @param period [String] Time period (week, month, season) + # @return [Integer] Number of days + def time_period_to_days(period) + return 7 if period == 'week' + return 90 if period == 'season' + 30 + end + + # Legacy method - kept for backwards compatibility + # TODO: Remove after migrating all callers to PerformanceAnalyticsService + def calculate_team_overview(matches) + stats = PlayerMatchStat.where(match: matches) + + { + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: calculate_win_rate(matches), + avg_game_duration: matches.average(:game_duration)&.round(0), + avg_kda: calculate_avg_kda(stats), + avg_kills_per_game: stats.average(:kills)&.round(1), + avg_deaths_per_game: stats.average(:deaths)&.round(1), + avg_assists_per_game: stats.average(:assists)&.round(1), + avg_gold_per_game: stats.average(:gold_earned)&.round(0), + avg_damage_per_game: stats.average(:damage_dealt_total)&.round(0), + avg_vision_score: stats.average(:vision_score)&.round(1) + } + end + + # Legacy methods - moved to PerformanceAnalyticsService and AnalyticsCalculations + # These methods now delegate to the concern + # TODO: Remove after confirming no external dependencies + + def identify_best_performers(players, matches) + players.map do |player| + stats = PlayerMatchStat.where(player: player, match: matches) + next if stats.empty? + + { + player: PlayerSerializer.render_as_hash(player), + games: stats.count, + avg_kda: calculate_avg_kda(stats), + avg_performance_score: stats.average(:performance_score)&.round(1) || 0, + mvp_count: stats.joins(:match).where(matches: { victory: true }).count + } + end.compact.sort_by { |p| -p[:avg_performance_score] }.take(5) + end + + def calculate_match_type_breakdown(matches) + matches.group(:match_type).select( + 'match_type', + 'COUNT(*) as total', + 'SUM(CASE WHEN victory THEN 1 ELSE 0 END) as wins' + ).map do |stat| + win_rate = stat.total.zero? ? 0 : ((stat.wins.to_f / stat.total) * 100).round(1) + { + match_type: stat.match_type, + total: stat.total, + wins: stat.wins, + losses: stat.total - stat.wins, + win_rate: win_rate + } + end + end + + # Methods moved to Analytics::Concerns::AnalyticsCalculations: + # - calculate_win_rate + # - calculate_avg_kda + + def calculate_player_stats(player, matches) + stats = PlayerMatchStat.where(player: player, match: matches) + + return nil if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + games_played = stats.count + + # Calculate win rate as decimal (0-1) for frontend + wins = stats.joins(:match).where(matches: { victory: true }).count + win_rate = games_played.zero? ? 0.0 : (wins.to_f / games_played) + + # Calculate KDA + deaths = total_deaths.zero? ? 1 : total_deaths + kda = ((total_kills + total_assists).to_f / deaths).round(2) + + # Calculate CS per min + total_cs = stats.sum(:cs) + total_duration = matches.where(id: stats.pluck(:match_id)).sum(:game_duration) + cs_per_min = calculate_cs_per_min(total_cs, total_duration) + + # Calculate gold per min + total_gold = stats.sum(:gold_earned) + gold_per_min = calculate_gold_per_min(total_gold, total_duration) + + # Calculate vision score + vision_score = stats.average(:vision_score)&.round(1) || 0.0 + + { + player_id: player.id, + summoner_name: player.summoner_name, + games_played: games_played, + win_rate: win_rate, + kda: kda, + cs_per_min: cs_per_min, + gold_per_min: gold_per_min, + vision_score: vision_score, + damage_share: 0.0, # Would need total team damage to calculate + avg_kills: (total_kills.to_f / games_played).round(1), + avg_deaths: (total_deaths.to_f / games_played).round(1), + avg_assists: (total_assists.to_f / games_played).round(1) + } + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/team_comparison_controller.rb b/app/controllers/api/v1/analytics/team_comparison_controller.rb index 447cb0b..fbecc1c 100644 --- a/app/controllers/api/v1/analytics/team_comparison_controller.rb +++ b/app/controllers/api/v1/analytics/team_comparison_controller.rb @@ -1,87 +1,151 @@ -class Api::V1::Analytics::TeamComparisonController < Api::V1::BaseController - def index - players = organization_scoped(Player).active.includes(:player_match_stats) - - # Get matches in date range - matches = organization_scoped(Match) - if params[:start_date].present? && params[:end_date].present? - matches = matches.in_date_range(params[:start_date], params[:end_date]) - else - matches = matches.recent(30) - end - - comparison_data = { - players: players.map do |player| - stats = PlayerMatchStat.where(player: player, match: matches) - next if stats.empty? - - { - player: PlayerSerializer.render_as_hash(player), - games_played: stats.count, - kda: calculate_kda(stats), - avg_damage: stats.average(:total_damage_dealt)&.round(0) || 0, - avg_gold: stats.average(:gold_earned)&.round(0) || 0, - avg_cs: stats.average('minions_killed + jungle_minions_killed')&.round(1) || 0, - avg_vision_score: stats.average(:vision_score)&.round(1) || 0, - avg_performance_score: stats.average(:performance_score)&.round(1) || 0, - multikills: { - double: stats.sum(:double_kills), - triple: stats.sum(:triple_kills), - quadra: stats.sum(:quadra_kills), - penta: stats.sum(:penta_kills) - } - } - end.compact.sort_by { |p| -p[:avg_performance_score] }, - team_averages: calculate_team_averages(matches), - role_rankings: calculate_role_rankings(players, matches) - } - - render_success(comparison_data) - end - - private - - def calculate_kda(stats) - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end - - def calculate_team_averages(matches) - all_stats = PlayerMatchStat.where(match: matches) - - { - avg_kda: calculate_kda(all_stats), - avg_damage: all_stats.average(:total_damage_dealt)&.round(0) || 0, - avg_gold: all_stats.average(:gold_earned)&.round(0) || 0, - avg_cs: all_stats.average('minions_killed + jungle_minions_killed')&.round(1) || 0, - avg_vision_score: all_stats.average(:vision_score)&.round(1) || 0 - } - end - - def calculate_role_rankings(players, matches) - rankings = {} - - %w[top jungle mid adc support].each do |role| - role_players = players.where(role: role) - role_data = role_players.map do |player| - stats = PlayerMatchStat.where(player: player, match: matches) - next if stats.empty? - - { - player_id: player.id, - summoner_name: player.summoner_name, - avg_performance: stats.average(:performance_score)&.round(1) || 0, - games: stats.count - } - end.compact.sort_by { |p| -p[:avg_performance] } - - rankings[role] = role_data - end - - rankings - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # API Controller for team performance comparison and analytics + # Provides endpoints to compare player statistics, team averages, and role rankings + # with advanced filtering options + class TeamComparisonController < Api::V1::BaseController + def index + players = fetch_active_players + matches = build_matches_query + + comparison_data = build_comparison_data(players, matches) + + render json: { data: comparison_data } + end + + private + + def fetch_active_players + organization_scoped(Player).active.includes(:player_match_stats) + end + + def build_matches_query + matches = organization_scoped(Match) + matches = apply_date_filter(matches) + matches = apply_opponent_filter(matches) + apply_match_type_filter(matches) + end + + def apply_date_filter(matches) + return matches.in_date_range(params[:start_date], params[:end_date]) if date_range_params? + return matches.recent(params[:days].to_i) if params[:days].present? + + matches.recent(30) + end + + def date_range_params? + params[:start_date].present? && params[:end_date].present? + end + + def apply_opponent_filter(matches) + return matches unless params[:opponent_team_id].present? + + matches.where(opponent_team_id: params[:opponent_team_id]) + end + + def apply_match_type_filter(matches) + return matches unless params[:match_type].present? + + matches.where(match_type: params[:match_type]) + end + + def build_comparison_data(players, matches) + { + players: build_player_comparisons(players, matches), + team_averages: calculate_team_averages(matches), + role_rankings: calculate_role_rankings(players, matches) + } + end + + def build_player_comparisons(players, matches) + player_stats = players.map { |player| build_player_stats(player, matches) } + sorted_stats = player_stats.compact + sorted_stats.sort_by { |p| -p[:avg_performance_score] } + end + + def build_player_stats(player, matches) + stats = PlayerMatchStat.where(player: player, match: matches) + return nil if stats.empty? + + { + player: PlayerSerializer.render_as_hash(player), + games_played: stats.count, + kda: calculate_kda(stats), + avg_damage: calculate_average(stats, :damage_dealt_total, 0), + avg_gold: calculate_average(stats, :gold_earned, 0), + avg_cs: calculate_average(stats, :cs, 1), + avg_vision_score: calculate_average(stats, :vision_score, 1), + avg_performance_score: calculate_average(stats, :performance_score, 1), + multikills: build_multikills(stats) + } + end + + def calculate_average(stats, column, precision) + stats.average(column)&.round(precision) || 0 + end + + def build_multikills(stats) + { + double: stats.sum(:double_kills), + triple: stats.sum(:triple_kills), + quadra: stats.sum(:quadra_kills), + penta: stats.sum(:penta_kills) + } + end + + def calculate_kda(stats) + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + + def calculate_team_averages(matches) + all_stats = PlayerMatchStat.where(match: matches) + + { + avg_kda: calculate_kda(all_stats), + avg_damage: calculate_average(all_stats, :damage_dealt_total, 0), + avg_gold: calculate_average(all_stats, :gold_earned, 0), + avg_cs: calculate_average(all_stats, :cs, 1), + avg_vision_score: calculate_average(all_stats, :vision_score, 1) + } + end + + def calculate_role_rankings(players, matches) + rankings = {} + + %w[top jungle mid adc support].each do |role| + rankings[role] = calculate_role_ranking(players, matches, role) + end + + rankings + end + + def calculate_role_ranking(players, matches, role) + role_players = players.where(role: role) + role_data = role_players.map { |player| build_role_player_data(player, matches) } + sorted_data = role_data.compact + sorted_data.sort_by { |p| -p[:avg_performance] } + end + + def build_role_player_data(player, matches) + stats = PlayerMatchStat.where(player: player, match: matches) + return nil if stats.empty? + + { + player_id: player.id, + summoner_name: player.summoner_name, + avg_performance: stats.average(:performance_score)&.round(1) || 0, + games: stats.count + } + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/teamfights_controller.rb b/app/controllers/api/v1/analytics/teamfights_controller.rb index 5b82aad..5c67177 100644 --- a/app/controllers/api/v1/analytics/teamfights_controller.rb +++ b/app/controllers/api/v1/analytics/teamfights_controller.rb @@ -1,64 +1,86 @@ -class Api::V1::Analytics::TeamfightsController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) - - stats = PlayerMatchStat.joins(:match) - .where(player: player, match: { organization: current_organization }) - .order('matches.game_start DESC') - .limit(20) - - teamfight_data = { - player: PlayerSerializer.render_as_hash(player), - damage_performance: { - avg_damage_dealt: stats.average(:total_damage_dealt)&.round(0), - avg_damage_taken: stats.average(:total_damage_taken)&.round(0), - best_damage_game: stats.maximum(:total_damage_dealt), - avg_damage_per_min: calculate_avg_damage_per_min(stats) - }, - participation: { - avg_kills: stats.average(:kills)&.round(1), - avg_assists: stats.average(:assists)&.round(1), - avg_deaths: stats.average(:deaths)&.round(1), - multikill_stats: { - double_kills: stats.sum(:double_kills), - triple_kills: stats.sum(:triple_kills), - quadra_kills: stats.sum(:quadra_kills), - penta_kills: stats.sum(:penta_kills) - } - }, - by_match: stats.map do |stat| - { - match_id: stat.match.id, - date: stat.match.game_start, - kills: stat.kills, - deaths: stat.deaths, - assists: stat.assists, - damage_dealt: stat.total_damage_dealt, - damage_taken: stat.total_damage_taken, - multikills: stat.double_kills + stat.triple_kills + stat.quadra_kills + stat.penta_kills, - champion: stat.champion, - victory: stat.match.victory - } - end - } - - render_success(teamfight_data) - end - - private - - def calculate_avg_damage_per_min(stats) - total_damage = 0 - total_minutes = 0 - - stats.each do |stat| - if stat.match.game_duration && stat.total_damage_dealt - total_damage += stat.total_damage_dealt - total_minutes += stat.match.game_duration / 60.0 - end - end - - return 0 if total_minutes.zero? - (total_damage / total_minutes).round(0) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # Teamfight Analytics Controller + # + # Analyzes combat performance including damage dealt, damage taken, and kill participation. + # Tracks multikill statistics and damage efficiency metrics for teamfight evaluation. + # + # @example GET /api/v1/analytics/teamfights/:player_id + # { + # damage_performance: { avg_damage_dealt: 18500, avg_damage_per_min: 740 }, + # participation: { avg_kills: 5.2, avg_assists: 7.8, multikill_stats: { penta_kills: 2 } } + # } + # + # 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]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + teamfight_data = { + player: PlayerSerializer.render_as_hash(player), + damage_performance: { + avg_damage_dealt: stats.average(:total_damage_dealt)&.round(0), + avg_damage_taken: stats.average(:total_damage_taken)&.round(0), + best_damage_game: stats.maximum(:total_damage_dealt), + avg_damage_per_min: calculate_avg_damage_per_min(stats) + }, + participation: { + avg_kills: stats.average(:kills)&.round(1), + avg_assists: stats.average(:assists)&.round(1), + avg_deaths: stats.average(:deaths)&.round(1), + multikill_stats: { + double_kills: stats.sum(:double_kills), + triple_kills: stats.sum(:triple_kills), + quadra_kills: stats.sum(:quadra_kills), + penta_kills: stats.sum(:penta_kills) + } + }, + by_match: stats.map do |stat| + { + match_id: stat.match.id, + date: stat.match.game_start, + kills: stat.kills, + deaths: stat.deaths, + assists: stat.assists, + damage_dealt: stat.total_damage_dealt, + damage_taken: stat.total_damage_taken, + multikills: stat.double_kills + stat.triple_kills + stat.quadra_kills + stat.penta_kills, + champion: stat.champion, + victory: stat.match.victory + } + end + } + + render_success(teamfight_data) + end + + private + + def calculate_avg_damage_per_min(stats) + total_damage = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration && stat.total_damage_dealt + total_damage += stat.total_damage_dealt + total_minutes += stat.match.game_duration / 60.0 + end + end + + return 0 if total_minutes.zero? + + (total_damage / total_minutes).round(0) + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/vision_controller.rb b/app/controllers/api/v1/analytics/vision_controller.rb index cbcbab4..5149233 100644 --- a/app/controllers/api/v1/analytics/vision_controller.rb +++ b/app/controllers/api/v1/analytics/vision_controller.rb @@ -1,81 +1,103 @@ -class Api::V1::Analytics::VisionController < Api::V1::BaseController - def show - player = organization_scoped(Player).find(params[:player_id]) - - stats = PlayerMatchStat.joins(:match) - .where(player: player, match: { organization: current_organization }) - .order('matches.game_start DESC') - .limit(20) - - vision_data = { - player: PlayerSerializer.render_as_hash(player), - vision_stats: { - avg_vision_score: stats.average(:vision_score)&.round(1), - avg_wards_placed: stats.average(:wards_placed)&.round(1), - avg_wards_killed: stats.average(:wards_killed)&.round(1), - best_vision_game: stats.maximum(:vision_score), - total_wards_placed: stats.sum(:wards_placed), - total_wards_killed: stats.sum(:wards_killed) - }, - vision_per_min: calculate_avg_vision_per_min(stats), - by_match: stats.map do |stat| - { - match_id: stat.match.id, - date: stat.match.game_start, - vision_score: stat.vision_score, - wards_placed: stat.wards_placed, - wards_killed: stat.wards_killed, - champion: stat.champion, - role: stat.role, - victory: stat.match.victory - } - end, - role_comparison: calculate_role_comparison(player) - } - - render_success(vision_data) - end - - private - - def calculate_avg_vision_per_min(stats) - total_vision = 0 - total_minutes = 0 - - stats.each do |stat| - if stat.match.game_duration && stat.vision_score - total_vision += stat.vision_score - total_minutes += stat.match.game_duration / 60.0 - end - end - - return 0 if total_minutes.zero? - (total_vision / total_minutes).round(2) - end - - def calculate_role_comparison(player) - # Compare player's vision score to team average for same role - team_stats = PlayerMatchStat.joins(:player) - .where(players: { organization: current_organization, role: player.role }) - .where.not(players: { id: player.id }) - - player_stats = PlayerMatchStat.where(player: player) - - { - player_avg: player_stats.average(:vision_score)&.round(1) || 0, - role_avg: team_stats.average(:vision_score)&.round(1) || 0, - percentile: calculate_percentile(player_stats.average(:vision_score), team_stats) - } - end - - def calculate_percentile(player_avg, team_stats) - return 0 if player_avg.nil? || team_stats.empty? - - all_averages = team_stats.group(:player_id).average(:vision_score).values - all_averages << player_avg - all_averages.sort! - - rank = all_averages.index(player_avg) + 1 - ((rank.to_f / all_averages.size) * 100).round(0) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Analytics + # Vision Analytics Controller + # + # Tracks ward placement, ward denial, and overall vision score metrics. + # Compares player vision performance against team role averages and calculates percentile rankings. + # + # @example GET /api/v1/analytics/vision/:player_id + # { + # vision_stats: { avg_vision_score: 45.2, avg_wards_placed: 18.5, avg_wards_killed: 8.2 }, + # role_comparison: { player_avg: 45.2, role_avg: 42.1, percentile: 68 } + # } + # + # Main endpoints: + # - GET show: Returns vision statistics for the last 20 matches with role-based comparisons + class VisionController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + vision_data = { + player: PlayerSerializer.render_as_hash(player), + vision_stats: { + avg_vision_score: stats.average(:vision_score)&.round(1), + avg_wards_placed: stats.average(:wards_placed)&.round(1), + avg_wards_killed: stats.average(:wards_killed)&.round(1), + best_vision_game: stats.maximum(:vision_score), + total_wards_placed: stats.sum(:wards_placed), + total_wards_killed: stats.sum(:wards_killed) + }, + vision_per_min: calculate_avg_vision_per_min(stats), + by_match: stats.map do |stat| + { + match_id: stat.match.id, + date: stat.match.game_start, + vision_score: stat.vision_score, + wards_placed: stat.wards_placed, + wards_killed: stat.wards_killed, + champion: stat.champion, + role: stat.role, + victory: stat.match.victory + } + end, + role_comparison: calculate_role_comparison(player) + } + + render_success(vision_data) + end + + private + + def calculate_avg_vision_per_min(stats) + total_vision = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration && stat.vision_score + total_vision += stat.vision_score + total_minutes += stat.match.game_duration / 60.0 + end + end + + return 0 if total_minutes.zero? + + (total_vision / total_minutes).round(2) + end + + def calculate_role_comparison(player) + # Compare player's vision score to team average for same role + team_stats = PlayerMatchStat.joins(:player) + .where(players: { organization: current_organization, role: player.role }) + .where.not(players: { id: player.id }) + + player_stats = PlayerMatchStat.where(player: player) + + { + player_avg: player_stats.average(:vision_score)&.round(1) || 0, + role_avg: team_stats.average(:vision_score)&.round(1) || 0, + percentile: calculate_percentile(player_stats.average(:vision_score), team_stats) + } + end + + def calculate_percentile(player_avg, team_stats) + return 0 if player_avg.nil? || team_stats.empty? + + all_averages = team_stats.group(:player_id).average(:vision_score).values + all_averages << player_avg + all_averages.sort! + + rank = all_averages.index(player_avg) + 1 + ((rank.to_f / all_averages.size) * 100).round(0) + end + end + end + end +end diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index ed873c3..9ed5f40 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -1,267 +1,11 @@ +# frozen_string_literal: true + +# Proxy controller that inherits from the modularized Authentication controller module Api module V1 - class AuthController < BaseController - skip_before_action :authenticate_request!, only: [:register, :login, :forgot_password, :reset_password, :refresh] - - # POST /api/v1/auth/register - def register - ActiveRecord::Base.transaction do - organization = create_organization! - user = create_user!(organization) - tokens = Authentication::Services::JwtService.generate_tokens(user) - - # Log audit action with user's organization - AuditLog.create!( - organization: organization, - user: user, - action: 'register', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - render_created( - { - user: JSON.parse(UserSerializer.render(user)), - organization: JSON.parse(OrganizationSerializer.render(organization)), - **tokens - }, - message: 'Registration successful' - ) - end - rescue ActiveRecord::RecordInvalid => e - render_validation_errors(e) - rescue => e - render_error(message: 'Registration failed', code: 'REGISTRATION_ERROR') - end - - # POST /api/v1/auth/login - def login - user = authenticate_user! - - if user - tokens = Authentication::Services::JwtService.generate_tokens(user) - user.update_last_login! - - # Log audit action with user's organization - AuditLog.create!( - organization: user.organization, - user: user, - action: 'login', - entity_type: 'User', - entity_id: user.id, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - - render_success( - { - user: JSON.parse(UserSerializer.render(user)), - organization: JSON.parse(OrganizationSerializer.render(user.organization)), - **tokens - }, - message: 'Login successful' - ) - else - render_error( - message: 'Invalid email or password', - code: 'INVALID_CREDENTIALS', - status: :unauthorized - ) - end - end - - # POST /api/v1/auth/refresh - def refresh - refresh_token = params[:refresh_token] - - if refresh_token.blank? - return render_error( - message: 'Refresh token is required', - code: 'MISSING_REFRESH_TOKEN', - status: :bad_request - ) - end - - begin - tokens = Authentication::Services::JwtService.refresh_access_token(refresh_token) - render_success(tokens, message: 'Token refreshed successfully') - rescue Authentication::Services::JwtService::AuthenticationError => e - render_error( - message: e.message, - code: 'INVALID_REFRESH_TOKEN', - status: :unauthorized - ) - end - end - - # POST /api/v1/auth/logout - def logout - # For JWT, we don't need to do anything server-side for logout - # The client should remove the token - - log_user_action( - action: 'logout', - entity_type: 'User', - entity_id: current_user.id - ) - - render_success({}, message: 'Logout successful') - end - - # POST /api/v1/auth/forgot-password - def forgot_password - email = params[:email]&.downcase&.strip - - if email.blank? - return render_error( - message: 'Email is required', - code: 'MISSING_EMAIL', - status: :bad_request - ) - end - - user = User.find_by(email: email) - - if user - # Generate password reset token - reset_token = generate_reset_token(user) - - # Here you would send an email with the reset token - # For now, we'll just return success - - 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 - - # Always return success to prevent email enumeration - render_success( - {}, - message: 'If the email exists, a password reset link has been sent' - ) - end - - # POST /api/v1/auth/reset-password - def reset_password - token = params[:token] - new_password = params[:password] - password_confirmation = params[:password_confirmation] - - if token.blank? || new_password.blank? - return render_error( - message: 'Token and password are required', - code: 'MISSING_PARAMETERS', - status: :bad_request - ) - end - - if new_password != password_confirmation - return render_error( - message: 'Password confirmation does not match', - code: 'PASSWORD_MISMATCH', - status: :bad_request - ) - end - - user = verify_reset_token(token) - - if user - user.update!(password: new_password) - - 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 - ) - - render_success({}, message: 'Password reset successful') - else - render_error( - message: 'Invalid or expired reset token', - code: 'INVALID_RESET_TOKEN', - status: :bad_request - ) - end - end - - # GET /api/v1/auth/me - def me - render_success( - { - user: JSON.parse(UserSerializer.render(current_user)), - organization: JSON.parse(OrganizationSerializer.render(current_organization)) - } - ) - end - - private - - def create_organization! - Organization.create!(organization_params) - end - - def create_user!(organization) - User.create!(user_params.merge( - organization: organization, - role: 'owner' # First user is always the owner - )) - end - - def authenticate_user! - email = params[:email]&.downcase&.strip - password = params[:password] - - return nil if email.blank? || password.blank? - - user = User.find_by(email: email) - user&.authenticate(password) ? user : nil - end - - def organization_params - params.require(:organization).permit(:name, :region, :tier) - end - - def user_params - params.require(:user).permit(:email, :password, :full_name, :timezone, :language) - end - - def generate_reset_token(user) - # In a real app, you'd store this token in the database or Redis - # For now, we'll use JWT with a short expiration - payload = { - user_id: user.id, - type: 'password_reset', - exp: 1.hour.from_now.to_i, - iat: Time.current.to_i - } - - JWT.encode(payload, Authentication::Services::JwtService::SECRET_KEY, 'HS256') - end - - def verify_reset_token(token) - begin - decoded = JWT.decode(token, Authentication::Services::JwtService::SECRET_KEY, true, { algorithm: 'HS256' }) - payload = HashWithIndifferentAccess.new(decoded[0]) - - return nil unless payload[:type] == 'password_reset' - - User.find(payload[:user_id]) - rescue JWT::DecodeError, JWT::ExpiredSignature, ActiveRecord::RecordNotFound - nil - end - end + class AuthController < ::Authentication::Controllers::AuthController + # All functionality is inherited from Authentication::Controllers::AuthController + # This controller exists only for backwards compatibility with existing routes end end end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index ed6e8c8..f477a86 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -1,125 +1,160 @@ -class Api::V1::BaseController < ApplicationController - include Authenticatable - include Pundit::Authorization - - # Skip authentication for specific actions if needed - # This will be overridden in individual controllers - - rescue_from ActiveRecord::RecordNotFound, with: :render_not_found - rescue_from ActiveRecord::RecordInvalid, with: :render_validation_errors - rescue_from ActionController::ParameterMissing, with: :render_parameter_missing - rescue_from Pundit::NotAuthorizedError, with: :render_forbidden_policy - - protected - - def render_success(data = {}, message: nil, status: :ok) - response = {} - response[:message] = message if message - response[:data] = data if data.present? - - render json: response, status: status - end - - def render_created(data = {}, message: 'Resource created successfully') - render_success(data, message: message, status: :created) - end - - def render_updated(data = {}, message: 'Resource updated successfully') - render_success(data, message: message, status: :ok) - end - - def render_deleted(message: 'Resource deleted successfully') - render json: { message: message }, status: :ok - end - - def render_error(message:, code: 'ERROR', status: :bad_request, details: nil) - error_response = { - error: { - code: code, - message: message - } - } - - error_response[:error][:details] = details if details - - render json: error_response, status: status - end - - def render_validation_errors(exception) - render_error( - message: 'Validation failed', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: exception.record.errors.as_json - ) - end - - def render_not_found(exception = nil) - resource_name = exception&.model&.humanize || 'Resource' - render_error( - message: "#{resource_name} not found", - code: 'NOT_FOUND', - status: :not_found - ) - end - - def render_parameter_missing(exception) - render_error( - message: "Missing required parameter: #{exception.param}", - code: 'PARAMETER_MISSING', - status: :bad_request - ) - end - - def paginate(collection, per_page: 20) - page = params[:page]&.to_i || 1 - per_page = [params[:per_page]&.to_i || per_page, 100].min # Max 100 per page - - paginated = collection.page(page).per(per_page) - - { - data: paginated, - pagination: { - current_page: paginated.current_page, - per_page: paginated.limit_value, - total_pages: paginated.total_pages, - total_count: paginated.total_count, - has_next_page: paginated.next_page.present?, - has_prev_page: paginated.prev_page.present? - } - } - end - - def log_user_action(action:, entity_type:, entity_id: nil, old_values: {}, new_values: {}) - AuditLog.create!( - organization: current_organization, - user: current_user, - action: action.to_s, - entity_type: entity_type.to_s, - entity_id: entity_id, - old_values: old_values, - new_values: new_values, - ip_address: request.remote_ip, - user_agent: request.user_agent - ) - end - - private - - def set_content_type - response.content_type = 'application/json' - end - - def render_forbidden_policy(exception) - policy_name = exception.policy.class.to_s.underscore - render_error( - message: "You are not authorized to perform this action", - code: 'FORBIDDEN', - status: :forbidden, - details: { - policy: policy_name, - action: exception.query - } - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +module Api + module V1 + # Base controller for API v1 endpoints + # + # Provides common functionality for all API v1 controllers including authentication, + # authorization via Pundit, standardized error handling, and response rendering methods. + # + # This controller includes: + # - Authentication through the Authenticatable concern + # - Authorization with Pundit + # - Standardized JSON response formats for success and error cases + # - Pagination support with metadata + # - Audit logging capabilities + # - Exception rescue handlers for common Rails errors + # + # @example Creating a new API controller + # class Api::V1::UsersController < Api::V1::BaseController + # def index + # users = User.all + # render_success(users: users) + # end + # end + # + # @example Using pagination + # class Api::V1::PostsController < Api::V1::BaseController + # def index + # posts = Post.all + # result = paginate(posts, per_page: 25) + # render_success(result) + # end + # end + class BaseController < ApplicationController + include Authenticatable + include Pundit::Authorization + + # Skip authentication for specific actions if needed + # This will be overridden in individual controllers + + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found + rescue_from ActiveRecord::RecordInvalid, with: :render_validation_errors + rescue_from ActionController::ParameterMissing, with: :render_parameter_missing + rescue_from Pundit::NotAuthorizedError, with: :render_forbidden_policy + + protected + + def render_success(data = {}, message: nil, status: :ok) + response = {} + response[:message] = message if message + response[:data] = data if data.present? + + render json: response, status: status + end + + def render_created(data = {}, message: 'Resource created successfully') + render_success(data, message: message, status: :created) + end + + def render_updated(data = {}, message: 'Resource updated successfully') + render_success(data, message: message, status: :ok) + end + + def render_deleted(message: 'Resource deleted successfully') + render json: { message: message }, status: :ok + end + + def render_error(message:, code: 'ERROR', status: :bad_request, details: nil) + error_response = { + error: { + code: code, + message: message + } + } + + error_response[:error][:details] = details if details + + render json: error_response, status: status + end + + def render_validation_errors(exception) + render_error( + message: 'Validation failed', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: exception.record.errors.as_json + ) + end + + def render_not_found(exception = nil) + resource_name = exception&.model&.humanize || 'Resource' + render_error( + message: "#{resource_name} not found", + code: 'NOT_FOUND', + status: :not_found + ) + end + + def render_parameter_missing(exception) + render_error( + message: "Missing required parameter: #{exception.param}", + code: 'PARAMETER_MISSING', + status: :bad_request + ) + end + + def paginate(collection, per_page: 20) + page = params[:page]&.to_i || 1 + per_page = [params[:per_page]&.to_i || per_page, 100].min # Max 100 per page + + paginated = collection.page(page).per(per_page) + + { + data: paginated, + pagination: { + current_page: paginated.current_page, + per_page: paginated.limit_value, + total_pages: paginated.total_pages, + total_count: paginated.total_count, + has_next_page: paginated.next_page.present?, + has_prev_page: paginated.prev_page.present? + } + } + end + + def log_user_action(action:, entity_type:, entity_id: nil, old_values: {}, new_values: {}) + AuditLog.create!( + organization: current_organization, + user: current_user, + action: action.to_s, + entity_type: entity_type.to_s, + entity_id: entity_id, + old_values: old_values, + new_values: new_values, + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + end + + private + + def set_content_type + response.content_type = 'application/json' + end + + def render_forbidden_policy(exception) + policy_name = exception.policy.class.to_s.underscore + render_error( + message: 'You are not authorized to perform this action', + code: 'FORBIDDEN', + status: :forbidden, + details: { + policy: policy_name, + action: exception.query + } + ) + end + end + end +end diff --git a/app/controllers/api/v1/constants_controller.rb b/app/controllers/api/v1/constants_controller.rb new file mode 100644 index 0000000..382054f --- /dev/null +++ b/app/controllers/api/v1/constants_controller.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +module Api + module V1 + # Constants Controller + # + # Provides application-wide constant values and enumerations for frontend consumption. + # Returns all valid options for dropdowns, filters, and validation including regions, tiers, + # roles, statuses, and other enumerated values used throughout the application. + # + # @example GET /api/v1/constants + # { + # regions: { values: ['BR', 'NA', 'EUW'], names: { 'BR': 'Brazil' } }, + # player: { roles: {...}, statuses: {...}, queue_ranks: [...] } + # } + # + # Main endpoints: + # - GET index: Returns comprehensive constants for all application entities (public, no auth required) + class ConstantsController < ApplicationController + # GET /api/v1/constants + # Public endpoint - no authentication required + def index + render json: { + data: { + regions: regions_data, + organization: organization_data, + user: user_data, + player: player_data, + match: match_data, + scrim: scrim_data, + competitive_match: competitive_match_data, + opponent_team: opponent_team_data, + schedule: schedule_data, + team_goal: team_goal_data, + vod_review: vod_review_data, + vod_timestamp: vod_timestamp_data, + scouting_target: scouting_target_data, + champion_pool: champion_pool_data + } + } + end + + private + + def regions_data + { + values: Constants::REGIONS, + names: Constants::REGION_NAMES + } + end + + def organization_data + { + tiers: { + values: Constants::Organization::TIERS, + names: Constants::Organization::TIER_NAMES + }, + subscription_plans: { + values: Constants::Organization::SUBSCRIPTION_PLANS, + names: Constants::Organization::SUBSCRIPTION_PLAN_NAMES + }, + subscription_statuses: Constants::Organization::SUBSCRIPTION_STATUSES + } + end + + def user_data + { + roles: { + values: Constants::User::ROLES, + names: Constants::User::ROLE_NAMES + } + } + end + + def player_data + { + roles: { + values: Constants::Player::ROLES, + names: Constants::Player::ROLE_NAMES + }, + statuses: { + values: Constants::Player::STATUSES, + names: Constants::Player::STATUS_NAMES + }, + queue_ranks: Constants::Player::QUEUE_RANKS, + queue_tiers: Constants::Player::QUEUE_TIERS + } + end + + def match_data + { + types: { + values: Constants::Match::TYPES, + names: Constants::Match::TYPE_NAMES + }, + sides: { + values: Constants::Match::SIDES, + names: Constants::Match::SIDE_NAMES + } + } + end + + def scrim_data + { + types: { + values: Constants::Scrim::TYPES, + names: Constants::Scrim::TYPE_NAMES + }, + focus_areas: { + values: Constants::Scrim::FOCUS_AREAS, + names: Constants::Scrim::FOCUS_AREA_NAMES + }, + visibility_levels: { + values: Constants::Scrim::VISIBILITY_LEVELS, + names: Constants::Scrim::VISIBILITY_NAMES + } + } + end + + def competitive_match_data + { + formats: { + values: Constants::CompetitiveMatch::FORMATS, + names: Constants::CompetitiveMatch::FORMAT_NAMES + }, + sides: { + values: Constants::CompetitiveMatch::SIDES, + names: Constants::Match::SIDE_NAMES + } + } + end + + def opponent_team_data + { + tiers: { + values: Constants::OpponentTeam::TIERS, + names: Constants::OpponentTeam::TIER_NAMES + } + } + end + + def schedule_data + { + event_types: { + values: Constants::Schedule::EVENT_TYPES, + names: Constants::Schedule::EVENT_TYPE_NAMES + }, + statuses: { + values: Constants::Schedule::STATUSES, + names: Constants::Schedule::STATUS_NAMES + } + } + end + + def team_goal_data + { + categories: { + values: Constants::TeamGoal::CATEGORIES, + names: Constants::TeamGoal::CATEGORY_NAMES + }, + metric_types: { + values: Constants::TeamGoal::METRIC_TYPES, + names: Constants::TeamGoal::METRIC_TYPE_NAMES + }, + statuses: { + values: Constants::TeamGoal::STATUSES, + names: Constants::TeamGoal::STATUS_NAMES + } + } + end + + def vod_review_data + { + types: { + values: Constants::VodReview::TYPES, + names: Constants::VodReview::TYPE_NAMES + }, + statuses: { + values: Constants::VodReview::STATUSES, + names: Constants::VodReview::STATUS_NAMES + } + } + end + + def vod_timestamp_data + { + categories: { + values: Constants::VodTimestamp::CATEGORIES, + names: Constants::VodTimestamp::CATEGORY_NAMES + }, + importance_levels: { + values: Constants::VodTimestamp::IMPORTANCE_LEVELS, + names: Constants::VodTimestamp::IMPORTANCE_NAMES + }, + target_types: { + values: Constants::VodTimestamp::TARGET_TYPES, + names: Constants::VodTimestamp::TARGET_TYPE_NAMES + } + } + end + + def scouting_target_data + { + statuses: { + values: Constants::ScoutingTarget::STATUSES, + names: Constants::ScoutingTarget::STATUS_NAMES + }, + priorities: { + values: Constants::ScoutingTarget::PRIORITIES, + names: Constants::ScoutingTarget::PRIORITY_NAMES + } + } + end + + def champion_pool_data + { + mastery_levels: { + values: Constants::ChampionPool::MASTERY_LEVELS.to_a, + names: Constants::ChampionPool::MASTERY_LEVEL_NAMES + }, + priority_levels: Constants::ChampionPool::PRIORITY_LEVELS.to_a + } + end + end + end +end diff --git a/app/controllers/api/v1/dashboard_controller.rb b/app/controllers/api/v1/dashboard_controller.rb index bf2d52c..a9374d4 100644 --- a/app/controllers/api/v1/dashboard_controller.rb +++ b/app/controllers/api/v1/dashboard_controller.rb @@ -1,152 +1,9 @@ -class Api::V1::DashboardController < Api::V1::BaseController - def index - dashboard_data = { - stats: calculate_stats, - recent_matches: recent_matches_data, - upcoming_events: upcoming_events_data, - active_goals: active_goals_data, - roster_status: roster_status_data - } - - render_success(dashboard_data) - end - - def stats - cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}" - cached_stats = Rails.cache.fetch(cache_key, expires_in: 5.minutes) { calculate_stats } - render_success(cached_stats) - end - - def activities - recent_activities = fetch_recent_activities - - render_success({ - activities: recent_activities, - count: recent_activities.size - }) - end - - def schedule - events = organization_scoped(Schedule) - .where('start_time >= ?', Time.current) - .order(start_time: :asc) - .limit(10) - - render_success({ - events: ScheduleSerializer.render_as_hash(events), - count: events.size - }) - end - - private - - def calculate_stats - matches = organization_scoped(Match).recent(30) - players = organization_scoped(Player).active - - { - total_players: players.count, - active_players: players.where(status: 'active').count, - total_matches: matches.count, - wins: matches.victories.count, - losses: matches.defeats.count, - win_rate: calculate_win_rate(matches), - recent_form: calculate_recent_form(matches.order(game_start: :desc).limit(5)), - avg_kda: calculate_average_kda(matches), - active_goals: organization_scoped(TeamGoal).active.count, - completed_goals: organization_scoped(TeamGoal).where(status: 'completed').count, - upcoming_matches: organization_scoped(Schedule).where('start_time >= ? AND event_type = ?', Time.current, 'match').count - } - end - - def calculate_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_recent_form(matches) - matches.map { |m| m.victory? ? 'W' : 'L' }.join('') - end - - def calculate_average_kda(matches) - stats = PlayerMatchStat.where(match: matches) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end - - def recent_matches_data - matches = organization_scoped(Match) - .order(game_start: :desc) - .limit(5) - - MatchSerializer.render_as_hash(matches) - end - - def upcoming_events_data - events = organization_scoped(Schedule) - .where('start_time >= ?', Time.current) - .order(start_time: :asc) - .limit(5) - - ScheduleSerializer.render_as_hash(events) - end - - def active_goals_data - goals = organization_scoped(TeamGoal) - .active - .order(end_date: :asc) - .limit(5) - - TeamGoalSerializer.render_as_hash(goals) - end - - def roster_status_data - players = organization_scoped(Player).includes(:champion_pools) - - # Order by role to ensure consistent order in by_role hash - by_role_ordered = players.ordered_by_role.group(:role).count - - { - by_role: by_role_ordered, - by_status: players.group(:status).count, - contracts_expiring: players.contracts_expiring_soon.count - } - end - - def fetch_recent_activities - # Fetch recent audit logs and format them - activities = AuditLog - .where(organization: current_organization) - .order(created_at: :desc) - .limit(20) - - activities.map do |log| - { - id: log.id, - action: log.action, - entity_type: log.entity_type, - entity_id: log.entity_id, - user: log.user&.email, - timestamp: log.created_at, - changes: summarize_changes(log) - } - end - end - - def summarize_changes(log) - return nil unless log.new_values.present? - - # Only show important field changes - important_fields = %w[status role summoner_name title victory] - changes = log.new_values.slice(*important_fields) - - return nil if changes.empty? - changes - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class DashboardController < ::Dashboard::Controllers::DashboardController + end + end +end diff --git a/app/controllers/api/v1/dashboard_controller_optimized.rb b/app/controllers/api/v1/dashboard_controller_optimized.rb index d91cd0f..079b739 100644 --- a/app/controllers/api/v1/dashboard_controller_optimized.rb +++ b/app/controllers/api/v1/dashboard_controller_optimized.rb @@ -1,80 +1,87 @@ -# OPTIMIZED VERSION - in validation, dont send to production -class Api::V1::DashboardController < Api::V1::BaseController - def stats - cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}" - - Rails.cache.fetch(cache_key, expires_in: 5.minutes) do - calculate_stats - end - end - - private - - def calculate_stats - matches = organization_scoped(Match) - .recent(30) - .includes(:player_match_stats) - - matches_array = matches.to_a - players = organization_scoped(Player).active - - match_stats = matches_array.group_by(&:victory?) - wins = match_stats[true]&.size || 0 - losses = match_stats[false]&.size || 0 - - kda_result = PlayerMatchStat - .where(match_id: matches_array.map(&:id)) - .select('SUM(kills) as total_kills, SUM(deaths) as total_deaths, SUM(assists) as total_assists') - .first - - goal_counts = organization_scoped(TeamGoal).group(:status).count - - { - total_players: players.count, - active_players: players.where(status: 'active').count, - total_matches: matches_array.size, - wins: wins, - losses: losses, - win_rate: calculate_win_rate_fast(wins, matches_array.size), - recent_form: calculate_recent_form(matches_array.first(5)), - avg_kda: calculate_average_kda_fast(kda_result), - active_goals: goal_counts['active'] || 0, - completed_goals: goal_counts['completed'] || 0, - upcoming_matches: organization_scoped(Schedule) - .where('start_time >= ? AND event_type = ?', Time.current, 'match') - .count - } - end +# frozen_string_literal: true - def calculate_win_rate_fast(wins, total) - return 0 if total.zero? - ((wins.to_f / total) * 100).round(1) - end +# OPTIMIZED VERSION - in validation, dont send to production +module Api + module V1 + class DashboardController < Api::V1::BaseController + def stats + cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}" + + Rails.cache.fetch(cache_key, expires_in: 5.minutes) do + calculate_stats + end + end + + private + + def calculate_stats + matches = organization_scoped(Match) + .recent(30) + .includes(:player_match_stats) + + matches_array = matches.to_a + players = organization_scoped(Player).active + + match_stats = matches_array.group_by(&:victory?) + wins = match_stats[true]&.size || 0 + losses = match_stats[false]&.size || 0 + + kda_result = PlayerMatchStat + .where(match_id: matches_array.map(&:id)) + .select('SUM(kills) as total_kills, SUM(deaths) as total_deaths, SUM(assists) as total_assists') + .first + + goal_counts = organization_scoped(TeamGoal).group(:status).count + + { + total_players: players.count, + active_players: players.where(status: 'active').count, + total_matches: matches_array.size, + wins: wins, + losses: losses, + win_rate: calculate_win_rate_fast(wins, matches_array.size), + recent_form: calculate_recent_form(matches_array.first(5)), + avg_kda: calculate_average_kda_fast(kda_result), + active_goals: goal_counts['active'] || 0, + completed_goals: goal_counts['completed'] || 0, + upcoming_matches: organization_scoped(Schedule) + .where('start_time >= ? AND event_type = ?', Time.current, 'match') + .count + } + end + + def calculate_win_rate_fast(wins, total) + return 0 if total.zero? + + ((wins.to_f / total) * 100).round(1) + end def calculate_recent_form(matches) - matches.map { |m| m.victory? ? 'W' : 'L' }.join('') - end + matches.map { |m| m.victory? ? 'W' : 'L' }.join + end - def calculate_average_kda_fast(kda_result) - return 0 unless kda_result + def calculate_average_kda_fast(kda_result) + return 0 unless kda_result - total_kills = kda_result.total_kills || 0 - total_deaths = kda_result.total_deaths || 0 - total_assists = kda_result.total_assists || 0 + total_kills = kda_result.total_kills || 0 + total_deaths = kda_result.total_deaths || 0 + total_assists = kda_result.total_assists || 0 - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end - def roster_status_data - players = organization_scoped(Player).includes(:champion_pools) + def roster_status_data + players = organization_scoped(Player).includes(:champion_pools) - players_array = players.to_a + players_array = players.to_a - { - by_role: players_array.group_by(&:role).transform_values(&:count), - by_status: players_array.group_by(&:status).transform_values(&:count), - contracts_expiring: players.contracts_expiring_soon.count - } + { + by_role: players_array.group_by(&:role).transform_values(&:count), + by_status: players_array.group_by(&:status).transform_values(&:count), + contracts_expiring: players.contracts_expiring_soon.count + } + end + end end end diff --git a/app/controllers/api/v1/matches_controller.rb b/app/controllers/api/v1/matches_controller.rb index daf0f7d..9c22181 100644 --- a/app/controllers/api/v1/matches_controller.rb +++ b/app/controllers/api/v1/matches_controller.rb @@ -1,267 +1,9 @@ -class Api::V1::MatchesController < Api::V1::BaseController - before_action :set_match, only: [:show, :update, :destroy, :stats] - - def index - matches = organization_scoped(Match).includes(:player_match_stats, :players) - - # Apply filters - 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' - - # Date range filter - if params[:start_date].present? && params[:end_date].present? - matches = matches.in_date_range(params[:start_date], params[:end_date]) - elsif params[:days].present? - matches = matches.recent(params[:days].to_i) - end - - # Opponent filter - matches = matches.with_opponent(params[:opponent]) if params[:opponent].present? - - # Tournament filter - if params[:tournament].present? - matches = matches.where('tournament_name ILIKE ?', "%#{params[:tournament]}%") - end - - # Sorting - sort_by = params[:sort_by] || 'game_start' - sort_order = params[:sort_order] || 'desc' - matches = matches.order("#{sort_by} #{sort_order}") - - # Pagination - result = paginate(matches) - - render_success({ - matches: MatchSerializer.render_as_hash(result[:data]), - pagination: result[:pagination], - summary: calculate_matches_summary(matches) - }) - end - - def show - match_data = MatchSerializer.render_as_hash(@match) - player_stats = PlayerMatchStatSerializer.render_as_hash( - @match.player_match_stats.includes(:player) - ) - - 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 - }) - end - - def create - match = organization_scoped(Match).new(match_params) - match.organization = current_organization - - if match.save - log_user_action( - action: 'create', - entity_type: 'Match', - entity_id: match.id, - new_values: match.attributes - ) - - render_created({ - match: MatchSerializer.render_as_hash(match) - }, message: 'Match created successfully') - else - render_error( - message: 'Failed to create match', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: match.errors.as_json - ) - end - end - - def update - old_values = @match.attributes.dup - - if @match.update(match_params) - log_user_action( - action: 'update', - entity_type: 'Match', - entity_id: @match.id, - old_values: old_values, - new_values: @match.attributes - ) - - render_updated({ - match: MatchSerializer.render_as_hash(@match) - }) - else - render_error( - message: 'Failed to update match', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @match.errors.as_json - ) - end - end - - def destroy - if @match.destroy - log_user_action( - action: 'delete', - entity_type: 'Match', - entity_id: @match.id, - old_values: @match.attributes - ) - - render_deleted(message: 'Match deleted successfully') - else - render_error( - message: 'Failed to delete match', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - def stats - # Detailed statistics for a single match - stats = @match.player_match_stats.includes(:player) - - stats_data = { - match: MatchSerializer.render_as_hash(@match), - team_stats: calculate_team_stats(stats), - player_stats: stats.map do |stat| - player_data = PlayerMatchStatSerializer.render_as_hash(stat) - player_data[:player] = PlayerSerializer.render_as_hash(stat.player) - player_data - end, - comparison: { - total_gold: stats.sum(:gold_earned), - total_damage: stats.sum(:total_damage_dealt), - total_vision_score: stats.sum(:vision_score), - avg_kda: calculate_avg_kda(stats) - } - } - - render_success(stats_data) - end - - def import - player_id = params[:player_id] - count = params[:count]&.to_i || 20 - - unless player_id.present? - return render_error( - message: 'player_id is required', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - end - - player = organization_scoped(Player).find(player_id) - - unless player.riot_puuid.present? - return render_error( - message: 'Player does not have a Riot PUUID. Please sync player from Riot first.', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - end - - begin - riot_service = RiotApiService.new - region = player.region || 'BR' - - match_ids = riot_service.get_match_history( - puuid: player.riot_puuid, - region: region, - count: count - ) - - imported_count = 0 - match_ids.each do |match_id| - # Check if match already exists - next if Match.exists?(riot_match_id: match_id) - - SyncMatchJob.perform_later(match_id, current_organization.id, region) - imported_count += 1 - end - - render_success({ - message: "Queued #{imported_count} matches for import", - total_matches_found: match_ids.count, - already_imported: match_ids.count - imported_count, - player: PlayerSerializer.render_as_hash(player) - }) - - rescue RiotApiService::RiotApiError => e - render_error( - message: "Failed to fetch matches from Riot API: #{e.message}", - code: 'RIOT_API_ERROR', - status: :bad_gateway - ) - rescue StandardError => e - render_error( - message: "Failed to import matches: #{e.message}", - code: 'IMPORT_ERROR', - status: :internal_server_error - ) - end - end - - private - - def set_match - @match = organization_scoped(Match).find(params[:id]) - end - - def match_params - params.require(:match).permit( - :match_type, :game_start, :game_end, :game_duration, - :riot_match_id, :patch_version, :tournament_name, :stage, - :opponent_name, :opponent_tag, :victory, - :our_side, :our_score, :opponent_score, - :first_blood, :first_tower, :first_baron, :first_dragon, - :total_kills, :total_deaths, :total_assists, :total_gold, - :vod_url, :replay_file_url, :notes - ) - end - - def calculate_matches_summary(matches) - { - total: matches.count, - victories: matches.victories.count, - defeats: matches.defeats.count, - win_rate: calculate_win_rate(matches), - by_type: matches.group(:match_type).count, - avg_duration: matches.average(:game_duration)&.round(0) - } - end - - def calculate_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_team_stats(stats) - { - total_kills: stats.sum(:kills), - total_deaths: stats.sum(:deaths), - total_assists: stats.sum(:assists), - total_gold: stats.sum(:gold_earned), - total_damage: stats.sum(:total_damage_dealt), - total_cs: stats.sum(:minions_killed), - total_vision_score: stats.sum(:vision_score) - } - end - - def calculate_avg_kda(stats) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class MatchesController < ::Matches::Controllers::MatchesController + end + end +end diff --git a/app/controllers/api/v1/players_controller.rb b/app/controllers/api/v1/players_controller.rb index 0fa1e51..b76e9de 100644 --- a/app/controllers/api/v1/players_controller.rb +++ b/app/controllers/api/v1/players_controller.rb @@ -1,541 +1,12 @@ -class Api::V1::PlayersController < Api::V1::BaseController - before_action :set_player, only: [:show, :update, :destroy, :stats, :matches, :sync_from_riot] +# frozen_string_literal: true - def index - players = organization_scoped(Player).includes(:champion_pools) - - # Apply filters - players = players.by_role(params[:role]) if params[:role].present? - players = players.by_status(params[:status]) if params[:status].present? - - # Apply search - if params[:search].present? - search_term = "%#{params[:search]}%" - players = players.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) - end - - # Pagination - order by role (top, jungle, mid, adc, support) then by name - result = paginate(players.ordered_by_role.order(:summoner_name)) - - render_success({ - players: PlayerSerializer.render_as_hash(result[:data]), - pagination: result[:pagination] - }) - end - - def show - render_success({ - player: PlayerSerializer.render_as_hash(@player) - }) - end - - def create - player = organization_scoped(Player).new(player_params) - player.organization = current_organization - - if player.save - log_user_action( - action: 'create', - entity_type: 'Player', - entity_id: player.id, - new_values: player.attributes - ) - - render_created({ - player: PlayerSerializer.render_as_hash(player) - }, message: 'Player created successfully') - else - render_error( - message: 'Failed to create player', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: player.errors.as_json - ) - end - end - - def update - old_values = @player.attributes.dup - - if @player.update(player_params) - log_user_action( - action: 'update', - entity_type: 'Player', - entity_id: @player.id, - old_values: old_values, - new_values: @player.attributes - ) - - render_updated({ - player: PlayerSerializer.render_as_hash(@player) - }) - else - render_error( - message: 'Failed to update player', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @player.errors.as_json - ) - end - end - - def destroy - if @player.destroy - log_user_action( - action: 'delete', - entity_type: 'Player', - entity_id: @player.id, - old_values: @player.attributes - ) - - render_deleted(message: 'Player deleted successfully') - else - render_error( - message: 'Failed to delete player', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - def stats - # Get player statistics - matches = @player.matches.order(game_start: :desc) - recent_matches = matches.limit(20) - player_stats = PlayerMatchStat.where(player: @player, match: matches) - - stats_data = { - player: PlayerSerializer.render_as_hash(@player), - overall: { - total_matches: matches.count, - wins: matches.victories.count, - losses: matches.defeats.count, - win_rate: calculate_player_win_rate(matches), - avg_kda: calculate_player_avg_kda(player_stats), - avg_cs: player_stats.average(:minions_killed)&.round(1) || 0, - avg_vision_score: player_stats.average(:vision_score)&.round(1) || 0, - avg_damage: player_stats.average(:total_damage_dealt)&.round(0) || 0 - }, - recent_form: { - last_5_matches: calculate_recent_form(recent_matches.limit(5)), - last_10_matches: calculate_recent_form(recent_matches.limit(10)) - }, - champion_pool: ChampionPoolSerializer.render_as_hash( - @player.champion_pools.order(games_played: :desc).limit(5) - ), - performance_by_role: calculate_performance_by_role(player_stats) - } - - render_success(stats_data) - end - - def matches - matches = @player.matches - .includes(:player_match_stats) - .order(game_start: :desc) - - # Filter by date range if provided - if params[:start_date].present? && params[:end_date].present? - matches = matches.in_date_range(params[:start_date], params[:end_date]) - end - - result = paginate(matches) - - # Include player stats for each match - matches_with_stats = result[:data].map do |match| - player_stat = match.player_match_stats.find_by(player: @player) - { - match: MatchSerializer.render_as_hash(match), - player_stats: player_stat ? PlayerMatchStatSerializer.render_as_hash(player_stat) : nil - } - end - - render_success({ - matches: matches_with_stats, - pagination: result[:pagination] - }) - end - - def import - summoner_name = params[:summoner_name] - role = params[:role] - region = params[:region] || 'br1' - - # Validate required params - unless summoner_name.present? && role.present? - return render_error( - message: 'Summoner name and role are required', - code: 'MISSING_PARAMETERS', - status: :unprocessable_entity - ) - end - - # Validate role - unless %w[top jungle mid adc support].include?(role) - return render_error( - message: 'Invalid role', - code: 'INVALID_ROLE', - status: :unprocessable_entity - ) - end - - # Check if player already exists - existing_player = organization_scoped(Player).find_by(summoner_name: summoner_name) - if existing_player - return render_error( - message: 'Player already exists in your organization', - code: 'PLAYER_EXISTS', - status: :unprocessable_entity - ) - end - - # Get Riot API key - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - begin - # Fetch summoner data from Riot API - summoner_data = fetch_summoner_by_name(summoner_name, region, riot_api_key) - ranked_data = fetch_ranked_stats(summoner_data['puuid'], region, riot_api_key) - - # Prepare player data - player_data = { - summoner_name: summoner_name, - role: role, - region: region, - status: 'active', - riot_puuid: summoner_data['puuid'], - riot_summoner_id: summoner_data['id'], - summoner_level: summoner_data['summonerLevel'], - profile_icon_id: summoner_data['profileIconId'], - sync_status: 'success', - last_sync_at: Time.current - } - - # Add ranked stats if available - solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } - if solo_queue - player_data.merge!({ - solo_queue_tier: solo_queue['tier'], - solo_queue_rank: solo_queue['rank'], - solo_queue_lp: solo_queue['leaguePoints'], - solo_queue_wins: solo_queue['wins'], - solo_queue_losses: solo_queue['losses'] - }) - end - - flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } - if flex_queue - player_data.merge!({ - flex_queue_tier: flex_queue['tier'], - flex_queue_rank: flex_queue['rank'], - flex_queue_lp: flex_queue['leaguePoints'] - }) - end - - # Create player - player = organization_scoped(Player).create!(player_data) - - log_user_action( - action: 'import_riot', - entity_type: 'Player', - entity_id: player.id, - new_values: player_data - ) - - render_created({ - player: PlayerSerializer.render_as_hash(player), - message: "Player #{summoner_name} imported successfully from Riot API" - }) - - rescue ActiveRecord::RecordInvalid => e - render_error( - message: "Failed to create player: #{e.message}", - code: 'VALIDATION_ERROR', - status: :unprocessable_entity - ) - rescue StandardError => e - Rails.logger.error "Riot API import error: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - - render_error( - message: "Failed to import from Riot API: #{e.message}", - code: 'RIOT_API_ERROR', - status: :service_unavailable - ) - end - end - - def sync_from_riot - # Check if player has riot_puuid or summoner_name - unless @player.riot_puuid.present? || @player.summoner_name.present? - return render_error( - message: 'Player must have either Riot PUUID or summoner name to sync', - code: 'MISSING_RIOT_INFO', - status: :unprocessable_entity - ) - end - - # Get Riot API key from environment - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - begin - # If we have PUUID, get summoner info by PUUID - # If not, get summoner by name first to get PUUID - region = params[:region] || 'br1' - - if @player.riot_puuid.present? - summoner_data = fetch_summoner_by_puuid(@player.riot_puuid, region, riot_api_key) - else - summoner_data = fetch_summoner_by_name(@player.summoner_name, region, riot_api_key) - end - - # Get ranked stats using PUUID - ranked_data = fetch_ranked_stats(summoner_data['puuid'], region, riot_api_key) - - # Update player with fresh data - update_data = { - riot_puuid: summoner_data['puuid'], - riot_summoner_id: summoner_data['id'] - } - - # Update ranked stats if available - solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } - if solo_queue - update_data.merge!({ - solo_queue_tier: solo_queue['tier'], - solo_queue_rank: solo_queue['rank'], - solo_queue_lp: solo_queue['leaguePoints'], - solo_queue_wins: solo_queue['wins'], - solo_queue_losses: solo_queue['losses'] - }) - end - - flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } - if flex_queue - update_data.merge!({ - flex_queue_tier: flex_queue['tier'], - flex_queue_rank: flex_queue['rank'], - flex_queue_lp: flex_queue['leaguePoints'] - }) - end - - update_data[:sync_status] = 'success' - update_data[:last_sync_at] = Time.current - - @player.update!(update_data) - - log_user_action( - action: 'sync_riot', - entity_type: 'Player', - entity_id: @player.id, - new_values: update_data - ) - - render_success({ - player: PlayerSerializer.render_as_hash(@player), - message: 'Player synced successfully from Riot API' - }) - - rescue StandardError => e - Rails.logger.error "Riot API sync error: #{e.message}" - - # Update sync status to error - @player.update(sync_status: 'error', last_sync_at: Time.current) - - render_error( - message: "Failed to sync with Riot API: #{e.message}", - code: 'RIOT_API_ERROR', - status: :service_unavailable - ) - end - end - - def bulk_sync - status = params[:status] || 'active' - - # Get players to sync - players = organization_scoped(Player).where(status: status) - - if players.empty? - return render_error( - message: "No #{status} players found to sync", - code: 'NO_PLAYERS_FOUND', - status: :not_found - ) - end - - # Check if Riot API is configured - riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - return render_error( - message: 'Riot API key not configured', - code: 'RIOT_API_NOT_CONFIGURED', - status: :service_unavailable - ) - end - - # Queue all players for sync (mark as syncing) - players.update_all(sync_status: 'syncing') - - # Perform sync in background - players.each do |player| - SyncPlayerFromRiotJob.perform_later(player.id) - end - - render_success({ - message: "#{players.count} players queued for sync", - players_count: players.count - }) - end - - private - - def set_player - @player = organization_scoped(Player).find(params[:id]) - end - - def player_params - # :role refers to in-game position (top/jungle/mid/adc/support), not user role - # nosemgrep - params.require(:player).permit( - :summoner_name, :real_name, :role, :region, :status, :jersey_number, - :birth_date, :country, :nationality, - :contract_start_date, :contract_end_date, - :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, - :solo_queue_wins, :solo_queue_losses, - :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, - :peak_tier, :peak_rank, :peak_season, - :riot_puuid, :riot_summoner_id, - :twitter_handle, :twitch_channel, :instagram_handle, - :notes - ) - end - - def calculate_player_win_rate(matches) - return 0 if matches.empty? - ((matches.victories.count.to_f / matches.count) * 100).round(1) - end - - def calculate_player_avg_kda(stats) - return 0 if stats.empty? - - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - ((total_kills + total_assists).to_f / deaths).round(2) - end - - def calculate_recent_form(matches) - matches.map { |m| m.victory? ? 'W' : 'L' } - end - - def calculate_performance_by_role(stats) - stats.group(:role).select( - 'role', - 'COUNT(*) as games', - 'AVG(kills) as avg_kills', - 'AVG(deaths) as avg_deaths', - 'AVG(assists) as avg_assists', - 'AVG(performance_score) as avg_performance' - ).map do |stat| - { - role: stat.role, - games: stat.games, - avg_kda: { - kills: stat.avg_kills&.round(1) || 0, - deaths: stat.avg_deaths&.round(1) || 0, - assists: stat.avg_assists&.round(1) || 0 - }, - avg_performance: stat.avg_performance&.round(1) || 0 - } - end - end - - def fetch_summoner_by_name(summoner_name, region, api_key) - require 'net/http' - require 'json' - - # Riot API v4 - Get summoner by name - # Note: Name must be URL encoded (game name + tagline) - # Example: "Player#BR1" should be split into gameName and tagLine - game_name, tag_line = summoner_name.split('#') - tag_line ||= region.upcase # Default tagline to region if not provided - - # First, get PUUID from Riot ID - account_url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{URI.encode_www_form_component(game_name)}/#{URI.encode_www_form_component(tag_line)}" - account_uri = URI(account_url) - account_request = Net::HTTP::Get.new(account_uri) - account_request['X-Riot-Token'] = api_key - - account_response = Net::HTTP.start(account_uri.hostname, account_uri.port, use_ssl: true) do |http| - http.request(account_request) - end - - unless account_response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{account_response.code} - #{account_response.body}" +# Proxy controller that inherits from the modularized Players controller +# This allows seamless migration to modular architecture without breaking existing routes +module Api + module V1 + class PlayersController < ::Players::Controllers::PlayersController + # All functionality is inherited from Players::Controllers::PlayersController + # This controller exists only for backwards compatibility with existing routes end - - account_data = JSON.parse(account_response.body) - puuid = account_data['puuid'] - - # Now get summoner by PUUID - fetch_summoner_by_puuid(puuid, region, api_key) - end - - def fetch_summoner_by_puuid(puuid, region, api_key) - require 'net/http' - require 'json' - - url = "https://#{region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" - uri = URI(url) - request = Net::HTTP::Get.new(uri) - request['X-Riot-Token'] = api_key - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - unless response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{response.code} - #{response.body}" - end - - JSON.parse(response.body) - end - - def fetch_ranked_stats(puuid, region, api_key) - require 'net/http' - require 'json' - - # Riot API v4 now uses PUUID instead of summoner ID - url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}" - uri = URI(url) - request = Net::HTTP::Get.new(uri) - request['X-Riot-Token'] = api_key - - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end - - unless response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{response.code} - #{response.body}" - end - - JSON.parse(response.body) end end diff --git a/app/controllers/api/v1/riot_data_controller.rb b/app/controllers/api/v1/riot_data_controller.rb new file mode 100644 index 0000000..2e826a3 --- /dev/null +++ b/app/controllers/api/v1/riot_data_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class RiotDataController < ::RiotIntegration::Controllers::RiotDataController + end + end +end diff --git a/app/controllers/api/v1/riot_integration_controller.rb b/app/controllers/api/v1/riot_integration_controller.rb index 7695239..6362b93 100644 --- a/app/controllers/api/v1/riot_integration_controller.rb +++ b/app/controllers/api/v1/riot_integration_controller.rb @@ -1,45 +1,9 @@ -class Api::V1::RiotIntegrationController < Api::V1::BaseController - def sync_status - players = organization_scoped(Player) - - # Calculate statistics - total_players = players.count - synced_players = players.where(sync_status: 'success').count - pending_sync = players.where(sync_status: ['pending', nil]).or(players.where(sync_status: nil)).count - failed_sync = players.where(sync_status: 'error').count - - # Players synced in last 24 hours - recently_synced = players.where('last_sync_at > ?', 24.hours.ago).count - - # Players that need sync (never synced or synced more than 1 hour ago) - needs_sync = players.where(last_sync_at: nil) - .or(players.where('last_sync_at < ?', 1.hour.ago)) - .count - - # Get recent syncs (last 10) - recent_syncs = players - .where.not(last_sync_at: nil) - .order(last_sync_at: :desc) - .limit(10) - .map do |player| - { - id: player.id, - summoner_name: player.summoner_name, - last_sync_at: player.last_sync_at, - sync_status: player.sync_status || 'pending' - } - end - - render_success({ - stats: { - total_players: total_players, - synced_players: synced_players, - pending_sync: pending_sync, - failed_sync: failed_sync, - recently_synced: recently_synced, - needs_sync: needs_sync - }, - recent_syncs: recent_syncs - }) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class RiotIntegrationController < ::RiotIntegration::Controllers::RiotIntegrationController + end + end +end diff --git a/app/controllers/api/v1/schedules_controller.rb b/app/controllers/api/v1/schedules_controller.rb index 5eed2a1..8c4d723 100644 --- a/app/controllers/api/v1/schedules_controller.rb +++ b/app/controllers/api/v1/schedules_controller.rb @@ -1,135 +1,9 @@ -class Api::V1::SchedulesController < Api::V1::BaseController - before_action :set_schedule, only: [:show, :update, :destroy] - - def index - schedules = organization_scoped(Schedule).includes(:match) - - # Apply filters - schedules = schedules.where(event_type: params[:event_type]) if params[:event_type].present? - schedules = schedules.where(status: params[:status]) if params[:status].present? - - # Date range filter - if params[:start_date].present? && params[:end_date].present? - schedules = schedules.where(start_time: params[:start_date]..params[:end_date]) - elsif params[:upcoming] == 'true' - schedules = schedules.where('start_time >= ?', Time.current) - elsif params[:past] == 'true' - schedules = schedules.where('end_time < ?', Time.current) - end - - # Today's events - if params[:today] == 'true' - schedules = schedules.where(start_time: Time.current.beginning_of_day..Time.current.end_of_day) - end - - # This week's events - if params[:this_week] == 'true' - schedules = schedules.where(start_time: Time.current.beginning_of_week..Time.current.end_of_week) - end - - # Sorting - sort_order = params[:sort_order] || 'asc' - schedules = schedules.order("start_time #{sort_order}") - - # Pagination - result = paginate(schedules) - - render_success({ - schedules: ScheduleSerializer.render_as_hash(result[:data]), - pagination: result[:pagination] - }) - end - - def show - render_success({ - schedule: ScheduleSerializer.render_as_hash(@schedule) - }) - end - - def create - schedule = organization_scoped(Schedule).new(schedule_params) - schedule.organization = current_organization - - if schedule.save - log_user_action( - action: 'create', - entity_type: 'Schedule', - entity_id: schedule.id, - new_values: schedule.attributes - ) - - render_created({ - schedule: ScheduleSerializer.render_as_hash(schedule) - }, message: 'Event scheduled successfully') - else - render_error( - message: 'Failed to create schedule', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: schedule.errors.as_json - ) - end - end - - def update - old_values = @schedule.attributes.dup - - if @schedule.update(schedule_params) - log_user_action( - action: 'update', - entity_type: 'Schedule', - entity_id: @schedule.id, - old_values: old_values, - new_values: @schedule.attributes - ) - - render_updated({ - schedule: ScheduleSerializer.render_as_hash(@schedule) - }) - else - render_error( - message: 'Failed to update schedule', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @schedule.errors.as_json - ) - end - end - - def destroy - if @schedule.destroy - log_user_action( - action: 'delete', - entity_type: 'Schedule', - entity_id: @schedule.id, - old_values: @schedule.attributes - ) - - render_deleted(message: 'Event deleted successfully') - else - render_error( - message: 'Failed to delete schedule', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_schedule - @schedule = organization_scoped(Schedule).find(params[:id]) - end - - def schedule_params - params.require(:schedule).permit( - :event_type, :title, :description, - :start_time, :end_time, :location, - :opponent_name, :status, :match_id, - :meeting_url, :all_day, :timezone, - :color, :is_recurring, :recurrence_rule, - :recurrence_end_date, :reminder_minutes, - required_players: [], optional_players: [], tags: [] - ) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class SchedulesController < ::Schedules::Controllers::SchedulesController + end + end +end diff --git a/app/controllers/api/v1/scouting/players_controller.rb b/app/controllers/api/v1/scouting/players_controller.rb index bbc7295..24adb7b 100644 --- a/app/controllers/api/v1/scouting/players_controller.rb +++ b/app/controllers/api/v1/scouting/players_controller.rb @@ -1,164 +1,203 @@ -class Api::V1::Scouting::PlayersController < Api::V1::BaseController - before_action :set_scouting_target, only: [:show, :update, :destroy, :sync] - - def index - targets = organization_scoped(ScoutingTarget).includes(:added_by, :assigned_to) - - # Apply filters - targets = targets.by_role(params[:role]) if params[:role].present? - targets = targets.by_status(params[:status]) if params[:status].present? - targets = targets.by_priority(params[:priority]) if params[:priority].present? - targets = targets.by_region(params[:region]) if params[:region].present? - - # Age range filter - if params[:age_range].present? && params[:age_range].is_a?(Array) - min_age, max_age = params[:age_range] - targets = targets.where(age: min_age..max_age) if min_age && max_age - end - - # Special filters - targets = targets.active if params[:active] == 'true' - targets = targets.high_priority if params[:high_priority] == 'true' - targets = targets.needs_review if params[:needs_review] == 'true' - targets = targets.assigned_to_user(params[:assigned_to_id]) if params[:assigned_to_id].present? - - # Search - if params[:search].present? - search_term = "%#{params[:search]}%" - targets = targets.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) - end - - # Sorting - sort_by = params[:sort_by] || 'created_at' - sort_order = params[:sort_order] || 'desc' - - # Map 'rank' to actual column names - if sort_by == 'rank' - targets = targets.order("current_lp #{sort_order} NULLS LAST") - elsif sort_by == 'winrate' - targets = targets.order("performance_trend #{sort_order} NULLS LAST") - else - targets = targets.order("#{sort_by} #{sort_order}") - end - - # Pagination - result = paginate(targets) - - render_success({ - players: ScoutingTargetSerializer.render_as_hash(result[:data]), - total: result[:pagination][:total_count], - page: result[:pagination][:current_page], - per_page: result[:pagination][:per_page], - total_pages: result[:pagination][:total_pages] - }) - end - - def show - render_success({ - scouting_target: ScoutingTargetSerializer.render_as_hash(@target) - }) - end - - def create - target = organization_scoped(ScoutingTarget).new(scouting_target_params) - target.organization = current_organization - target.added_by = current_user - - if target.save - log_user_action( - action: 'create', - entity_type: 'ScoutingTarget', - entity_id: target.id, - new_values: target.attributes - ) - - render_created({ - scouting_target: ScoutingTargetSerializer.render_as_hash(target) - }, message: 'Scouting target added successfully') - else - render_error( - message: 'Failed to add scouting target', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: target.errors.as_json - ) - end - end - - def update - old_values = @target.attributes.dup - - if @target.update(scouting_target_params) - log_user_action( - action: 'update', - entity_type: 'ScoutingTarget', - entity_id: @target.id, - old_values: old_values, - new_values: @target.attributes - ) - - render_updated({ - scouting_target: ScoutingTargetSerializer.render_as_hash(@target) - }) - else - render_error( - message: 'Failed to update scouting target', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @target.errors.as_json - ) - end - end - - def destroy - if @target.destroy - log_user_action( - action: 'delete', - entity_type: 'ScoutingTarget', - entity_id: @target.id, - old_values: @target.attributes - ) - - render_deleted(message: 'Scouting target removed successfully') - else - render_error( - message: 'Failed to remove scouting target', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - def sync - # This will sync the scouting target with Riot API - # Will be implemented when Riot API service is ready - render_error( - message: 'Sync functionality not yet implemented', - code: 'NOT_IMPLEMENTED', - status: :not_implemented - ) - end - - private - - def set_scouting_target - @target = organization_scoped(ScoutingTarget).find(params[:id]) - end - - def scouting_target_params - # :role refers to in-game position (top/jungle/mid/adc/support), not user role - # nosemgrep - params.require(:scouting_target).permit( - :summoner_name, :real_name, :role, :region, :nationality, - :age, :status, :priority, :current_team, - :current_tier, :current_rank, :current_lp, - :peak_tier, :peak_rank, - :riot_puuid, :riot_summoner_id, - :email, :phone, :discord_username, :twitter_handle, - :scouting_notes, :contact_notes, - :availability, :salary_expectations, - :performance_trend, :assigned_to_id, - champion_pool: [] - ) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Scouting + # Scouting Players Controller + # + # Manages scouting targets for League of Legends players. Provides CRUD operations + # for tracking potential recruits with comprehensive filtering, searching, and sorting. + # + # This controller handles: + # - Creating and managing scouting targets + # - Filtering by role, status, priority, region, age range + # - Searching by summoner name or real name + # - Sorting by multiple criteria (rank, winrate, priority, etc.) + # - Assignment to scouts + # - Audit logging of all changes + # - Pagination of results + # + # All operations are scoped to the current organization and require authentication. + # + # @example List all scouting targets + # GET /api/v1/scouting/players + # + # @example Filter high priority targets by role + # GET /api/v1/scouting/players?role=mid&high_priority=true + # + # @example Search and sort targets + # GET /api/v1/scouting/players?search=Faker&sort_by=rank&sort_order=desc + # + # @example Create a new scouting target + # POST /api/v1/scouting/players + # { "scouting_target": { "summoner_name": "Player1", "role": "mid", "priority": "high" } } + class PlayersController < Api::V1::BaseController + before_action :set_scouting_target, only: %i[show update destroy sync] + + def index + targets = organization_scoped(ScoutingTarget).includes(:added_by, :assigned_to) + + # Apply filters + targets = targets.by_role(params[:role]) if params[:role].present? + targets = targets.by_status(params[:status]) if params[:status].present? + targets = targets.by_priority(params[:priority]) if params[:priority].present? + targets = targets.by_region(params[:region]) if params[:region].present? + + # Age range filter + if params[:age_range].present? && params[:age_range].is_a?(Array) + min_age, max_age = params[:age_range] + targets = targets.where(age: min_age..max_age) if min_age && max_age + end + + # Special filters + targets = targets.active if params[:active] == 'true' + targets = targets.high_priority if params[:high_priority] == 'true' + targets = targets.needs_review if params[:needs_review] == 'true' + targets = targets.assigned_to_user(params[:assigned_to_id]) if params[:assigned_to_id].present? + + # Search + if params[:search].present? + search_term = "%#{params[:search]}%" + targets = targets.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) + end + + # Whitelist for sort parameters to prevent SQL injection + allowed_sort_fields = %w[created_at updated_at summoner_name current_tier priority status role region age] + allowed_sort_orders = %w[asc desc] + + sort_by = allowed_sort_fields.include?(params[:sort_by]) ? params[:sort_by] : 'created_at' + sort_order = allowed_sort_orders.include?(params[:sort_order]&.downcase) ? params[:sort_order].downcase : 'desc' + + # Handle special sort fields + targets = if params[:sort_by] == 'rank' + targets.order(Arel.sql("current_lp #{sort_order} NULLS LAST")) + elsif params[:sort_by] == 'winrate' + targets.order(Arel.sql("performance_trend #{sort_order} NULLS LAST")) + else + targets.order(sort_by => sort_order) + end + + # Pagination + result = paginate(targets) + + render_success({ + players: ScoutingTargetSerializer.render_as_hash(result[:data]), + total: result[:pagination][:total_count], + page: result[:pagination][:current_page], + per_page: result[:pagination][:per_page], + total_pages: result[:pagination][:total_pages] + }) + end + + def show + render_success({ + scouting_target: ScoutingTargetSerializer.render_as_hash(@target) + }) + end + + def create + target = organization_scoped(ScoutingTarget).new(scouting_target_params) + target.organization = current_organization + target.added_by = current_user + + if target.save + log_user_action( + action: 'create', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: target.attributes + ) + + render_created({ + scouting_target: ScoutingTargetSerializer.render_as_hash(target) + }, message: 'Scouting target added successfully') + else + render_error( + message: 'Failed to add scouting target', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: target.errors.as_json + ) + end + end + + def update + old_values = @target.attributes.dup + + if @target.update(scouting_target_params) + log_user_action( + action: 'update', + entity_type: 'ScoutingTarget', + entity_id: @target.id, + old_values: old_values, + new_values: @target.attributes + ) + + render_updated({ + scouting_target: ScoutingTargetSerializer.render_as_hash(@target) + }) + else + render_error( + message: 'Failed to update scouting target', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @target.errors.as_json + ) + end + end + + def destroy + if @target.destroy + log_user_action( + action: 'delete', + entity_type: 'ScoutingTarget', + entity_id: @target.id, + old_values: @target.attributes + ) + + render_deleted(message: 'Scouting target removed successfully') + else + render_error( + message: 'Failed to remove scouting target', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + def sync + # This will sync the scouting target with Riot API + # Will be implemented when Riot API service is ready + render_error( + message: 'Sync functionality not yet implemented', + code: 'NOT_IMPLEMENTED', + status: :not_implemented + ) + end + + private + + def set_scouting_target + @target = organization_scoped(ScoutingTarget).find(params[:id]) + end + + def scouting_target_params + # :role refers to in-game position (top/jungle/mid/adc/support), not user role + # nosemgrep + params.require(:scouting_target).permit( + :summoner_name, :real_name, :role, :region, :nationality, + :age, :status, :priority, :current_team, + :current_tier, :current_rank, :current_lp, + :peak_tier, :peak_rank, + :riot_puuid, :riot_summoner_id, + :email, :phone, :discord_username, :twitter_handle, + :scouting_notes, :contact_notes, + :availability, :salary_expectations, + :performance_trend, :assigned_to_id, + champion_pool: [] + ) + end + end + end + end +end diff --git a/app/controllers/api/v1/scouting/regions_controller.rb b/app/controllers/api/v1/scouting/regions_controller.rb index d3c7cb3..8bb2acf 100644 --- a/app/controllers/api/v1/scouting/regions_controller.rb +++ b/app/controllers/api/v1/scouting/regions_controller.rb @@ -1,21 +1,42 @@ -class Api::V1::Scouting::RegionsController < Api::V1::BaseController - skip_before_action :authenticate_request!, only: [:index] - - def index - regions = [ - { code: 'BR', name: 'Brazil', platform: 'BR1' }, - { code: 'NA', name: 'North America', platform: 'NA1' }, - { code: 'EUW', name: 'Europe West', platform: 'EUW1' }, - { code: 'EUNE', name: 'Europe Nordic & East', platform: 'EUN1' }, - { code: 'KR', name: 'Korea', platform: 'KR' }, - { code: 'JP', name: 'Japan', platform: 'JP1' }, - { code: 'OCE', name: 'Oceania', platform: 'OC1' }, - { code: 'LAN', name: 'Latin America North', platform: 'LA1' }, - { code: 'LAS', name: 'Latin America South', platform: 'LA2' }, - { code: 'RU', name: 'Russia', platform: 'RU' }, - { code: 'TR', name: 'Turkey', platform: 'TR1' } - ] - - render_success(regions) - end -end +# frozen_string_literal: true + +module Api + module V1 + module Scouting + # Regions Controller + # + # Provides League of Legends server region information for scouting purposes. + # Returns region codes, display names, and platform identifiers for all supported regions. + # + # @example GET /api/v1/scouting/regions + # [ + # { code: 'BR', name: 'Brazil', platform: 'BR1' }, + # { code: 'NA', name: 'North America', platform: 'NA1' } + # ] + # + # Main endpoints: + # - GET index: Returns all available regions with platform IDs (public, no auth required) + class RegionsController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: [:index] + + def index + regions = [ + { code: 'BR', name: 'Brazil', platform: 'BR1' }, + { code: 'NA', name: 'North America', platform: 'NA1' }, + { code: 'EUW', name: 'Europe West', platform: 'EUW1' }, + { code: 'EUNE', name: 'Europe Nordic & East', platform: 'EUN1' }, + { code: 'KR', name: 'Korea', platform: 'KR' }, + { code: 'JP', name: 'Japan', platform: 'JP1' }, + { code: 'OCE', name: 'Oceania', platform: 'OC1' }, + { code: 'LAN', name: 'Latin America North', platform: 'LA1' }, + { code: 'LAS', name: 'Latin America South', platform: 'LA2' }, + { code: 'RU', name: 'Russia', platform: 'RU' }, + { code: 'TR', name: 'Turkey', platform: 'TR1' } + ] + + render_success(regions) + end + end + end + end +end diff --git a/app/controllers/api/v1/scouting/watchlist_controller.rb b/app/controllers/api/v1/scouting/watchlist_controller.rb index 85b1fb9..3202811 100644 --- a/app/controllers/api/v1/scouting/watchlist_controller.rb +++ b/app/controllers/api/v1/scouting/watchlist_controller.rb @@ -1,61 +1,81 @@ -class Api::V1::Scouting::WatchlistController < Api::V1::BaseController - def index - # Watchlist is just high-priority scouting targets - targets = organization_scoped(ScoutingTarget) - .where(priority: %w[high critical]) - .where(status: %w[watching contacted negotiating]) - .includes(:added_by, :assigned_to) - .order(priority: :desc, created_at: :desc) - - render_success({ - watchlist: ScoutingTargetSerializer.render_as_hash(targets), - count: targets.size - }) - end - - def create - # Add a scouting target to watchlist by updating its priority - target = organization_scoped(ScoutingTarget).find(params[:scouting_target_id]) - - if target.update(priority: 'high') - log_user_action( - action: 'add_to_watchlist', - entity_type: 'ScoutingTarget', - entity_id: target.id, - new_values: { priority: 'high' } - ) - - render_created({ - scouting_target: ScoutingTargetSerializer.render_as_hash(target) - }, message: 'Added to watchlist') - else - render_error( - message: 'Failed to add to watchlist', - code: 'UPDATE_ERROR', - status: :unprocessable_entity - ) - end - end - - def destroy - # Remove from watchlist by lowering priority - target = organization_scoped(ScoutingTarget).find(params[:id]) - - if target.update(priority: 'medium') - log_user_action( - action: 'remove_from_watchlist', - entity_type: 'ScoutingTarget', - entity_id: target.id, - new_values: { priority: 'medium' } - ) - - render_deleted(message: 'Removed from watchlist') - else - render_error( - message: 'Failed to remove from watchlist', - code: 'UPDATE_ERROR', - status: :unprocessable_entity - ) - end - end -end +# frozen_string_literal: true + +module Api + module V1 + module Scouting + # Watchlist Controller + # + # Manages high-priority scouting targets for active recruitment tracking. + # Watchlist entries are scouting targets with high/critical priority and active status. + # + # @example GET /api/v1/scouting/watchlist + # { watchlist: [...], count: 5 } + # + # Main endpoints: + # - GET index: Lists all high-priority scouting targets currently being watched + # - POST create: Adds a scouting target to watchlist by elevating priority to 'high' + # - DELETE destroy: Removes from watchlist by lowering priority to 'medium' + class WatchlistController < Api::V1::BaseController + def index + # Watchlist is just high-priority scouting targets + targets = organization_scoped(ScoutingTarget) + .where(priority: %w[high critical]) + .where(status: %w[watching contacted negotiating]) + .includes(:added_by, :assigned_to) + .order(priority: :desc, created_at: :desc) + + render_success({ + watchlist: ScoutingTargetSerializer.render_as_hash(targets), + count: targets.size + }) + end + + def create + # Add a scouting target to watchlist by updating its priority + target = organization_scoped(ScoutingTarget).find(params[:scouting_target_id]) + + if target.update(priority: 'high') + log_user_action( + action: 'add_to_watchlist', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: { priority: 'high' } + ) + + render_created({ + scouting_target: ScoutingTargetSerializer.render_as_hash(target) + }, message: 'Added to watchlist') + else + render_error( + message: 'Failed to add to watchlist', + code: 'UPDATE_ERROR', + status: :unprocessable_entity + ) + end + end + + def destroy + # Remove from watchlist by lowering priority + target = organization_scoped(ScoutingTarget).find(params[:id]) + + if target.update(priority: 'medium') + log_user_action( + action: 'remove_from_watchlist', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: { priority: 'medium' } + ) + + render_deleted(message: 'Removed from watchlist') + else + render_error( + message: 'Failed to remove from watchlist', + code: 'UPDATE_ERROR', + status: :unprocessable_entity + ) + end + end + end + end + end +end diff --git a/app/controllers/api/v1/scrims/opponent_teams_controller.rb b/app/controllers/api/v1/scrims/opponent_teams_controller.rb new file mode 100644 index 0000000..3b784e9 --- /dev/null +++ b/app/controllers/api/v1/scrims/opponent_teams_controller.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scrims + # OpponentTeams Controller + # + # Manages opponent team records which are shared across organizations. + # Security note: Update and delete operations are restricted to organizations + # that have used this opponent team in scrims. + # + class OpponentTeamsController < Api::V1::BaseController + include TierAuthorization + include Paginatable + + before_action :set_opponent_team, only: %i[show update destroy scrim_history] + before_action :verify_team_usage!, only: %i[update destroy] + + # GET /api/v1/scrims/opponent_teams + def index + teams = OpponentTeam.all.order(:name) + + # Filters + teams = teams.by_region(params[:region]) if params[:region].present? + teams = teams.by_tier(params[:tier]) if params[:tier].present? + teams = teams.by_league(params[:league]) if params[:league].present? + teams = teams.with_scrims if params[:with_scrims] == 'true' + + # Search + if params[:search].present? + search_term = ActiveRecord::Base.sanitize_sql_like(params[:search]) + teams = teams.where('name ILIKE ? OR tag ILIKE ?', "%#{search_term}%", "%#{search_term}%") + end + + # Pagination + page = params[:page] || 1 + per_page = params[:per_page] || 20 + + teams = teams.page(page).per(per_page) + + render json: { + data: { + opponent_teams: teams.map { |team| ScrimOpponentTeamSerializer.new(team).as_json }, + meta: pagination_meta(teams) + } + } + end + + # GET /api/v1/scrims/opponent_teams/:id + def show + render json: { data: ScrimOpponentTeamSerializer.new(@opponent_team, detailed: true).as_json } + end + + # GET /api/v1/scrims/opponent_teams/:id/scrim_history + def scrim_history + scrims = current_organization.scrims + .where(opponent_team_id: @opponent_team.id) + .includes(:match) + .order(scheduled_at: :desc) + + service = Scrims::ScrimAnalyticsService.new(current_organization) + opponent_stats = service.opponent_performance(@opponent_team.id) + + render json: { + data: { + opponent_team: ScrimOpponentTeamSerializer.new(@opponent_team).as_json, + scrims: scrims.map { |scrim| ScrimSerializer.new(scrim).as_json }, + stats: opponent_stats + } + } + end + + # POST /api/v1/scrims/opponent_teams + def create + team = OpponentTeam.new(opponent_team_params) + + if team.save + render json: { data: ScrimOpponentTeamSerializer.new(team).as_json }, status: :created + else + render json: { errors: team.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH /api/v1/scrims/opponent_teams/:id + def update + if @opponent_team.update(opponent_team_params) + render json: { data: ScrimOpponentTeamSerializer.new(@opponent_team).as_json } + else + render json: { errors: @opponent_team.errors.full_messages }, status: :unprocessable_entity + end + end + + # DELETE /api/v1/scrims/opponent_teams/:id + def destroy + # Check if team has scrims from other organizations before deleting + other_org_scrims = @opponent_team.scrims.where.not(organization_id: current_organization.id).exists? + + if other_org_scrims + return render json: { + error: 'Cannot delete opponent team that is used by other organizations' + }, status: :unprocessable_entity + end + + @opponent_team.destroy + head :no_content + end + + private + + # Finds opponent team by ID + # Security Note: OpponentTeam is a shared resource across organizations. + # Access control is enforced via verify_team_usage! before_action for + # sensitive operations (update/destroy). This ensures organizations can + # only modify teams they have scrims with. + # Read operations (index/show) are allowed for all teams to enable discovery. + # + def set_opponent_team + id = Integer(params[:id], exception: false) + return render json: { error: 'Opponent team not found' }, status: :not_found unless id + + @opponent_team = OpponentTeam.find_by(id: id) + return render json: { error: 'Opponent team not found' }, status: :not_found unless @opponent_team + end + + # Verifies that current organization has used this opponent team + # Prevents organizations from modifying/deleting teams they haven't interacted with + def verify_team_usage! + has_scrims = current_organization.scrims.exists?(opponent_team_id: @opponent_team.id) + + return if has_scrims + + render json: { + error: 'You cannot modify this opponent team. Your organization has not played against them.' + }, status: :forbidden + end + + def opponent_team_params + params.require(:opponent_team).permit( + :name, + :tag, + :region, + :tier, + :league, + :logo_url, + :playstyle_notes, + :contact_email, + :discord_server, + known_players: [], + strengths: [], + weaknesses: [], + recent_performance: {}, + preferred_champions: {} + ) + end + end + end + end +end diff --git a/app/controllers/api/v1/scrims/scrims_controller.rb b/app/controllers/api/v1/scrims/scrims_controller.rb new file mode 100644 index 0000000..d0ad0b6 --- /dev/null +++ b/app/controllers/api/v1/scrims/scrims_controller.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scrims + # Scrims Controller + # + # Manages practice matches (scrims) against opponent teams. + # Handles scrim scheduling, game result tracking, analytics, and calendar views. + # Includes tier-based authorization for premium features. + # + # @example GET /api/v1/scrims?status=upcoming&per_page=10 + # { scrims: [...], meta: { current_page: 1, total_pages: 3 } } + # + # Main endpoints: + # - GET index: Lists scrims with filtering (type, focus_area, status) and pagination + # - GET calendar: Returns scrims within a date range for calendar visualization + # - GET analytics: Provides scrim performance statistics and trends + # - POST create: Creates new scrim (respects organization monthly limits) + # - POST add_game: Records individual game results within a scrim session + class ScrimsController < Api::V1::BaseController + include TierAuthorization + include Paginatable + + before_action :set_scrim, only: %i[show update destroy add_game] + + # GET /api/v1/scrims + def index + scrims = current_organization.scrims + .includes(:opponent_team, :match) + .order(scheduled_at: :desc) + + # Filters + scrims = scrims.by_type(params[:scrim_type]) if params[:scrim_type].present? + scrims = scrims.by_focus_area(params[:focus_area]) if params[:focus_area].present? + scrims = scrims.where(opponent_team_id: params[:opponent_team_id]) if params[:opponent_team_id].present? + + # Status filter + case params[:status] + when 'upcoming' + scrims = scrims.upcoming + when 'past' + scrims = scrims.past + when 'completed' + scrims = scrims.completed + when 'in_progress' + scrims = scrims.in_progress + end + + # Pagination + page = params[:page] || 1 + per_page = params[:per_page] || 20 + + scrims = scrims.page(page).per(per_page) + + render json: { + data: { + scrims: scrims.map { |scrim| ScrimSerializer.new(scrim).as_json }, + meta: pagination_meta(scrims) + } + } + end + + # GET /api/v1/scrims/calendar + def calendar + start_date = params[:start_date]&.to_date || Date.current.beginning_of_month + end_date = params[:end_date]&.to_date || Date.current.end_of_month + + scrims = current_organization.scrims + .includes(:opponent_team) + .where(scheduled_at: start_date..end_date) + .order(scheduled_at: :asc) + + render json: { + data: { + scrims: scrims.map { |scrim| ScrimSerializer.new(scrim, calendar_view: true).as_json }, + start_date: start_date, + end_date: end_date + } + } + end + + # GET /api/v1/scrims/analytics + def analytics + service = ::Scrims::Services::ScrimAnalyticsService.new(current_organization) + date_range = (params[:days]&.to_i || 30).days + + render json: { + overall_stats: service.overall_stats(date_range: date_range), + by_opponent: service.stats_by_opponent, + by_focus_area: service.stats_by_focus_area, + success_patterns: service.success_patterns, + improvement_trends: service.improvement_trends + } + end + + # GET /api/v1/scrims/:id + def show + render json: { data: ScrimSerializer.new(@scrim, detailed: true).as_json } + end + + # POST /api/v1/scrims + def create + # Check scrim creation limit + unless current_organization.can_create_scrim? + return render json: { + error: 'Scrim Limit Reached', + message: 'You have reached your monthly scrim limit. Upgrade to create more scrims.' + }, status: :forbidden + end + + scrim = current_organization.scrims.new(scrim_params) + + if scrim.save + render json: { data: ScrimSerializer.new(scrim).as_json }, status: :created + else + render json: { errors: scrim.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH /api/v1/scrims/:id + def update + if @scrim.update(scrim_params) + render json: { data: ScrimSerializer.new(@scrim).as_json } + else + render json: { errors: @scrim.errors.full_messages }, status: :unprocessable_entity + end + end + + # DELETE /api/v1/scrims/:id + def destroy + @scrim.destroy + head :no_content + end + + # POST /api/v1/scrims/:id/add_game + def add_game + victory = params[:victory] + duration = params[:duration] + notes = params[:notes] + + if @scrim.add_game_result(victory: victory, duration: duration, notes: notes) + # Update opponent team stats if present + @scrim.opponent_team.update_scrim_stats!(victory: victory) if @scrim.opponent_team.present? + + render json: { data: ScrimSerializer.new(@scrim.reload).as_json } + else + render json: { errors: @scrim.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def set_scrim + @scrim = current_organization.scrims.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Scrim not found' }, status: :not_found + end + + def scrim_params + params.require(:scrim).permit( + :opponent_team_id, + :match_id, + :scheduled_at, + :scrim_type, + :focus_area, + :pre_game_notes, + :post_game_notes, + :is_confidential, + :visibility, + :games_planned, + :games_completed, + game_results: [], + objectives: {}, + outcomes: {} + ) + end + end + end + end +end diff --git a/app/controllers/api/v1/team_goals_controller.rb b/app/controllers/api/v1/team_goals_controller.rb index 2634d07..72547aa 100644 --- a/app/controllers/api/v1/team_goals_controller.rb +++ b/app/controllers/api/v1/team_goals_controller.rb @@ -1,139 +1,9 @@ -class Api::V1::TeamGoalsController < Api::V1::BaseController - before_action :set_team_goal, only: [:show, :update, :destroy] - - def index - goals = organization_scoped(TeamGoal).includes(:player, :assigned_to, :created_by) - - # Apply filters - goals = goals.by_status(params[:status]) if params[:status].present? - goals = goals.by_category(params[:category]) if params[:category].present? - goals = goals.for_player(params[:player_id]) if params[:player_id].present? - - # Special filters - goals = goals.team_goals if params[:type] == 'team' - goals = goals.player_goals if params[:type] == 'player' - goals = goals.active if params[:active] == 'true' - goals = goals.overdue if params[:overdue] == 'true' - goals = goals.expiring_soon(params[:expiring_days]&.to_i || 7) if params[:expiring_soon] == 'true' - - # Assigned to filter - goals = goals.where(assigned_to_id: params[:assigned_to_id]) if params[:assigned_to_id].present? - - # Sorting - sort_by = params[:sort_by] || 'created_at' - sort_order = params[:sort_order] || 'desc' - goals = goals.order("#{sort_by} #{sort_order}") - - # Pagination - result = paginate(goals) - - render_success({ - goals: TeamGoalSerializer.render_as_hash(result[:data]), - pagination: result[:pagination], - summary: calculate_goals_summary(goals) - }) - end - - def show - render_success({ - goal: TeamGoalSerializer.render_as_hash(@goal) - }) - end - - def create - goal = organization_scoped(TeamGoal).new(team_goal_params) - goal.organization = current_organization - goal.created_by = current_user - - if goal.save - log_user_action( - action: 'create', - entity_type: 'TeamGoal', - entity_id: goal.id, - new_values: goal.attributes - ) - - render_created({ - goal: TeamGoalSerializer.render_as_hash(goal) - }, message: 'Goal created successfully') - else - render_error( - message: 'Failed to create goal', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: goal.errors.as_json - ) - end - end - - def update - old_values = @goal.attributes.dup - - if @goal.update(team_goal_params) - log_user_action( - action: 'update', - entity_type: 'TeamGoal', - entity_id: @goal.id, - old_values: old_values, - new_values: @goal.attributes - ) - - render_updated({ - goal: TeamGoalSerializer.render_as_hash(@goal) - }) - else - render_error( - message: 'Failed to update goal', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @goal.errors.as_json - ) - end - end - - def destroy - if @goal.destroy - log_user_action( - action: 'delete', - entity_type: 'TeamGoal', - entity_id: @goal.id, - old_values: @goal.attributes - ) - - render_deleted(message: 'Goal deleted successfully') - else - render_error( - message: 'Failed to delete goal', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_team_goal - @goal = organization_scoped(TeamGoal).find(params[:id]) - end - - def team_goal_params - params.require(:team_goal).permit( - :title, :description, :category, :metric_type, - :target_value, :current_value, :start_date, :end_date, - :status, :progress, :notes, - :player_id, :assigned_to_id - ) - end - - def calculate_goals_summary(goals) - { - total: goals.count, - by_status: goals.group(:status).count, - by_category: goals.group(:category).count, - active_count: goals.active.count, - completed_count: goals.where(status: 'completed').count, - overdue_count: goals.overdue.count, - avg_progress: goals.active.average(:progress)&.round(1) || 0 - } - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class TeamGoalsController < ::TeamGoals::Controllers::TeamGoalsController + end + end +end diff --git a/app/controllers/api/v1/vod_reviews_controller.rb b/app/controllers/api/v1/vod_reviews_controller.rb index b253f7a..f5c6c22 100644 --- a/app/controllers/api/v1/vod_reviews_controller.rb +++ b/app/controllers/api/v1/vod_reviews_controller.rb @@ -1,131 +1,9 @@ -class Api::V1::VodReviewsController < Api::V1::BaseController - before_action :set_vod_review, only: [:show, :update, :destroy] - - def index - vod_reviews = organization_scoped(VodReview).includes(:match, :reviewed_by) - - # Apply filters - vod_reviews = vod_reviews.where(status: params[:status]) if params[:status].present? - vod_reviews = vod_reviews.where(vod_platform: params[:platform]) if params[:platform].present? - - # Match filter - vod_reviews = vod_reviews.where(match_id: params[:match_id]) if params[:match_id].present? - - # Reviewed by filter - vod_reviews = vod_reviews.where(reviewed_by_id: params[:reviewed_by_id]) if params[:reviewed_by_id].present? - - # Search by title - if params[:search].present? - search_term = "%#{params[:search]}%" - vod_reviews = vod_reviews.where('title ILIKE ?', search_term) - end - - # Sorting - sort_by = params[:sort_by] || 'created_at' - sort_order = params[:sort_order] || 'desc' - vod_reviews = vod_reviews.order("#{sort_by} #{sort_order}") - - # Pagination - result = paginate(vod_reviews) - - render_success({ - vod_reviews: VodReviewSerializer.render_as_hash(result[:data], include_timestamps_count: true), - pagination: result[:pagination] - }) - end - - def show - vod_review_data = VodReviewSerializer.render_as_hash(@vod_review) - timestamps = VodTimestampSerializer.render_as_hash( - @vod_review.vod_timestamps.includes(:target_player, :created_by).order(:timestamp_seconds) - ) - - render_success({ - vod_review: vod_review_data, - timestamps: timestamps - }) - end - - def create - vod_review = organization_scoped(VodReview).new(vod_review_params) - vod_review.organization = current_organization - vod_review.reviewed_by = current_user - - if vod_review.save - log_user_action( - action: 'create', - entity_type: 'VodReview', - entity_id: vod_review.id, - new_values: vod_review.attributes - ) - - render_created({ - vod_review: VodReviewSerializer.render_as_hash(vod_review) - }, message: 'VOD review created successfully') - else - render_error( - message: 'Failed to create VOD review', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: vod_review.errors.as_json - ) - end - end - - def update - old_values = @vod_review.attributes.dup - - if @vod_review.update(vod_review_params) - log_user_action( - action: 'update', - entity_type: 'VodReview', - entity_id: @vod_review.id, - old_values: old_values, - new_values: @vod_review.attributes - ) - - render_updated({ - vod_review: VodReviewSerializer.render_as_hash(@vod_review) - }) - else - render_error( - message: 'Failed to update VOD review', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @vod_review.errors.as_json - ) - end - end - - def destroy - if @vod_review.destroy - log_user_action( - action: 'delete', - entity_type: 'VodReview', - entity_id: @vod_review.id, - old_values: @vod_review.attributes - ) - - render_deleted(message: 'VOD review deleted successfully') - else - render_error( - message: 'Failed to delete VOD review', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_vod_review - @vod_review = organization_scoped(VodReview).find(params[:id]) - end - - def vod_review_params - params.require(:vod_review).permit( - :title, :vod_url, :vod_platform, :game_start_timestamp, - :status, :notes, :match_id - ) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class VodReviewsController < ::VodReviews::Controllers::VodReviewsController + end + end +end diff --git a/app/controllers/api/v1/vod_timestamps_controller.rb b/app/controllers/api/v1/vod_timestamps_controller.rb index 14fdffd..1a0851c 100644 --- a/app/controllers/api/v1/vod_timestamps_controller.rb +++ b/app/controllers/api/v1/vod_timestamps_controller.rb @@ -1,107 +1,9 @@ -class Api::V1::VodTimestampsController < Api::V1::BaseController - before_action :set_vod_review, only: [:index, :create] - before_action :set_vod_timestamp, only: [:update, :destroy] - - def index - timestamps = @vod_review.vod_timestamps - .includes(:target_player, :created_by) - .order(:timestamp_seconds) - - # Apply filters - timestamps = timestamps.where(category: params[:category]) if params[:category].present? - timestamps = timestamps.where(importance: params[:importance]) if params[:importance].present? - timestamps = timestamps.where(target_player_id: params[:player_id]) if params[:player_id].present? - - render_success({ - timestamps: VodTimestampSerializer.render_as_hash(timestamps) - }) - end - - def create - timestamp = @vod_review.vod_timestamps.new(vod_timestamp_params) - timestamp.created_by = current_user - - if timestamp.save - log_user_action( - action: 'create', - entity_type: 'VodTimestamp', - entity_id: timestamp.id, - new_values: timestamp.attributes - ) - - render_created({ - timestamp: VodTimestampSerializer.render_as_hash(timestamp) - }, message: 'Timestamp added successfully') - else - render_error( - message: 'Failed to create timestamp', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: timestamp.errors.as_json - ) - end - end - - def update - old_values = @timestamp.attributes.dup - - if @timestamp.update(vod_timestamp_params) - log_user_action( - action: 'update', - entity_type: 'VodTimestamp', - entity_id: @timestamp.id, - old_values: old_values, - new_values: @timestamp.attributes - ) - - render_updated({ - timestamp: VodTimestampSerializer.render_as_hash(@timestamp) - }) - else - render_error( - message: 'Failed to update timestamp', - code: 'VALIDATION_ERROR', - status: :unprocessable_entity, - details: @timestamp.errors.as_json - ) - end - end - - def destroy - if @timestamp.destroy - log_user_action( - action: 'delete', - entity_type: 'VodTimestamp', - entity_id: @timestamp.id, - old_values: @timestamp.attributes - ) - - render_deleted(message: 'Timestamp deleted successfully') - else - render_error( - message: 'Failed to delete timestamp', - code: 'DELETE_ERROR', - status: :unprocessable_entity - ) - end - end - - private - - def set_vod_review - @vod_review = organization_scoped(VodReview).find(params[:vod_review_id]) - end - - def set_vod_timestamp - @timestamp = VodTimestamp.joins(:vod_review) - .where(vod_reviews: { organization: current_organization }) - .find(params[:id]) - end - - def vod_timestamp_params - params.require(:vod_timestamp).permit( - :timestamp_seconds, :category, :importance, - :title, :description, :target_player_id - ) - end -end +# frozen_string_literal: true + +# Proxy controller - inherits from modularized controller +module Api + module V1 + class VodTimestampsController < ::VodReviews::Controllers::VodTimestampsController + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 10cf4d7..de0b6b1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,13 +1,32 @@ -class ApplicationController < ActionController::API - # Prevent CSRF attacks by raising an exception. - # For APIs, you may want to use :null_session instead. - # protect_from_forgery with: :exception - - before_action :set_default_response_format - - private - - def set_default_response_format - request.format = :json - end -end \ No newline at end of file +# frozen_string_literal: true + +# Base Application Controller +# +# Root controller for the entire API application. All controllers inherit from this class. +# Provides fundamental API configuration and sets JSON as the default response format. +# +# This controller: +# - Inherits from ActionController::API for API-only functionality +# - Sets JSON as the default response format for all endpoints +# - Provides the foundation for API behavior across the application +# +# Note: CSRF protection is disabled as this is an API-only application. +# Specific controllers (like Api::V1::BaseController) add authentication and authorization. +# +# @example Inheriting in a namespaced controller +# class Api::V1::BaseController < ApplicationController +# include Authenticatable +# end +class ApplicationController < ActionController::API + # Prevent CSRF attacks by raising an exception. + # For APIs, you may want to use :null_session instead. + # protect_from_forgery with: :exception + + before_action :set_default_response_format + + private + + def set_default_response_format + request.format = :json + end +end diff --git a/app/controllers/concerns/authenticatable.rb b/app/controllers/concerns/authenticatable.rb index a2f8140..4769be3 100644 --- a/app/controllers/concerns/authenticatable.rb +++ b/app/controllers/concerns/authenticatable.rb @@ -1,118 +1,119 @@ -module Authenticatable - extend ActiveSupport::Concern - - included do - before_action :authenticate_request! - before_action :set_current_user - before_action :set_current_organization - end - - private - - def authenticate_request! - token = extract_token_from_header - - if token.nil? - render_unauthorized('Missing authentication token') - return - end - - begin - @jwt_payload = Authentication::Services::JwtService.decode(token) - @current_user = User.find(@jwt_payload[:user_id]) - @current_organization = @current_user.organization - - # Update last login time - @current_user.update_last_login! if should_update_last_login? - - rescue Authentication::Services::JwtService::AuthenticationError => e - render_unauthorized(e.message) - rescue ActiveRecord::RecordNotFound - render_unauthorized('User not found') - end - end - - def extract_token_from_header - auth_header = request.headers['Authorization'] - return nil unless auth_header - - match = auth_header.match(/Bearer\s+(.+)/i) - match&.[](1) - end - - def current_user - @current_user - end - - def current_organization - @current_organization - end - - def current_user_id - @current_user&.id - end - - def current_organization_id - @current_organization&.id - end - - def user_signed_in? - @current_user.present? - end - - def require_admin! - unless current_user&.admin_or_owner? - render_forbidden('Admin access required') - end - end - - def require_owner! - unless current_user&.role == 'owner' - render_forbidden('Owner access required') - end - end - - def require_role!(*allowed_roles) - unless allowed_roles.include?(current_user&.role) - render_forbidden("Required role: #{allowed_roles.join(' or ')}") - end - end - - def organization_scoped(model_class) - model_class.where(organization: current_organization) - end - - def set_current_user - # This method can be overridden in controllers if needed - end - - def set_current_organization - # This method can be overridden in controllers if needed - end - - def should_update_last_login? - return false unless @current_user - return true if @current_user.last_login_at.nil? - - # Only update if last login was more than 1 hour ago to avoid too many updates - @current_user.last_login_at < 1.hour.ago - end - - def render_unauthorized(message = 'Unauthorized') - render json: { - error: { - code: 'UNAUTHORIZED', - message: message - } - }, status: :unauthorized - end - - def render_forbidden(message = 'Forbidden') - render json: { - error: { - code: 'FORBIDDEN', - message: message - } - }, status: :forbidden - end -end \ No newline at end of file +# frozen_string_literal: true + +module Authenticatable + extend ActiveSupport::Concern + + included do + before_action :authenticate_request! + before_action :set_current_user + before_action :set_current_organization + end + + private + + def authenticate_request! + token = extract_token_from_header + + if token.nil? + render_unauthorized('Missing authentication token') + return + end + + begin + @jwt_payload = Authentication::Services::JwtService.decode(token) + @current_user = User.find(@jwt_payload[:user_id]) + @current_organization = @current_user.organization + + # Update last login time + @current_user.update_last_login! if should_update_last_login? + rescue Authentication::Services::JwtService::AuthenticationError => e + render_unauthorized(e.message) + rescue ActiveRecord::RecordNotFound + render_unauthorized('User not found') + end + end + + def extract_token_from_header + auth_header = request.headers['Authorization'] + return nil unless auth_header + + match = auth_header.match(/Bearer\s+(.+)/i) + match&.[](1) + end + + def current_user + @current_user + end + + def current_organization + @current_organization + end + + def current_user_id + @current_user&.id + end + + def current_organization_id + @current_organization&.id + end + + def user_signed_in? + @current_user.present? + end + + def require_admin! + return if current_user&.admin_or_owner? + + render_forbidden('Admin access required') + end + + def require_owner! + return if current_user&.role == 'owner' + + render_forbidden('Owner access required') + end + + def require_role!(*allowed_roles) + return if allowed_roles.include?(current_user&.role) + + render_forbidden("Required role: #{allowed_roles.join(' or ')}") + end + + def organization_scoped(model_class) + model_class.where(organization: current_organization) + end + + def set_current_user + # This method can be overridden in controllers if needed + end + + def set_current_organization + # This method can be overridden in controllers if needed + end + + def should_update_last_login? + return false unless @current_user + return true if @current_user.last_login_at.nil? + + # Only update if last login was more than 1 hour ago to avoid too many updates + @current_user.last_login_at < 1.hour.ago + end + + def render_unauthorized(message = 'Unauthorized') + render json: { + error: { + code: 'UNAUTHORIZED', + message: message + } + }, status: :unauthorized + end + + def render_forbidden(message = 'Forbidden') + render json: { + error: { + code: 'FORBIDDEN', + message: message + } + }, status: :forbidden + end +end diff --git a/app/controllers/concerns/paginatable.rb b/app/controllers/concerns/paginatable.rb new file mode 100644 index 0000000..ec6f4b4 --- /dev/null +++ b/app/controllers/concerns/paginatable.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Paginatable Concern +# +# Provides pagination helper methods for controllers using Kaminari. +# Include this concern to add consistent pagination metadata to API responses. +# +# Example: +# class MyController < ApplicationController +# include Paginatable +# +# def index +# records = Model.page(params[:page]).per(params[:per_page]) +# render json: { data: records, meta: pagination_meta(records) } +# end +# end +# +module Paginatable + extend ActiveSupport::Concern + + private + + # Builds pagination metadata for a Kaminari paginated collection + # + # @param collection [ActiveRecord::Relation] Kaminari paginated collection + # @return [Hash] Pagination metadata + # + # @example + # users = User.page(1).per(20) + # pagination_meta(users) + # # => { + # # current_page: 1, + # # total_pages: 5, + # # total_count: 100, + # # per_page: 20 + # # } + def pagination_meta(collection) + { + current_page: collection.current_page, + total_pages: collection.total_pages, + total_count: collection.total_count, + per_page: collection.limit_value + } + end +end diff --git a/app/controllers/concerns/parameter_validation.rb b/app/controllers/concerns/parameter_validation.rb new file mode 100644 index 0000000..735e1bb --- /dev/null +++ b/app/controllers/concerns/parameter_validation.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +# Parameter Validation Concern +# +# Provides helper methods for validating and sanitizing controller parameters. +# Helps prevent nil errors and improves input validation across controllers. +# +# @example Usage in a controller +# class MyController < ApplicationController +# include ParameterValidation +# +# def index +# # Validate required parameter +# validate_required_param!(:user_id) +# +# # Validate with custom error message +# validate_required_param!(:email, message: 'Email is required') +# +# # Validate enum value +# status = validate_enum_param(:status, %w[active inactive]) +# +# # Get integer with default +# page = integer_param(:page, default: 1, min: 1) +# end +# end +# +module ParameterValidation + extend ActiveSupport::Concern + + # Validates that a required parameter is present + # + # @param param_name [Symbol] Name of the parameter to validate + # @param message [String] Custom error message (optional) + # @raise [ActionController::ParameterMissing] if parameter is missing or blank + # @return [String] The parameter value + # + # @example + # validate_required_param!(:email) + # # => "user@example.com" or raises error + def validate_required_param!(param_name, message: nil) + value = params[param_name] + + if value.blank? + error_message = message || "#{param_name.to_s.humanize} is required" + raise ActionController::ParameterMissing.new(param_name), error_message + end + + value + end + + # Validates that a parameter matches one of the allowed values + # + # @param param_name [Symbol] Name of the parameter to validate + # @param allowed_values [Array] Array of allowed values + # @param default [Object] Default value if parameter is missing (optional) + # @param message [String] Custom error message (optional) + # @return [String, Object] The validated parameter value or default + # @raise [ArgumentError] if value is not in allowed_values + # + # @example + # validate_enum_param(:status, %w[active inactive], default: 'active') + # # => "active" + def validate_enum_param(param_name, allowed_values, default: nil, message: nil) + value = params[param_name] + + return default if value.blank? && default.present? + + if value.present? && !allowed_values.include?(value) + error_message = message || "#{param_name.to_s.humanize} must be one of: #{allowed_values.join(', ')}" + raise ArgumentError, error_message + end + + value || default + end + + # Extracts and validates an integer parameter + # + # @param param_name [Symbol] Name of the parameter + # @param default [Integer] Default value if parameter is missing + # @param min [Integer] Minimum allowed value (optional) + # @param max [Integer] Maximum allowed value (optional) + # @return [Integer] The validated integer value + # @raise [ArgumentError] if value is not a valid integer or out of range + # + # @example + # integer_param(:page, default: 1, min: 1, max: 100) + # # => 1 + def integer_param(param_name, default: nil, min: nil, max: nil) + value = params[param_name] + + return default if value.blank? + + begin + int_value = Integer(value) + rescue ArgumentError, TypeError + raise ArgumentError, "#{param_name.to_s.humanize} must be a valid integer" + end + + raise ArgumentError, "#{param_name.to_s.humanize} must be at least #{min}" if min.present? && int_value < min + + raise ArgumentError, "#{param_name.to_s.humanize} must be at most #{max}" if max.present? && int_value > max + + int_value + end + + # Extracts and validates a boolean parameter + # + # @param param_name [Symbol] Name of the parameter + # @param default [Boolean] Default value if parameter is missing + # @return [Boolean] The boolean value + # + # @example + # boolean_param(:active, default: true) + # # => true + def boolean_param(param_name, default: false) + value = params[param_name] + + return default unless value + + ActiveModel::Type::Boolean.new.cast(value) + end + + # Extracts and validates a date parameter + # + # @param param_name [Symbol] Name of the parameter + # @param default [Date] Default value if parameter is missing + # @return [Date, nil] The date value + # @raise [ArgumentError] if value is not a valid date + # + # @example + # date_param(:start_date) + # # => Date object or nil + def date_param(param_name, default: nil) + value = params[param_name] + + return default if value.blank? + + begin + Date.parse(value.to_s) + rescue ArgumentError + raise ArgumentError, "#{param_name.to_s.humanize} must be a valid date" + end + end + + # Validates and sanitizes email parameter + # + # @param param_name [Symbol] Name of the parameter + # @param required [Boolean] Whether the parameter is required + # @return [String, nil] Normalized email (lowercase, stripped) + # @raise [ArgumentError] if email format is invalid + # + # @example + # email_param(:email, required: true) + # # => "user@example.com" + def email_param(param_name, required: false) + value = params[param_name] + + raise ArgumentError, "#{param_name.to_s.humanize} is required" if required && value.blank? + + return nil if value.blank? + + email = value.to_s.downcase.strip + + unless email.match?(URI::MailTo::EMAIL_REGEXP) + raise ArgumentError, "#{param_name.to_s.humanize} must be a valid email address" + end + + email + end + + # Validates array parameter + # + # @param param_name [Symbol] Name of the parameter + # @param default [Array] Default value if parameter is missing + # @param max_size [Integer] Maximum array size (optional) + # @return [Array] The array value + # @raise [ArgumentError] if not an array or exceeds max size + # + # @example + # array_param(:tags, default: [], max_size: 10) + # # => ["tag1", "tag2"] + def array_param(param_name, default: [], max_size: nil) + value = params[param_name] + + return default if value.blank? + + raise ArgumentError, "#{param_name.to_s.humanize} must be an array" unless value.is_a?(Array) + + if max_size.present? && value.size > max_size + raise ArgumentError, "#{param_name.to_s.humanize} cannot contain more than #{max_size} items" + end + + value + end + + # Sanitizes string parameter (strips whitespace) + # + # @param param_name [Symbol] Name of the parameter + # @param default [String] Default value if parameter is missing + # @param max_length [Integer] Maximum string length (optional) + # @return [String, nil] Sanitized string + # @raise [ArgumentError] if exceeds max_length + # + # @example + # string_param(:name, max_length: 255) + # # => "John Doe" + def string_param(param_name, default: nil, max_length: nil) + value = params[param_name] + + return default if value.blank? + + string = value.to_s.strip + + if max_length.present? && string.length > max_length + raise ArgumentError, "#{param_name.to_s.humanize} cannot be longer than #{max_length} characters" + end + + string + end +end diff --git a/app/controllers/concerns/tier_authorization.rb b/app/controllers/concerns/tier_authorization.rb new file mode 100644 index 0000000..7b481e8 --- /dev/null +++ b/app/controllers/concerns/tier_authorization.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module TierAuthorization + extend ActiveSupport::Concern + + included do + before_action :check_tier_access, only: %i[create update] + before_action :check_match_limit, only: [:create], if: -> { controller_name == 'matches' } + before_action :check_player_limit, only: [:create], if: -> { controller_name == 'players' } + end + + private + + def check_tier_access + feature = controller_feature_name + + return if current_organization.can_access?(feature) + + render_upgrade_required(feature) + end + + def controller_feature_name + # Map controller to feature name + controller_map = { + 'scrims' => 'scrims', + 'opponent_teams' => 'opponent_database', + 'draft_analysis' => 'draft_analysis', + 'team_comp' => 'team_composition', + 'competitive_matches' => 'competitive_data', + 'predictive' => 'predictive_analytics' + } + + controller_map[controller_name] || controller_name + end + + def render_upgrade_required(feature) + required_tier = tier_required_for(feature) + + render json: { + error: 'Upgrade Required', + message: "This feature requires #{required_tier} subscription", + current_tier: current_organization.tier, + required_tier: required_tier, + upgrade_url: "#{frontend_url}/pricing", + feature: feature + }, status: :forbidden + end + + def tier_required_for(feature) + tier_requirements = { + 'scrims' => 'Tier 2 (Semi-Pro)', + 'opponent_database' => 'Tier 2 (Semi-Pro)', + 'draft_analysis' => 'Tier 2 (Semi-Pro)', + 'team_composition' => 'Tier 2 (Semi-Pro)', + 'competitive_data' => 'Tier 1 (Professional)', + 'predictive_analytics' => 'Tier 1 (Professional)', + 'meta_analysis' => 'Tier 1 (Professional)', + 'api_access' => 'Tier 1 (Enterprise)' + } + + tier_requirements[feature] || 'Unknown' + end + + def check_match_limit + return unless current_organization.match_limit_reached? + + render json: { + error: 'Limit Reached', + message: 'Monthly match limit reached. Upgrade to increase limit.', + upgrade_url: "#{frontend_url}/pricing", + current_limit: current_organization.tier_limits[:max_matches_per_month], + current_usage: current_organization.tier_limits[:current_monthly_matches] + }, status: :forbidden + end + + def check_player_limit + return unless current_organization.player_limit_reached? + + render json: { + error: 'Limit Reached', + message: 'Player limit reached. Upgrade to add more players.', + upgrade_url: "#{frontend_url}/pricing", + current_limit: current_organization.tier_limits[:max_players], + current_usage: current_organization.tier_limits[:current_players] + }, status: :forbidden + end + + def frontend_url + ENV['FRONTEND_URL'] || 'http://localhost:3000' + end + + # Helper method to check feature access without rendering + def has_feature_access?(feature_name) + current_organization.can_access?(feature_name) + end + + # Helper method to get tier limits + def current_tier_limits + current_organization.tier_limits + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d..bef3959 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked diff --git a/app/jobs/cleanup_expired_tokens_job.rb b/app/jobs/cleanup_expired_tokens_job.rb new file mode 100644 index 0000000..638fa13 --- /dev/null +++ b/app/jobs/cleanup_expired_tokens_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CleanupExpiredTokensJob < ApplicationJob + queue_as :default + + # This job should be scheduled to run periodically (e.g., daily) + # You can use cron, sidekiq-scheduler, or a similar tool to schedule this job + + def perform + Rails.logger.info 'Starting cleanup of expired tokens...' + + # Cleanup expired password reset tokens + password_reset_deleted = PasswordResetToken.cleanup_old_tokens + Rails.logger.info "Cleaned up #{password_reset_deleted} expired password reset tokens" + + # Cleanup expired blacklisted tokens + blacklist_deleted = TokenBlacklist.cleanup_expired + Rails.logger.info "Cleaned up #{blacklist_deleted} expired blacklisted tokens" + + Rails.logger.info 'Token cleanup completed successfully' + rescue StandardError => e + Rails.logger.error "Error during token cleanup: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise e + end +end diff --git a/app/jobs/concerns/rank_comparison.rb b/app/jobs/concerns/rank_comparison.rb new file mode 100644 index 0000000..9665934 --- /dev/null +++ b/app/jobs/concerns/rank_comparison.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Concern for comparing League of Legends ranks +# +# Provides utilities for determining if a new rank is higher than a current rank. +# Used in sync jobs to update peak rank information. +# +# Can be used as module methods or included in classes: +# RankComparison.should_update_peak?(entity, new_tier, new_rank) +# # or +# include RankComparison +# should_update_peak?(entity, new_tier, new_rank) +module RankComparison + extend ActiveSupport::Concern + + TIER_HIERARCHY = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER].freeze + + RANK_HIERARCHY = %w[IV III II I].freeze + + # Determines if peak rank should be updated + # + # @param entity [Object] Entity with peak_tier and peak_rank attributes + # @param new_tier [String] New tier to compare + # @param new_rank [String] New rank to compare + # @return [Boolean] True if peak should be updated + def should_update_peak?(entity, new_tier, new_rank) + return true if entity.peak_tier.blank? + + current_tier_index = tier_index(entity.peak_tier) + new_tier_index = tier_index(new_tier) + + return true if new_tier_higher?(new_tier_index, current_tier_index) + return false if new_tier_lower?(new_tier_index, current_tier_index) + + new_rank_higher?(entity.peak_rank, new_rank) + end + module_function :should_update_peak? + + # Returns the index of a tier in the hierarchy + # + # @param tier [String] Tier name + # @return [Integer] Index in hierarchy (0 for lowest) + def tier_index(tier) + TIER_HIERARCHY.index(tier&.upcase) || 0 + end + module_function :tier_index + + # Returns the index of a rank within a tier + # + # @param rank [String] Rank (I, II, III, IV) + # @return [Integer] Index in hierarchy (0 for lowest) + def rank_index(rank) + RANK_HIERARCHY.index(rank&.upcase) || 0 + end + module_function :rank_index + + # Checks if new tier is higher than current + # + # @param new_index [Integer] New tier index + # @param current_index [Integer] Current tier index + # @return [Boolean] True if new tier is higher + def new_tier_higher?(new_index, current_index) + new_index > current_index + end + module_function :new_tier_higher? + + # Checks if new tier is lower than current + # + # @param new_index [Integer] New tier index + # @param current_index [Integer] Current tier index + # @return [Boolean] True if new tier is lower + def new_tier_lower?(new_index, current_index) + new_index < current_index + end + module_function :new_tier_lower? + + # Checks if new rank is higher than current within the same tier + # + # @param current_rank [String] Current rank + # @param new_rank [String] New rank + # @return [Boolean] True if new rank is higher + def new_rank_higher?(current_rank, new_rank) + rank_index(new_rank) > rank_index(current_rank) + end + module_function :new_rank_higher? +end diff --git a/app/jobs/sync_match_job.rb b/app/jobs/sync_match_job.rb index e0f0a9f..2e3196f 100644 --- a/app/jobs/sync_match_job.rb +++ b/app/jobs/sync_match_job.rb @@ -1,133 +1,155 @@ -class SyncMatchJob < ApplicationJob - queue_as :default - - retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 - retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 - - def perform(match_id, organization_id, region = 'BR') - organization = Organization.find(organization_id) - riot_service = RiotApiService.new - - match_data = riot_service.get_match_details( - match_id: match_id, - region: region - ) - - # Check if match already exists - match = Match.find_by(riot_match_id: match_data[:match_id]) - if match.present? - Rails.logger.info("Match #{match_id} already exists") - return - end - - # Create match record - match = create_match_record(match_data, organization) - - # Create player match stats - create_player_match_stats(match, match_data[:participants], organization) - - Rails.logger.info("Successfully synced match #{match_id}") - - rescue RiotApiService::NotFoundError => e - Rails.logger.error("Match not found in Riot API: #{match_id} - #{e.message}") - rescue StandardError => e - Rails.logger.error("Failed to sync match #{match_id}: #{e.message}") - raise - end - - private - - def create_match_record(match_data, organization) - Match.create!( - organization: organization, - riot_match_id: match_data[:match_id], - match_type: determine_match_type(match_data[:game_mode]), - game_start: match_data[:game_creation], - game_end: match_data[:game_creation] + match_data[:game_duration].seconds, - game_duration: match_data[:game_duration], - patch_version: match_data[:game_version], - victory: determine_team_victory(match_data[:participants], organization) - ) - end - - def create_player_match_stats(match, participants, organization) - participants.each do |participant_data| - # Find player by PUUID - player = organization.players.find_by(riot_puuid: participant_data[:puuid]) - next unless player - - PlayerMatchStat.create!( - match: match, - player: player, - role: normalize_role(participant_data[:role]), - champion: participant_data[:champion_name], - kills: participant_data[:kills], - deaths: participant_data[:deaths], - assists: participant_data[:assists], - gold_earned: participant_data[:gold_earned], - total_damage_dealt: participant_data[:total_damage_dealt], - total_damage_taken: participant_data[:total_damage_taken], - minions_killed: participant_data[:minions_killed], - jungle_minions_killed: participant_data[:neutral_minions_killed], - vision_score: participant_data[:vision_score], - wards_placed: participant_data[:wards_placed], - wards_killed: participant_data[:wards_killed], - champion_level: participant_data[:champion_level], - first_blood_kill: participant_data[:first_blood_kill], - 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) - ) - end - end - - def determine_match_type(game_mode) - case game_mode.upcase - when 'CLASSIC' then 'official' - when 'ARAM' then 'scrim' - else 'scrim' - end - end - - def determine_team_victory(participants, organization) - # Find our players in the match - our_player_puuids = organization.players.pluck(:riot_puuid).compact - our_participants = participants.select { |p| our_player_puuids.include?(p[:puuid]) } - - return nil if our_participants.empty? - - # Check if our players won - our_participants.first[:win] - end - - def normalize_role(role) - role_mapping = { - 'top' => 'top', - 'jungle' => 'jungle', - 'middle' => 'mid', - 'mid' => 'mid', - 'bottom' => 'adc', - 'adc' => 'adc', - 'utility' => 'support', - 'support' => 'support' - } - - role_mapping[role&.downcase] || 'mid' - end - - def calculate_performance_score(participant_data) - # Simple performance score calculation - # This can be made more sophisticated - kda = participant_data[:deaths].zero? ? - (participant_data[:kills] + participant_data[:assists]).to_f : - (participant_data[:kills] + participant_data[:assists]).to_f / participant_data[:deaths] - - base_score = kda * 10 - damage_score = (participant_data[:total_damage_dealt] / 1000.0) - vision_score = participant_data[:vision_score] || 0 - - (base_score + damage_score * 0.1 + vision_score).round(2) - end -end +# frozen_string_literal: true + +class SyncMatchJob < ApplicationJob + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(match_id, organization_id, region = 'BR') + organization = Organization.find(organization_id) + riot_service = RiotApiService.new + + match_data = riot_service.get_match_details( + match_id: match_id, + region: region + ) + + # Check if match already exists + match = Match.find_by(riot_match_id: match_data[:match_id]) + if match.present? + Rails.logger.info("Match #{match_id} already exists") + return + end + + # Create match record + match = create_match_record(match_data, organization) + + # Create player match stats + create_player_match_stats(match, match_data[:participants], organization) + + Rails.logger.info("Successfully synced match #{match_id}") + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Match not found in Riot API: #{match_id} - #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync match #{match_id}: #{e.message}") + raise + end + + private + + def create_match_record(match_data, organization) + Match.create!( + organization: organization, + riot_match_id: match_data[:match_id], + match_type: determine_match_type(match_data[:game_mode]), + game_start: match_data[:game_creation], + game_end: match_data[:game_creation] + match_data[:game_duration].seconds, + game_duration: match_data[:game_duration], + game_version: match_data[:game_version], + victory: determine_team_victory(match_data[:participants], organization) + ) + end + + def create_player_match_stats(match, participants, organization) + Rails.logger.info "Creating stats for #{participants.count} participants" + created_count = 0 + + participants.each do |participant_data| + # Find player by PUUID + player = organization.players.find_by(riot_puuid: participant_data[:puuid]) + + if player.nil? + Rails.logger.debug "Participant PUUID #{participant_data[:puuid][0..20]}... not found in organization" + next + end + + Rails.logger.info "Creating stat for player: #{player.summoner_name}" + + PlayerMatchStat.create!( + match: match, + player: player, + role: normalize_role(participant_data[:role]), + champion: participant_data[:champion_name], + kills: participant_data[:kills], + deaths: participant_data[:deaths], + assists: participant_data[:assists], + gold_earned: participant_data[:gold_earned], + damage_dealt_champions: participant_data[:total_damage_dealt], + damage_dealt_total: participant_data[:total_damage_dealt], + damage_taken: participant_data[:total_damage_taken], + cs: participant_data[:minions_killed].to_i + participant_data[:neutral_minions_killed].to_i, + 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], + 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) + ) + created_count += 1 + Rails.logger.info "Stat created successfully for #{player.summoner_name}" + end + + Rails.logger.info "Created #{created_count} player match stats" + end + + def determine_match_type(game_mode) + case game_mode.upcase + when 'CLASSIC' then 'official' + when 'ARAM' then 'scrim' + else 'scrim' + end + end + + def determine_team_victory(participants, organization) + # Find our players in the match + our_player_puuids = organization.players.pluck(:riot_puuid).compact + our_participants = participants.select { |p| our_player_puuids.include?(p[:puuid]) } + + return nil if our_participants.empty? + + # Check if our players won + our_participants.first[:win] + end + + def normalize_role(role) + role_mapping = { + 'top' => 'top', + 'jungle' => 'jungle', + 'middle' => 'mid', + 'mid' => 'mid', + 'bottom' => 'adc', + 'adc' => 'adc', + 'utility' => 'support', + 'support' => 'support' + } + + role_mapping[role&.downcase] || 'mid' + end + + def calculate_performance_score(participant_data) + # Simple performance score calculation + # This can be made more sophisticated + kda = calculate_kda( + kills: participant_data[:kills], + deaths: participant_data[:deaths], + assists: participant_data[:assists] + ) + + base_score = kda * 10 + damage_score = (participant_data[:total_damage_dealt] / 1000.0) + vision_score = participant_data[:vision_score] || 0 + + (base_score + (damage_score * 0.1) + vision_score).round(2) + end + + def calculate_kda(kills:, deaths:, assists:) + total = (kills + assists).to_f + return total if deaths.zero? + + total / deaths + end +end diff --git a/app/jobs/sync_player_from_riot_job.rb b/app/jobs/sync_player_from_riot_job.rb index 0a9253b..42071cb 100644 --- a/app/jobs/sync_player_from_riot_job.rb +++ b/app/jobs/sync_player_from_riot_job.rb @@ -1,77 +1,31 @@ +# frozen_string_literal: true + class SyncPlayerFromRiotJob < ApplicationJob queue_as :default def perform(player_id) player = Player.find(player_id) - # Check if player has necessary data - unless player.riot_puuid.present? || player.summoner_name.present? - player.update(sync_status: 'error', last_sync_at: Time.current) - Rails.logger.error "Player #{player_id} missing Riot info" - return - end + return mark_error(player, "Player #{player_id} missing Riot info") unless player.riot_puuid.present? || player.summoner_name.present? - # Get Riot API key riot_api_key = ENV['RIOT_API_KEY'] - unless riot_api_key.present? - player.update(sync_status: 'error', last_sync_at: Time.current) - Rails.logger.error "Riot API key not configured" - return - end + return mark_error(player, 'Riot API key not configured') unless riot_api_key.present? + + region = player.region.presence&.downcase || 'br1' begin - region = 'br1' # TODO: Make this configurable per player - - # Fetch summoner data - if player.riot_puuid.present? - summoner_data = fetch_summoner_by_puuid(player.riot_puuid, region, riot_api_key) - else - summoner_data = fetch_summoner_by_name(player.summoner_name, region, riot_api_key) - end - - # Fetch ranked stats - ranked_data = fetch_ranked_stats(summoner_data['id'], region, riot_api_key) - - # Update player data - update_data = { - riot_puuid: summoner_data['puuid'], - riot_summoner_id: summoner_data['id'], - summoner_level: summoner_data['summonerLevel'], - profile_icon_id: summoner_data['profileIconId'], - sync_status: 'success', - last_sync_at: Time.current - } - - # Update ranked stats if available - solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } - if solo_queue - update_data.merge!({ - solo_queue_tier: solo_queue['tier'], - solo_queue_rank: solo_queue['rank'], - solo_queue_lp: solo_queue['leaguePoints'], - solo_queue_wins: solo_queue['wins'], - solo_queue_losses: solo_queue['losses'] - }) - end - - flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } - if flex_queue - update_data.merge!({ - flex_queue_tier: flex_queue['tier'], - flex_queue_rank: flex_queue['rank'], - flex_queue_lp: flex_queue['leaguePoints'] - }) - end + summoner_data = fetch_summoner(player, region, riot_api_key) + ranked_data = fetch_ranked_stats_by_puuid(summoner_data['puuid'], region, riot_api_key) - player.update!(update_data) + update_data = build_update_data(summoner_data) + update_data.merge!(extract_queue_updates(ranked_data)) + player.update!(update_data) Rails.logger.info "Successfully synced player #{player_id} from Riot API" - rescue StandardError => e Rails.logger.error "Failed to sync player #{player_id}: #{e.message}" Rails.logger.error e.backtrace.join("\n") - - player.update(sync_status: 'error', last_sync_at: Time.current) + mark_error(player) end end @@ -84,7 +38,6 @@ def fetch_summoner_by_name(summoner_name, region, api_key) game_name, tag_line = summoner_name.split('#') tag_line ||= region.upcase - # Get PUUID from Riot ID account_url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{URI.encode_www_form_component(game_name)}/#{URI.encode_www_form_component(tag_line)}" account_uri = URI(account_url) account_request = Net::HTTP::Get.new(account_uri) @@ -117,9 +70,7 @@ def fetch_summoner_by_puuid(puuid, region, api_key) http.request(request) end - unless response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{response.code} - #{response.body}" - end + raise "Riot API Error: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body) end @@ -137,10 +88,72 @@ def fetch_ranked_stats(summoner_id, region, api_key) http.request(request) end - unless response.is_a?(Net::HTTPSuccess) - raise "Riot API Error: #{response.code} - #{response.body}" + raise "Riot API Error: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + + def fetch_ranked_stats_by_puuid(puuid, region, api_key) + require 'net/http' + require 'json' + + url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}" + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) end + raise "Riot API Error: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess) + JSON.parse(response.body) end end + def fetch_summoner(player, region, api_key) + return fetch_summoner_by_puuid(player.riot_puuid, region, api_key) if player.riot_puuid.present? + fetch_summoner_by_name(player.summoner_name, region, api_key) + end + + def build_update_data(summoner_data) + { + riot_puuid: summoner_data['puuid'], + riot_summoner_id: summoner_data['id'], + summoner_level: summoner_data['summonerLevel'], + profile_icon_id: summoner_data['profileIconId'], + sync_status: 'success', + last_sync_at: Time.current + } + end + + def extract_queue_updates(ranked_data) + updates = {} + + solo = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } + if solo + updates.merge!({ + solo_queue_tier: solo['tier'], + solo_queue_rank: solo['rank'], + solo_queue_lp: solo['leaguePoints'], + solo_queue_wins: solo['wins'], + solo_queue_losses: solo['losses'] + }) + end + + flex = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } + if flex + updates.merge!({ + flex_queue_tier: flex['tier'], + flex_queue_rank: flex['rank'], + flex_queue_lp: flex['leaguePoints'] + }) + end + + updates + end + + def mark_error(player, message = nil) + Rails.logger.error(message) if message + player.update(sync_status: 'error', last_sync_at: Time.current) + end diff --git a/app/jobs/sync_player_job.rb b/app/jobs/sync_player_job.rb index d374c3d..e52d101 100644 --- a/app/jobs/sync_player_job.rb +++ b/app/jobs/sync_player_job.rb @@ -1,159 +1,137 @@ -class SyncPlayerJob < ApplicationJob - queue_as :default - - retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 - retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 - - def perform(player_id, region = 'BR') - player = Player.find(player_id) - riot_service = RiotApiService.new - - # Skip if player doesn't have PUUID yet - if player.riot_puuid.blank? - sync_summoner_by_name(player, riot_service, region) - else - sync_summoner_by_puuid(player, riot_service, region) - end - - # Update rank information - sync_rank_info(player, riot_service, region) if player.riot_summoner_id.present? - - # Update champion mastery - sync_champion_mastery(player, riot_service, region) if player.riot_puuid.present? - - # Update last sync timestamp - player.update!(last_sync_at: Time.current) - - rescue RiotApiService::NotFoundError => e - Rails.logger.error("Player not found in Riot API: #{player.summoner_name} - #{e.message}") - rescue RiotApiService::UnauthorizedError => e - Rails.logger.error("Riot API authentication failed: #{e.message}") - rescue StandardError => e - Rails.logger.error("Failed to sync player #{player.id}: #{e.message}") - raise - end - - private - - def sync_summoner_by_name(player, riot_service, region) - summoner_data = riot_service.get_summoner_by_name( - summoner_name: player.summoner_name, - region: region - ) - - player.update!( - riot_puuid: summoner_data[:puuid], - riot_summoner_id: summoner_data[:summoner_id] - ) - end - - def sync_summoner_by_puuid(player, riot_service, region) - summoner_data = riot_service.get_summoner_by_puuid( - puuid: player.riot_puuid, - region: region - ) - - # Update summoner name if changed - if player.summoner_name != summoner_data[:summoner_name] - player.update!(summoner_name: summoner_data[:summoner_name]) - end - end - - def sync_rank_info(player, riot_service, region) - league_data = riot_service.get_league_entries( - summoner_id: player.riot_summoner_id, - region: region - ) - - update_attributes = {} - - # Solo Queue - if league_data[:solo_queue].present? - solo = league_data[:solo_queue] - update_attributes.merge!( - solo_queue_tier: solo[:tier], - solo_queue_rank: solo[:rank], - solo_queue_lp: solo[:lp], - solo_queue_wins: solo[:wins], - solo_queue_losses: solo[:losses] - ) - - # Update peak if current is higher - if should_update_peak?(player, solo[:tier], solo[:rank]) - update_attributes.merge!( - peak_tier: solo[:tier], - peak_rank: solo[:rank], - peak_season: current_season - ) - end - end - - # Flex Queue - if league_data[:flex_queue].present? - flex = league_data[:flex_queue] - update_attributes.merge!( - flex_queue_tier: flex[:tier], - flex_queue_rank: flex[:rank], - flex_queue_lp: flex[:lp] - ) - end - - player.update!(update_attributes) if update_attributes.present? - end - - def sync_champion_mastery(player, riot_service, region) - mastery_data = riot_service.get_champion_mastery( - puuid: player.riot_puuid, - region: region - ) - - # Get champion static data (you would need a champion ID to name mapping) - champion_id_map = load_champion_id_map - - mastery_data.take(20).each do |mastery| - champion_name = champion_id_map[mastery[:champion_id]] - next unless champion_name - - champion_pool = player.champion_pools.find_or_initialize_by(champion: champion_name) - champion_pool.update!( - mastery_level: mastery[:champion_level], - mastery_points: mastery[:champion_points], - last_played_at: mastery[:last_played] - ) - end - end - - def should_update_peak?(player, new_tier, new_rank) - return true if player.peak_tier.blank? - - tier_values = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER] - rank_values = %w[IV III II I] - - current_tier_index = tier_values.index(player.peak_tier&.upcase) || 0 - new_tier_index = tier_values.index(new_tier&.upcase) || 0 - - return true if new_tier_index > current_tier_index - return false if new_tier_index < current_tier_index - - # Same tier, compare ranks - current_rank_index = rank_values.index(player.peak_rank&.upcase) || 0 - new_rank_index = rank_values.index(new_rank&.upcase) || 0 - - new_rank_index > current_rank_index - end - - def current_season - # This should be dynamic based on Riot's current season - Time.current.year - 2010 # Season 1 was 2011 - end - - def load_champion_id_map - # This is a simplified version. In production, you would load this from Data Dragon - # or cache it in Redis - Rails.cache.fetch('riot:champion_id_map', expires_in: 1.week) do - # Fetch from Data Dragon API or use a static file - # For now, return an empty hash - {} - end - end -end +# frozen_string_literal: true + +class SyncPlayerJob < ApplicationJob + include RankComparison + + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(player_id, region = 'BR') + player = Player.find(player_id) + riot_service = RiotApiService.new + + # Skip if player doesn't have PUUID yet + if player.riot_puuid.blank? + sync_summoner_by_name(player, riot_service, region) + else + sync_summoner_by_puuid(player, riot_service, region) + end + + # Update rank information + sync_rank_info(player, riot_service, region) if player.riot_summoner_id.present? + + # Update champion mastery + sync_champion_mastery(player, riot_service, region) if player.riot_puuid.present? + + # Update last sync timestamp + player.update!(last_sync_at: Time.current) + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Player not found in Riot API: #{player.summoner_name} - #{e.message}") + rescue RiotApiService::UnauthorizedError => e + Rails.logger.error("Riot API authentication failed: #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync player #{player.id}: #{e.message}") + raise + end + + private + + def sync_summoner_by_name(player, riot_service, region) + summoner_data = riot_service.get_summoner_by_name( + summoner_name: player.summoner_name, + region: region + ) + + player.update!( + riot_puuid: summoner_data[:puuid], + riot_summoner_id: summoner_data[:summoner_id] + ) + end + + def sync_summoner_by_puuid(player, riot_service, region) + summoner_data = riot_service.get_summoner_by_puuid( + puuid: player.riot_puuid, + region: region + ) + + # Update summoner name if changed + return unless player.summoner_name != summoner_data[:summoner_name] + + player.update!(summoner_name: summoner_data[:summoner_name]) + end + + def sync_rank_info(player, riot_service, region) + league_data = riot_service.get_league_entries( + summoner_id: player.riot_summoner_id, + region: region + ) + + update_attributes = {} + + # Solo Queue + if league_data[:solo_queue].present? + solo = league_data[:solo_queue] + update_attributes.merge!( + solo_queue_tier: solo[:tier], + solo_queue_rank: solo[:rank], + solo_queue_lp: solo[:lp], + solo_queue_wins: solo[:wins], + solo_queue_losses: solo[:losses] + ) + + # Update peak if current is higher + if should_update_peak?(player, solo[:tier], solo[:rank]) + update_attributes.merge!( + peak_tier: solo[:tier], + peak_rank: solo[:rank], + peak_season: current_season + ) + end + end + + # Flex Queue + if league_data[:flex_queue].present? + flex = league_data[:flex_queue] + update_attributes.merge!( + flex_queue_tier: flex[:tier], + flex_queue_rank: flex[:rank], + flex_queue_lp: flex[:lp] + ) + end + + player.update!(update_attributes) if update_attributes.present? + end + + def sync_champion_mastery(player, riot_service, region) + mastery_data = riot_service.get_champion_mastery( + puuid: player.riot_puuid, + region: region + ) + + # Get champion static data (you would need a champion ID to name mapping) + champion_id_map = load_champion_id_map + + mastery_data.take(20).each do |mastery| + champion_name = champion_id_map[mastery[:champion_id]] + next unless champion_name + + champion_pool = player.champion_pools.find_or_initialize_by(champion: champion_name) + champion_pool.update!( + mastery_level: mastery[:champion_level], + mastery_points: mastery[:champion_points], + last_played_at: mastery[:last_played] + ) + end + end + + def current_season + # This should be dynamic based on Riot's current season + Time.current.year - 2010 # Season 1 was 2011 + end + + def load_champion_id_map + DataDragonService.new.champion_id_map + end +end diff --git a/app/jobs/sync_scouting_target_job.rb b/app/jobs/sync_scouting_target_job.rb index 8341106..f809463 100644 --- a/app/jobs/sync_scouting_target_job.rb +++ b/app/jobs/sync_scouting_target_job.rb @@ -1,115 +1,97 @@ -class SyncScoutingTargetJob < ApplicationJob - queue_as :default - - retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 - retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 - - def perform(scouting_target_id) - target = ScoutingTarget.find(scouting_target_id) - riot_service = RiotApiService.new - - # Get summoner info - if target.riot_puuid.blank? - summoner_data = riot_service.get_summoner_by_name( - summoner_name: target.summoner_name, - region: target.region - ) - - target.update!( - riot_puuid: summoner_data[:puuid], - riot_summoner_id: summoner_data[:summoner_id] - ) - end - - # Get rank information - if target.riot_summoner_id.present? - league_data = riot_service.get_league_entries( - summoner_id: target.riot_summoner_id, - region: target.region - ) - - update_rank_info(target, league_data) - end - - # Get champion mastery for champion pool - if target.riot_puuid.present? - mastery_data = riot_service.get_champion_mastery( - puuid: target.riot_puuid, - region: target.region - ) - - update_champion_pool(target, mastery_data) - end - - # Update last sync - target.update!(last_sync_at: Time.current) - - Rails.logger.info("Successfully synced scouting target #{target.id}") - - rescue RiotApiService::NotFoundError => e - Rails.logger.error("Scouting target not found in Riot API: #{target.summoner_name} - #{e.message}") - rescue StandardError => e - Rails.logger.error("Failed to sync scouting target #{target.id}: #{e.message}") - raise - end - - private - - def update_rank_info(target, league_data) - update_attributes = {} - - if league_data[:solo_queue].present? - solo = league_data[:solo_queue] - update_attributes.merge!( - current_tier: solo[:tier], - current_rank: solo[:rank], - current_lp: solo[:lp] - ) - - # Update peak if current is higher - if should_update_peak?(target, solo[:tier], solo[:rank]) - update_attributes.merge!( - peak_tier: solo[:tier], - peak_rank: solo[:rank] - ) - end - end - - target.update!(update_attributes) if update_attributes.present? - end - - def update_champion_pool(target, mastery_data) - # Get top 10 champions - champion_id_map = load_champion_id_map - champion_names = mastery_data.take(10).map do |mastery| - champion_id_map[mastery[:champion_id]] - end.compact - - target.update!(champion_pool: champion_names) - end - - def should_update_peak?(target, new_tier, new_rank) - return true if target.peak_tier.blank? - - tier_values = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER] - rank_values = %w[IV III II I] - - current_tier_index = tier_values.index(target.peak_tier&.upcase) || 0 - new_tier_index = tier_values.index(new_tier&.upcase) || 0 - - return true if new_tier_index > current_tier_index - return false if new_tier_index < current_tier_index - - # Same tier, compare ranks - current_rank_index = rank_values.index(target.peak_rank&.upcase) || 0 - new_rank_index = rank_values.index(new_rank&.upcase) || 0 - - new_rank_index > current_rank_index - end - - def load_champion_id_map - Rails.cache.fetch('riot:champion_id_map', expires_in: 1.week) do - {} - end - end -end +# frozen_string_literal: true + +class SyncScoutingTargetJob < ApplicationJob + include RankComparison + + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(scouting_target_id) + target = ScoutingTarget.find(scouting_target_id) + riot_service = RiotApiService.new + + # Get summoner info + if target.riot_puuid.blank? + summoner_data = riot_service.get_summoner_by_name( + summoner_name: target.summoner_name, + region: target.region + ) + + target.update!( + riot_puuid: summoner_data[:puuid], + riot_summoner_id: summoner_data[:summoner_id] + ) + end + + # Get rank information + if target.riot_summoner_id.present? + league_data = riot_service.get_league_entries( + summoner_id: target.riot_summoner_id, + region: target.region + ) + + update_rank_info(target, league_data) + end + + # Get champion mastery for champion pool + if target.riot_puuid.present? + mastery_data = riot_service.get_champion_mastery( + puuid: target.riot_puuid, + region: target.region + ) + + update_champion_pool(target, mastery_data) + end + + # Update last sync + target.update!(last_sync_at: Time.current) + + Rails.logger.info("Successfully synced scouting target #{target.id}") + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Scouting target not found in Riot API: #{target.summoner_name} - #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync scouting target #{target.id}: #{e.message}") + raise + end + + private + + def update_rank_info(target, league_data) + update_attributes = {} + + if league_data[:solo_queue].present? + solo = league_data[:solo_queue] + update_attributes.merge!( + current_tier: solo[:tier], + current_rank: solo[:rank], + current_lp: solo[:lp] + ) + + # Update peak if current is higher + if should_update_peak?(target, solo[:tier], solo[:rank]) + update_attributes.merge!( + peak_tier: solo[:tier], + peak_rank: solo[:rank] + ) + end + end + + target.update!(update_attributes) if update_attributes.present? + end + + def update_champion_pool(target, mastery_data) + # Get top 10 champions + champion_id_map = load_champion_id_map + champion_names = mastery_data.take(10).map do |mastery| + champion_id_map[mastery[:champion_id]] + end.compact + + target.update!(champion_pool: champion_names) + end + + def load_champion_id_map + DataDragonService.new.champion_id_map + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..9ed2213 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ApplicationMailer < ActionMailer::Base + default from: ENV.fetch('MAILER_FROM_EMAIL', 'noreply@prostaff.gg') + layout 'mailer' +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..663abca --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class UserMailer < ApplicationMailer + def password_reset(user, reset_token) + @user = user + @reset_token = reset_token + @reset_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/reset-password?token=#{reset_token.token}" + @expires_in = ((reset_token.expires_at - Time.current) / 60).to_i # minutes + + mail( + to: @user.email, + subject: 'Password Reset Request - ProStaff' + ) + end + + def password_reset_confirmation(user) + @user = user + + mail( + to: @user.email, + subject: 'Password Successfully Reset - ProStaff' + ) + end + + def welcome(user) + @user = user + + mail( + to: @user.email, + subject: 'Welcome to ProStaff!' + ) + end +end diff --git a/app/middlewares/jwt_authentication.rb b/app/middlewares/jwt_authentication.rb index 7a09552..73ca7c9 100644 --- a/app/middlewares/jwt_authentication.rb +++ b/app/middlewares/jwt_authentication.rb @@ -1,70 +1,86 @@ -class JwtAuthentication - def initialize(app) - @app = app - end - - def call(env) - begin - authenticate_request(env) - rescue Authentication::Services::JwtService::AuthenticationError => e - return unauthorized_response(e.message) - end - - @app.call(env) - end - - private - - def authenticate_request(env) - request = Rack::Request.new(env) - - # Skip authentication for certain paths - return if skip_authentication?(request.path_info) - - token = extract_token(request) - return if token.nil? # Let controller handle missing token - - # Decode and verify token - payload = Authentication::Services::JwtService.decode(token) - user = User.find(payload[:user_id]) - - # Store user info in environment for controllers - env['rack.jwt.payload'] = payload - env['current_user'] = user - env['current_organization'] = user.organization - - rescue ActiveRecord::RecordNotFound - raise Authentication::Services::JwtService::AuthenticationError, 'User not found' - end - - def extract_token(request) - # Check Authorization header - auth_header = request.get_header('HTTP_AUTHORIZATION') - return nil unless auth_header - - # Extract Bearer token - match = auth_header.match(/Bearer\s+(.+)/i) - match&.[](1) - end - - def skip_authentication?(path) - # Paths that don't require authentication - skip_paths = [ - '/api/v1/auth/login', - '/api/v1/auth/register', - '/api/v1/auth/forgot-password', - '/api/v1/auth/reset-password', - '/up', # Health check - ] - - skip_paths.any? { |skip_path| path.start_with?(skip_path) } - end - - def unauthorized_response(message = 'Unauthorized') - [ - 401, - { 'Content-Type' => 'application/json' }, - [{ error: { code: 'UNAUTHORIZED', message: message } }.to_json] - ] - end -end \ No newline at end of file +# frozen_string_literal: true + +# JWT Authentication Middleware +# +# Rack middleware that validates JWT tokens for API authentication. +# Extracts Bearer tokens from Authorization headers, decodes them, and sets current_user +# and current_organization in the Rack environment for downstream controllers. +# +# @example Authorization Header +# Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... +# +# Features: +# - Automatic token extraction from Authorization header +# - JWT decoding and validation via JwtService +# - User and organization injection into request environment +# - Configurable path-based authentication skipping (login, register, public endpoints) +# - Returns 401 Unauthorized for invalid/missing tokens on protected routes +class JwtAuthentication + def initialize(app) + @app = app + end + + def call(env) + begin + authenticate_request(env) + rescue Authentication::Services::JwtService::AuthenticationError => e + return unauthorized_response(e.message) + end + + @app.call(env) + end + + private + + def authenticate_request(env) + request = Rack::Request.new(env) + + # Skip authentication for certain paths + return if skip_authentication?(request.path_info) + + token = extract_token(request) + return if token.nil? # Let controller handle missing token + + # Decode and verify token + payload = Authentication::Services::JwtService.decode(token) + user = User.find(payload[:user_id]) + + # Store user info in environment for controllers + env['rack.jwt.payload'] = payload + env['current_user'] = user + env['current_organization'] = user.organization + rescue ActiveRecord::RecordNotFound + raise Authentication::Services::JwtService::AuthenticationError, 'User not found' + end + + def extract_token(request) + # Check Authorization header + auth_header = request.get_header('HTTP_AUTHORIZATION') + return nil unless auth_header + + # Extract Bearer token + match = auth_header.match(/Bearer\s+(.+)/i) + match&.[](1) + end + + def skip_authentication?(path) + # Paths that don't require authentication + skip_paths = [ + '/api/v1/auth/login', + '/api/v1/auth/register', + '/api/v1/auth/forgot-password', + '/api/v1/auth/reset-password', + '/up' # Health check + ] + + skip_paths.any? { |skip_path| path.start_with?(skip_path) } + end + + def unauthorized_response(message = 'Unauthorized') + [ + 401, + { 'Content-Type' => 'application/json' }, + [{ error: { code: 'UNAUTHORIZED', message: message } }.to_json] + ] + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 1eb6ae6..2d99dac 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,16 @@ -class ApplicationRecord < ActiveRecord::Base - primary_abstract_class -end +# frozen_string_literal: true + +# Abstract base class for all application models +# +# This class serves as the primary abstract class for all models in the application, +# inheriting from ActiveRecord::Base. All application models should inherit from this +# class rather than directly from ActiveRecord::Base. +# +# @abstract Subclass and add model-specific behavior +# @example Define a new model +# class MyModel < ApplicationRecord +# # model code here +# end +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index 4f2c6af..7f8c133 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -1,153 +1,181 @@ -class AuditLog < ApplicationRecord - # Associations - belongs_to :organization - belongs_to :user, optional: true - - # Validations - validates :action, presence: true - validates :entity_type, presence: true - - # Scopes - scope :by_action, ->(action) { where(action: action) } - scope :by_entity_type, ->(type) { where(entity_type: type) } - scope :by_user, ->(user_id) { where(user_id: user_id) } - scope :recent, ->(days = 30) { where(created_at: days.days.ago..Time.current) } - scope :for_entity, ->(type, id) { where(entity_type: type, entity_id: id) } - - # Instance methods - def action_display - action.humanize - end - - def user_display - user&.full_name || user&.email || 'System' - end - - def entity_display - "#{entity_type} ##{entity_id}" - end - - def changes_summary - return 'Created' if action == 'create' - return 'Deleted' if action == 'delete' - - return 'No changes recorded' if old_values.blank? && new_values.blank? - - changes = [] - - if old_values.present? && new_values.present? - new_values.each do |key, new_val| - old_val = old_values[key] - next if old_val == new_val - - changes << "#{key.humanize}: #{format_value(old_val)} → #{format_value(new_val)}" - end - end - - changes.empty? ? 'No changes recorded' : changes.join(', ') - end - - def ip_location - # This could be enhanced with a GeoIP service - return 'Unknown' if ip_address.blank? - - if ip_address.to_s.start_with?('127.0.0.1', '::1') - 'Local' - elsif ip_address.to_s.start_with?('192.168.', '10.', '172.') - 'Private Network' - else - 'External' - end - end - - def browser_info - return 'Unknown' if user_agent.blank? - - # Simple browser detection - case user_agent - when /Chrome/i then 'Chrome' - when /Firefox/i then 'Firefox' - when /Safari/i then 'Safari' - when /Edge/i then 'Edge' - when /Opera/i then 'Opera' - else 'Unknown Browser' - end - end - - def time_ago - time_diff = Time.current - created_at - - case time_diff - when 0...60 - "#{time_diff.to_i} seconds ago" - when 60...3600 - "#{(time_diff / 60).to_i} minutes ago" - when 3600...86400 - "#{(time_diff / 3600).to_i} hours ago" - when 86400...2592000 - "#{(time_diff / 86400).to_i} days ago" - else - created_at.strftime('%B %d, %Y') - end - end - - def risk_level - case action - when 'delete' then 'high' - when 'update' then 'medium' - when 'create' then 'low' - when 'login', 'logout' then 'info' - else 'medium' - end - end - - def risk_color - case risk_level - when 'high' then 'red' - when 'medium' then 'orange' - when 'low' then 'green' - when 'info' then 'blue' - else 'gray' - end - end - - def self.log_action(organization:, user: nil, action:, entity_type:, entity_id: nil, old_values: {}, new_values: {}, ip: nil, user_agent: nil) - create!( - organization: organization, - user: user, - action: action, - entity_type: entity_type, - entity_id: entity_id, - old_values: old_values, - new_values: new_values, - ip_address: ip, - user_agent: user_agent - ) - end - - def self.security_events - where(action: %w[login logout failed_login password_reset]) - end - - def self.data_changes - where(action: %w[create update delete]) - end - - def self.high_risk_actions - where(action: %w[delete user_role_change organization_settings_change]) - end - - private - - def format_value(value) - case value - when nil then 'nil' - when true then 'true' - when false then 'false' - when String - value.length > 50 ? "#{value[0..47]}..." : value - else - value.to_s - end - end -end \ No newline at end of file +# frozen_string_literal: true + +# Model representing audit trail logs for tracking system activities +# +# This model stores comprehensive audit logs of user actions and system events within +# an organization. It tracks CRUD operations, security events, and data changes with +# detailed metadata including IP addresses, user agents, and value changes. +# +# Associated with: +# - Organization: The organization where the action occurred +# - User: The user who performed the action (optional for system actions) +# +# @example Log a user action +# AuditLog.log_action( +# organization: org, +# user: current_user, +# action: 'update', +# entity_type: 'Player', +# entity_id: player.id, +# old_values: { status: 'active' }, +# new_values: { status: 'inactive' }, +# ip: request.remote_ip, +# user_agent: request.user_agent +# ) +# +# @example Query security events +# AuditLog.security_events.recent(7) +# +# @example Find high-risk actions +# AuditLog.high_risk_actions.by_user(user.id) +class AuditLog < ApplicationRecord + # Associations + belongs_to :organization + belongs_to :user, optional: true + + # Validations + validates :action, presence: true + validates :entity_type, presence: true + + # Scopes + scope :by_action, ->(action) { where(action: action) } + scope :by_entity_type, ->(type) { where(entity_type: type) } + scope :by_user, ->(user_id) { where(user_id: user_id) } + scope :recent, ->(days = 30) { where(created_at: days.days.ago..Time.current) } + scope :for_entity, ->(type, id) { where(entity_type: type, entity_id: id) } + + # Instance methods + def action_display + action.humanize + end + + def user_display + user&.full_name || user&.email || 'System' + end + + def entity_display + "#{entity_type} ##{entity_id}" + end + + def changes_summary + return 'Created' if action == 'create' + return 'Deleted' if action == 'delete' + + return 'No changes recorded' if old_values.blank? && new_values.blank? + + changes = [] + + if old_values.present? && new_values.present? + new_values.each do |key, new_val| + old_val = old_values[key] + next if old_val == new_val + + changes << "#{key.humanize}: #{format_value(old_val)} → #{format_value(new_val)}" + end + end + + changes.empty? ? 'No changes recorded' : changes.join(', ') + end + + def ip_location + # This could be enhanced with a GeoIP service + return 'Unknown' if ip_address.blank? + + if ip_address.to_s.start_with?('127.0.0.1', '::1') + 'Local' + elsif ip_address.to_s.start_with?('192.168.', '10.', '172.') + 'Private Network' + else + 'External' + end + end + + def browser_info + return 'Unknown' if user_agent.blank? + + # Simple browser detection + case user_agent + when /Chrome/i then 'Chrome' + when /Firefox/i then 'Firefox' + when /Safari/i then 'Safari' + when /Edge/i then 'Edge' + when /Opera/i then 'Opera' + else 'Unknown Browser' + end + end + + def time_ago + time_diff = Time.current - created_at + + case time_diff + when 0...60 + "#{time_diff.to_i} seconds ago" + when 60...3600 + "#{(time_diff / 60).to_i} minutes ago" + when 3600...86_400 + "#{(time_diff / 3600).to_i} hours ago" + when 86_400...2_592_000 + "#{(time_diff / 86_400).to_i} days ago" + else + created_at.strftime('%B %d, %Y') + end + end + + def risk_level + return 'high' if action == 'delete' + return 'low' if action == 'create' + return 'info' if %w[login logout].include?(action) + 'medium' + end + + def risk_color + case risk_level + when 'high' then 'red' + when 'medium' then 'orange' + when 'low' then 'green' + when 'info' then 'blue' + else 'gray' + end + end + + def self.log_action(organization:, action:, entity_type:, user: nil, entity_id: nil, old_values: {}, new_values: {}, + ip: nil, user_agent: nil) + create!( + organization: organization, + user: user, + action: action, + entity_type: entity_type, + entity_id: entity_id, + old_values: old_values, + new_values: new_values, + ip_address: ip, + user_agent: user_agent + ) + end + + def self.security_events + where(action: %w[login logout failed_login password_reset]) + end + + def self.data_changes + where(action: %w[create update delete]) + end + + def self.high_risk_actions + where(action: %w[delete user_role_change organization_settings_change]) + end + + private + + def format_value(value) + case value + when nil then 'nil' + when true then 'true' + when false then 'false' + when String + value.length > 50 ? "#{value[0..47]}..." : value + else + value.to_s + end + end +end diff --git a/app/models/champion_pool.rb b/app/models/champion_pool.rb index 1b13a7b..6870114 100644 --- a/app/models/champion_pool.rb +++ b/app/models/champion_pool.rb @@ -1,118 +1,150 @@ -class ChampionPool < ApplicationRecord - # Associations - belongs_to :player - - # Validations - validates :champion, presence: true - validates :player_id, uniqueness: { scope: :champion } - validates :games_played, :games_won, numericality: { greater_than_or_equal_to: 0 } - validates :mastery_level, inclusion: { in: 1..7 } - validates :priority, inclusion: { in: 1..10 } - - # Scopes - scope :comfort_picks, -> { where(is_comfort_pick: true) } - scope :pocket_picks, -> { where(is_pocket_pick: true) } - scope :learning, -> { where(is_learning: true) } - scope :by_priority, ->(priority) { where(priority: priority) } - scope :high_priority, -> { where(priority: 8..10) } - scope :medium_priority, -> { where(priority: 4..7) } - scope :low_priority, -> { where(priority: 1..3) } - scope :most_played, -> { order(games_played: :desc) } - scope :best_performance, -> { order(average_kda: :desc) } - - # Instance methods - def win_rate - return 0 if games_played.zero? - - (games_won.to_f / games_played * 100).round(1) - end - - def win_rate_display - "#{win_rate}%" - end - - def games_lost - games_played - games_won - end - - def performance_tier - return 'Learning' if is_learning? - return 'Pocket Pick' if is_pocket_pick? - return 'Comfort Pick' if is_comfort_pick? - - case win_rate - when 0...40 then 'Needs Practice' - when 40...60 then 'Decent' - when 60...75 then 'Good' - when 75...90 then 'Excellent' - else 'Master' - end - end - - def mastery_display - case mastery_level - when 1..4 then "Mastery #{mastery_level}" - when 5 then "Mastery 5" - when 6 then "Mastery 6" - when 7 then "Mastery 7" - end - end - - def priority_label - case priority - when 9..10 then 'Must Ban' - when 7..8 then 'High Priority' - when 4..6 then 'Medium Priority' - when 2..3 then 'Low Priority' - when 1 then 'Situational' - end - end - - def recently_played? - last_played.present? && last_played >= 2.weeks.ago - end - - def needs_practice? - games_played < 5 || win_rate < 50 || !recently_played? - end - - def champion_role - # This could be enhanced with actual champion data - # For now, return the player's main role - player.role - end - - def update_stats!(new_game_won:, new_kda: nil, new_cs_per_min: nil, new_damage_share: nil) - self.games_played += 1 - self.games_won += 1 if new_game_won - self.last_played = Time.current - - # Update averages if new stats provided - if new_kda - current_total_kda = (average_kda || 0) * (games_played - 1) - self.average_kda = (current_total_kda + new_kda) / games_played - end - - if new_cs_per_min - current_total_cs = (average_cs_per_min || 0) * (games_played - 1) - self.average_cs_per_min = (current_total_cs + new_cs_per_min) / games_played - end - - if new_damage_share - current_total_damage = (average_damage_share || 0) * (games_played - 1) - self.average_damage_share = (current_total_damage + new_damage_share) / games_played - end - - save! - end - - def self.top_champions_for_role(role, limit: 10) - joins(:player) - .where(players: { role: role }) - .group(:champion) - .average(:average_kda) - .sort_by { |_, kda| -kda } - .first(limit) - .map(&:first) - end -end \ No newline at end of file +# frozen_string_literal: true + +# Model representing a player's champion pool and performance statistics +# +# This model tracks individual champion performance data for a player, including +# games played, win rates, mastery levels, and priority ratings. It stores comprehensive +# statistics like KDA, CS per minute, and damage share to evaluate champion proficiency. +# +# Associated with: +# - Player: The player who owns this champion in their pool +# +# @example Create a champion pool entry +# pool = ChampionPool.create( +# player: player, +# champion: 'Ahri', +# mastery_level: 7, +# priority: 9, +# is_comfort_pick: true +# ) +# +# @example Find high priority champions +# ChampionPool.high_priority.comfort_picks +# +# @example Update champion stats after a game +# pool.update_stats!( +# new_game_won: true, +# new_kda: 5.2, +# new_cs_per_min: 8.5 +# ) +class ChampionPool < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :player + + # Validations + validates :champion, presence: true + validates :player_id, uniqueness: { scope: :champion } + validates :games_played, :games_won, numericality: { greater_than_or_equal_to: 0 } + validates :mastery_level, inclusion: { in: Constants::ChampionPool::MASTERY_LEVELS } + validates :priority, inclusion: { in: Constants::ChampionPool::PRIORITY_LEVELS } + + # Scopes + scope :comfort_picks, -> { where(is_comfort_pick: true) } + scope :pocket_picks, -> { where(is_pocket_pick: true) } + scope :learning, -> { where(is_learning: true) } + scope :by_priority, ->(priority) { where(priority: priority) } + scope :high_priority, -> { where(priority: 8..10) } + scope :medium_priority, -> { where(priority: 4..7) } + scope :low_priority, -> { where(priority: 1..3) } + scope :most_played, -> { order(games_played: :desc) } + scope :best_performance, -> { order(average_kda: :desc) } + + # Instance methods + def win_rate + return 0 if games_played.zero? + + (games_won.to_f / games_played * 100).round(1) + end + + def win_rate_display + "#{win_rate}%" + end + + def games_lost + games_played - games_won + end + + def performance_tier + return 'Learning' if is_learning? + return 'Pocket Pick' if is_pocket_pick? + return 'Comfort Pick' if is_comfort_pick? + + case win_rate + when 0...40 then 'Needs Practice' + when 40...60 then 'Decent' + when 60...75 then 'Good' + when 75...90 then 'Excellent' + else 'Master' + end + end + + def mastery_display + case mastery_level + when 1..4 then "Mastery #{mastery_level}" + when 5 then 'Mastery 5' + when 6 then 'Mastery 6' + when 7 then 'Mastery 7' + end + end + + def priority_label + case priority + when 9..10 then 'Must Ban' + when 7..8 then 'High Priority' + when 4..6 then 'Medium Priority' + when 2..3 then 'Low Priority' + when 1 then 'Situational' + end + end + + def recently_played? + last_played.present? && last_played >= 2.weeks.ago + end + + def needs_practice? + games_played < 5 || win_rate < 50 || !recently_played? + end + + def champion_role + # This could be enhanced with actual champion data + # For now, return the player's main role + player.role + end + + def update_stats!(new_game_won:, new_kda: nil, new_cs_per_min: nil, new_damage_share: nil) + self.games_played += 1 + self.games_won += 1 if new_game_won + self.last_played = Time.current + + # Update averages if new stats provided + if new_kda + current_total_kda = (average_kda || 0) * (games_played - 1) + self.average_kda = (current_total_kda + new_kda) / games_played + end + + if new_cs_per_min + current_total_cs = (average_cs_per_min || 0) * (games_played - 1) + self.average_cs_per_min = (current_total_cs + new_cs_per_min) / games_played + end + + if new_damage_share + current_total_damage = (average_damage_share || 0) * (games_played - 1) + self.average_damage_share = (current_total_damage + new_damage_share) / games_played + end + + save! + end + + def self.top_champions_for_role(role, limit: 10) + joins(:player) + .where(players: { role: role }) + .group(:champion) + .average(:average_kda) + .sort_by { |_, kda| -kda } + .first(limit) + .map(&:first) + end +end diff --git a/app/models/competitive_match.rb b/app/models/competitive_match.rb new file mode 100644 index 0000000..360d020 --- /dev/null +++ b/app/models/competitive_match.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +# Model representing competitive tournament match records +# +# This model stores detailed information about competitive matches in official tournaments, +# including draft phase data (picks and bans), game metadata, tournament context, and match +# outcomes. It tracks both team compositions and strategic decisions made during the draft. +# +# Associated with: +# - Organization: The organization participating in the match +# - OpponentTeam: The opposing team (optional) +# - Match: Link to internal match record if available (optional) +# +# @example Create a competitive match record +# match = CompetitiveMatch.create( +# organization: org, +# tournament_name: 'Spring Split 2025', +# tournament_stage: 'Semifinals', +# match_format: 'Bo5', +# game_number: 1, +# side: 'blue', +# victory: true +# ) +# +# @example Query recent victories +# CompetitiveMatch.victories.recent(30) +# +# @example Analyze draft phase +# match.draft_summary +# match.our_composition +class CompetitiveMatch < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + belongs_to :opponent_team, optional: true + belongs_to :match, optional: true # Link to internal match record if available + + # Validations + validates :tournament_name, presence: true + validates :external_match_id, uniqueness: true, allow_blank: true + + validates :match_format, inclusion: { + in: Constants::CompetitiveMatch::FORMATS, + message: '%{value} is not a valid match format' + }, allow_blank: true + + validates :side, inclusion: { + in: Constants::CompetitiveMatch::SIDES, + message: '%{value} is not a valid side' + }, allow_blank: true + + validates :game_number, numericality: { + greater_than: 0, less_than_or_equal_to: 5 + }, allow_nil: true + + # Callbacks + after_create :log_competitive_match_created + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_tournament, ->(tournament) { where(tournament_name: tournament) } + scope :by_region, ->(region) { where(tournament_region: region) } + scope :by_stage, ->(stage) { where(tournament_stage: stage) } + scope :by_patch, ->(patch) { where(patch_version: patch) } + scope :victories, -> { where(victory: true) } + scope :defeats, -> { where(victory: false) } + scope :recent, ->(days = 30) { where('match_date > ?', days.days.ago).order(match_date: :desc) } + scope :blue_side, -> { where(side: 'blue') } + scope :red_side, -> { where(side: 'red') } + scope :in_date_range, ->(start_date, end_date) { where(match_date: start_date..end_date) } + scope :ordered_by_date, -> { order(match_date: :desc) } + + # Instance methods + def result_text + return 'Unknown' unless victory + + victory? ? 'Victory' : 'Defeat' + end + + def tournament_display + if tournament_stage.present? + "#{tournament_name} - #{tournament_stage}" + else + tournament_name + end + end + + def game_label + if game_number.present? && match_format.present? + "#{match_format} - Game #{game_number}" + elsif match_format.present? + match_format + else + 'Competitive Match' + end + end + + def draft_summary + { + our_bans: our_bans.presence || [], + opponent_bans: opponent_bans.presence || [], + our_picks: our_picks.presence || [], + opponent_picks: opponent_picks.presence || [], + side: side + } + end + + def our_composition + our_picks.presence || [] + end + + def opponent_composition + opponent_picks.presence || [] + end + + def total_bans + (our_bans.size + opponent_bans.size) + end + + def total_picks + (our_picks.size + opponent_picks.size) + end + + def has_complete_draft? + our_picks.size == 5 && opponent_picks.size == 5 + end + + def our_banned_champions + our_bans.map { |ban| ban['champion'] }.compact + end + + def opponent_banned_champions + opponent_bans.map { |ban| ban['champion'] }.compact + end + + def our_picked_champions + our_picks.map { |pick| pick['champion'] }.compact + end + + def opponent_picked_champions + opponent_picks.map { |pick| pick['champion'] }.compact + end + + def champion_picked_by_role(role, team: 'ours') + picks = team == 'ours' ? our_picks : opponent_picks + pick = picks.find { |p| p['role']&.downcase == role.downcase } + pick&.dig('champion') + end + + def meta_relevant? + return false if meta_champions.blank? + + our_champions = our_picked_champions + meta_count = (our_champions & meta_champions).size + + meta_count >= 2 # At least 2 meta champions + end + + def is_current_patch?(current_patch = nil) + return false if patch_version.blank? + return true unless current_patch + + patch_version == current_patch + end + + # Analysis methods + def draft_phase_sequence + # Returns the complete draft sequence combining bans and picks + sequence = [] + + # Combine bans and picks with timestamps/order if available + our_bans.each do |ban| + sequence << { team: 'ours', type: 'ban', **ban.symbolize_keys } + end + + opponent_bans.each do |ban| + sequence << { team: 'opponent', type: 'ban', **ban.symbolize_keys } + end + + our_picks.each do |pick| + sequence << { team: 'ours', type: 'pick', **pick.symbolize_keys } + end + + opponent_picks.each do |pick| + sequence << { team: 'opponent', type: 'pick', **pick.symbolize_keys } + end + + # Sort by order if available + sequence.sort_by { |item| item[:order] || 0 } + end + + def first_rotation_bans + our_bans.select { |ban| (ban['order'] || 0) <= 3 } + end + + def second_rotation_bans + our_bans.select { |ban| (ban['order'] || 0) > 3 } + end + + private + + def log_competitive_match_created + AuditLog.create!( + organization: organization, + action: 'create', + entity_type: 'CompetitiveMatch', + entity_id: id, + new_values: attributes + ) + rescue StandardError => e + Rails.logger.error("Failed to create audit log for competitive match #{id}: #{e.message}") + end + + def log_audit_trail + AuditLog.create!( + organization: organization, + action: 'update', + entity_type: 'CompetitiveMatch', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + rescue StandardError => e + Rails.logger.error("Failed to update audit log for competitive match #{id}: #{e.message}") + end +end diff --git a/app/models/concerns/constants.rb b/app/models/concerns/constants.rb new file mode 100644 index 0000000..7a7d6eb --- /dev/null +++ b/app/models/concerns/constants.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +module Constants + # League of Legends regions + REGIONS = %w[BR NA EUW EUNE KR JP OCE LAN LAS RU TR].freeze + + # Organization related constants + module Organization + TIERS = %w[tier_3_amateur tier_2_semi_pro tier_1_professional].freeze + SUBSCRIPTION_PLANS = %w[free amateur semi_pro professional enterprise].freeze + SUBSCRIPTION_STATUSES = %w[active inactive trial expired].freeze + + TIER_NAMES = { + 'tier_3_amateur' => 'Amateur', + 'tier_2_semi_pro' => 'Semi-Pro', + 'tier_1_professional' => 'Professional' + }.freeze + + SUBSCRIPTION_PLAN_NAMES = { + 'free' => 'Free', + 'amateur' => 'Amateur', + 'semi_pro' => 'Semi-Pro', + 'professional' => 'Professional', + 'enterprise' => 'Enterprise' + }.freeze + end + + # User roles + module User + ROLES = %w[owner admin coach analyst viewer].freeze + + ROLE_NAMES = { + 'owner' => 'Owner', + 'admin' => 'Administrator', + 'coach' => 'Coach', + 'analyst' => 'Analyst', + 'viewer' => 'Viewer' + }.freeze + end + + # Player related constants + module Player + ROLES = %w[top jungle mid adc support].freeze + STATUSES = %w[active inactive benched trial].freeze + QUEUE_RANKS = %w[I II III IV].freeze + QUEUE_TIERS = %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER].freeze + + ROLE_NAMES = { + 'top' => 'Top', + 'jungle' => 'Jungle', + 'mid' => 'Mid', + 'adc' => 'ADC', + 'support' => 'Support' + }.freeze + + STATUS_NAMES = { + 'active' => 'Active', + 'inactive' => 'Inactive', + 'benched' => 'Benched', + 'trial' => 'Trial' + }.freeze + end + + # Match related constants + module Match + TYPES = %w[official scrim tournament].freeze + SIDES = %w[blue red].freeze + + TYPE_NAMES = { + 'official' => 'Official Match', + 'scrim' => 'Scrim', + 'tournament' => 'Tournament' + }.freeze + + SIDE_NAMES = { + 'blue' => 'Blue Side', + 'red' => 'Red Side' + }.freeze + end + + # Scrim related constants + module Scrim + TYPES = %w[practice vod_review tournament_prep].freeze + FOCUS_AREAS = %w[draft macro teamfight laning objectives vision communication].freeze + VISIBILITY_LEVELS = %w[internal_only coaching_staff full_team].freeze + + TYPE_NAMES = { + 'practice' => 'Practice', + 'vod_review' => 'VOD Review', + 'tournament_prep' => 'Tournament Preparation' + }.freeze + + FOCUS_AREA_NAMES = { + 'draft' => 'Draft', + 'macro' => 'Macro Play', + 'teamfight' => 'Team Fighting', + 'laning' => 'Laning Phase', + 'objectives' => 'Objective Control', + 'vision' => 'Vision Control', + 'communication' => 'Communication' + }.freeze + + VISIBILITY_NAMES = { + 'internal_only' => 'Internal Only', + 'coaching_staff' => 'Coaching Staff', + 'full_team' => 'Full Team' + }.freeze + end + + # Competitive match constants + module CompetitiveMatch + FORMATS = %w[BO1 BO3 BO5].freeze + SIDES = Match::SIDES # Reuse from Match + + FORMAT_NAMES = { + 'BO1' => 'Best of 1', + 'BO3' => 'Best of 3', + 'BO5' => 'Best of 5' + }.freeze + end + + # Opponent team constants + module OpponentTeam + TIERS = %w[tier_1 tier_2 tier_3].freeze + + TIER_NAMES = { + 'tier_1' => 'Professional', + 'tier_2' => 'Semi-Pro', + 'tier_3' => 'Amateur' + }.freeze + end + + # Schedule constants + module Schedule + EVENT_TYPES = %w[match scrim practice meeting review].freeze + STATUSES = %w[scheduled ongoing completed cancelled].freeze + + EVENT_TYPE_NAMES = { + 'match' => 'Match', + 'scrim' => 'Scrim', + 'practice' => 'Practice', + 'meeting' => 'Meeting', + 'review' => 'Review' + }.freeze + + STATUS_NAMES = { + 'scheduled' => 'Scheduled', + 'ongoing' => 'Ongoing', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled' + }.freeze + end + + # Team goal constants + module TeamGoal + CATEGORIES = %w[performance rank tournament skill].freeze + METRIC_TYPES = %w[win_rate kda cs_per_min vision_score damage_share rank_climb].freeze + STATUSES = %w[active completed failed cancelled].freeze + + CATEGORY_NAMES = { + 'performance' => 'Performance', + 'rank' => 'Rank', + 'tournament' => 'Tournament', + 'skill' => 'Skill' + }.freeze + + METRIC_TYPE_NAMES = { + 'win_rate' => 'Win Rate', + 'kda' => 'KDA', + 'cs_per_min' => 'CS/Min', + 'vision_score' => 'Vision Score', + 'damage_share' => 'Damage Share', + 'rank_climb' => 'Rank Climb' + }.freeze + + STATUS_NAMES = { + 'active' => 'Active', + 'completed' => 'Completed', + 'failed' => 'Failed', + 'cancelled' => 'Cancelled' + }.freeze + end + + # VOD Review constants + module VodReview + TYPES = %w[team individual opponent].freeze + STATUSES = %w[draft published archived].freeze + + TYPE_NAMES = { + 'team' => 'Team Review', + 'individual' => 'Individual Review', + 'opponent' => 'Opponent Review' + }.freeze + + STATUS_NAMES = { + 'draft' => 'Draft', + 'published' => 'Published', + 'archived' => 'Archived' + }.freeze + end + + # VOD Timestamp constants + module VodTimestamp + CATEGORIES = %w[mistake good_play team_fight objective laning].freeze + IMPORTANCE_LEVELS = %w[low normal high critical].freeze + TARGET_TYPES = %w[player team opponent].freeze + + CATEGORY_NAMES = { + 'mistake' => 'Mistake', + 'good_play' => 'Good Play', + 'team_fight' => 'Team Fight', + 'objective' => 'Objective', + 'laning' => 'Laning' + }.freeze + + IMPORTANCE_NAMES = { + 'low' => 'Low', + 'normal' => 'Normal', + 'high' => 'High', + 'critical' => 'Critical' + }.freeze + + TARGET_TYPE_NAMES = { + 'player' => 'Player', + 'team' => 'Team', + 'opponent' => 'Opponent' + }.freeze + end + + # Scouting target constants + module ScoutingTarget + STATUSES = %w[watching contacted negotiating rejected signed].freeze + PRIORITIES = %w[low medium high critical].freeze + + STATUS_NAMES = { + 'watching' => 'Watching', + 'contacted' => 'Contacted', + 'negotiating' => 'Negotiating', + 'rejected' => 'Rejected', + 'signed' => 'Signed' + }.freeze + + PRIORITY_NAMES = { + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + 'critical' => 'Critical' + }.freeze + end + + # Champion Pool constants + module ChampionPool + MASTERY_LEVELS = (1..7) + PRIORITY_LEVELS = (1..10) + + MASTERY_LEVEL_NAMES = { + 1 => 'Novice', + 2 => 'Beginner', + 3 => 'Intermediate', + 4 => 'Advanced', + 5 => 'Expert', + 6 => 'Master', + 7 => 'Grandmaster' + }.freeze + end + + # Region names for display + REGION_NAMES = { + 'BR' => 'Brazil', + 'NA' => 'North America', + 'EUW' => 'Europe West', + 'EUNE' => 'Europe Nordic & East', + 'KR' => 'Korea', + 'LAN' => 'Latin America North', + 'LAS' => 'Latin America South', + 'OCE' => 'Oceania', + 'RU' => 'Russia', + 'TR' => 'Turkey', + 'JP' => 'Japan' + }.freeze +end diff --git a/app/models/concerns/tier_features.rb b/app/models/concerns/tier_features.rb new file mode 100644 index 0000000..d347950 --- /dev/null +++ b/app/models/concerns/tier_features.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +module TierFeatures + extend ActiveSupport::Concern + + TIER_FEATURES = { + tier_3_amateur: { + subscription_plans: %w[free amateur], + max_players: 10, + max_matches_per_month: 50, + data_retention_months: 3, + data_sources: %w[soloq], + analytics: %w[basic], + features: %w[vod_reviews champion_pools schedules], + api_access: false + }, + tier_2_semi_pro: { + subscription_plans: %w[semi_pro], + max_players: 25, + max_matches_per_month: 200, + data_retention_months: 12, + data_sources: %w[soloq scrims regional_tournaments], + analytics: %w[basic advanced scrim_analysis], + features: %w[ + vod_reviews champion_pools schedules + scrims draft_analysis team_composition opponent_database + ], + api_access: false + }, + tier_1_professional: { + subscription_plans: %w[professional enterprise], + max_players: 50, + max_matches_per_month: nil, # unlimited + data_retention_months: nil, # unlimited + data_sources: %w[soloq scrims official_competitive international], + analytics: %w[basic advanced predictive meta_analysis], + features: %w[ + vod_reviews champion_pools schedules + scrims draft_analysis team_composition opponent_database + competitive_data predictive_analytics meta_analysis + patch_impact player_form_tracking + ], + api_access: true # only for enterprise plan + } + }.freeze + + # Check if organization can access a specific feature + def can_access?(feature_name) + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + tier_config[:features].include?(feature_name.to_s) + end + + # Scrim access + def can_access_scrims? + tier.in?(%w[tier_2_semi_pro tier_1_professional]) + end + + def can_create_scrim? + return false unless can_access_scrims? + + monthly_scrims_count = scrims.where('created_at > ?', 1.month.ago).count + + case tier + when 'tier_2_semi_pro' + monthly_scrims_count < 50 + when 'tier_1_professional' + true # unlimited + else + false + end + end + + # Competitive data access + def can_access_competitive_data? + tier == 'tier_1_professional' + end + + # Predictive analytics access + def can_access_predictive_analytics? + tier == 'tier_1_professional' + end + + # API access (Enterprise only) + def can_access_api? + tier == 'tier_1_professional' && subscription_plan == 'enterprise' + end + + # Match limits + def match_limit_reached? + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + max_matches = tier_config[:max_matches_per_month] + + return false unless max_matches # unlimited + + monthly_matches = matches.where('created_at > ?', 1.month.ago).count + monthly_matches >= max_matches + end + + def matches_remaining_this_month + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + max_matches = tier_config[:max_matches_per_month] + + return nil unless max_matches # unlimited + + monthly_matches = matches.where('created_at > ?', 1.month.ago).count + [max_matches - monthly_matches, 0].max + end + + # Player limits + def player_limit_reached? + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + players.count >= tier_config[:max_players] + end + + def players_remaining + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + max_players = tier_config[:max_players] + + [max_players - players.count, 0].max + end + + # Analytics level + def analytics_level + case tier + when 'tier_3_amateur' + :basic + when 'tier_2_semi_pro' + :advanced + when 'tier_1_professional' + :predictive + else + :basic + end + end + + # Data retention check + def data_retention_cutoff + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + months = tier_config[:data_retention_months] + + return nil unless months # unlimited + + months.months.ago + end + + # Tier information + def tier_display_name + case tier + when 'tier_3_amateur' + 'Amateur (Tier 3)' + when 'tier_2_semi_pro' + 'Semi-Pro (Tier 2)' + when 'tier_1_professional' + 'Professional (Tier 1)' + else + 'Unknown' + end + end + + def tier_limits + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + + { + max_players: tier_config[:max_players], + max_matches_per_month: tier_config[:max_matches_per_month], + data_retention_months: tier_config[:data_retention_months], + current_players: players.count, + current_monthly_matches: matches.where('created_at > ?', 1.month.ago).count, + players_remaining: players_remaining, + matches_remaining: matches_remaining_this_month + } + end + + def available_features + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + tier_config[:features] + end + + def available_data_sources + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + tier_config[:data_sources] + end + + def available_analytics + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + tier_config[:analytics] + end + + # Upgrade suggestions + def suggested_upgrade + case tier + when 'tier_3_amateur' + { + tier: 'tier_2_semi_pro', + name: 'Semi-Pro', + benefits: [ + '25 players (from 10)', + '200 matches/month (from 50)', + 'Scrim tracking', + 'Draft analysis', + 'Advanced analytics' + ] + } + when 'tier_2_semi_pro' + { + tier: 'tier_1_professional', + name: 'Professional', + benefits: [ + '50 players (from 25)', + 'Unlimited matches', + 'Official competitive data', + 'Predictive analytics', + 'Meta analysis' + ] + } + end + end + + # Usage warnings + def approaching_player_limit? + return false if player_limit_reached? + + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + max_players = tier_config[:max_players] + + players.count >= (max_players * 0.8).floor # 80% threshold + end + + def approaching_match_limit? + tier_config = TIER_FEATURES[tier_symbol] || TIER_FEATURES[:tier_3_amateur] + max_matches = tier_config[:max_matches_per_month] + + return false unless max_matches # unlimited + return false if match_limit_reached? + + monthly_matches = matches.where('created_at > ?', 1.month.ago).count + monthly_matches >= (max_matches * 0.8).floor # 80% threshold + end + + private + + def tier_symbol + tier&.to_sym || :tier_3_amateur + end +end diff --git a/app/models/match.rb b/app/models/match.rb index f6a58c6..6060f26 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -1,115 +1,150 @@ -class Match < ApplicationRecord - # Associations - belongs_to :organization - has_many :player_match_stats, dependent: :destroy - has_many :players, through: :player_match_stats - has_many :schedules, dependent: :nullify - has_many :vod_reviews, dependent: :destroy - - # Validations - validates :match_type, presence: true, inclusion: { in: %w[official scrim tournament] } - validates :riot_match_id, uniqueness: true, allow_blank: true - validates :our_side, inclusion: { in: %w[blue red] }, allow_blank: true - validates :game_duration, numericality: { greater_than: 0 }, allow_blank: true - - # Callbacks - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_type, ->(type) { where(match_type: type) } - scope :victories, -> { where(victory: true) } - scope :defeats, -> { where(victory: false) } - scope :recent, ->(days = 30) { where(game_start: days.days.ago..Time.current) } - scope :in_date_range, ->(start_date, end_date) { where(game_start: start_date..end_date) } - scope :with_opponent, ->(opponent) { where('opponent_name ILIKE ?', "%#{opponent}%") } - - # Instance methods - def result_text - return 'Unknown' if victory.nil? - - victory? ? 'Victory' : 'Defeat' - end - - def duration_formatted - return 'Unknown' if game_duration.blank? - - minutes = game_duration / 60 - seconds = game_duration % 60 - "#{minutes}:#{seconds.to_s.rjust(2, '0')}" - end - - def score_display - return 'Unknown' if our_score.blank? || opponent_score.blank? - - "#{our_score} - #{opponent_score}" - end - - def kda_summary - stats = player_match_stats.includes(:player) - total_kills = stats.sum(:kills) - total_deaths = stats.sum(:deaths) - total_assists = stats.sum(:assists) - - deaths = total_deaths.zero? ? 1 : total_deaths - kda = (total_kills + total_assists).to_f / deaths - - { - kills: total_kills, - deaths: total_deaths, - assists: total_assists, - kda: kda.round(2) - } - end - - def gold_advantage - return nil if our_score.blank? || opponent_score.blank? - - our_gold = player_match_stats.sum(:gold_earned) - # Assuming opponent gold is estimated based on game duration and average values - estimated_opponent_gold = game_duration.present? ? game_duration * 350 * 5 : nil - - return nil if estimated_opponent_gold.blank? - - our_gold - estimated_opponent_gold - end - - def mvp_player - return nil if player_match_stats.empty? - - player_match_stats - .joins(:player) - .order(performance_score: :desc, kills: :desc, assists: :desc) - .first&.player - end - - def team_composition - player_match_stats.includes(:player).map do |stat| - { - player: stat.player.summoner_name, - champion: stat.champion, - role: stat.role - } - end - end - - def has_replay? - replay_file_url.present? - end - - def has_vod? - vod_url.present? - end - - private - - def log_audit_trail - AuditLog.create!( - organization: organization, - action: 'update', - entity_type: 'Match', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +# Represents a League of Legends match/game +# +# Matches store game data including results, statistics, and metadata. +# They can be official competitive matches, scrims, or tournament games. +# Match data can be imported from Riot API or created manually. +# +# @attr [String] match_type Type of match: official, scrim, or tournament +# @attr [DateTime] game_start When the match started +# @attr [DateTime] game_end When the match ended +# @attr [Integer] game_duration Duration in seconds +# @attr [String] riot_match_id Riot's unique match identifier +# @attr [String] patch_version Game patch version (e.g., "13.24") +# @attr [String] opponent_name Name of the opposing team +# @attr [Boolean] victory Whether the organization won the match +# @attr [String] our_side Which side the team played on: blue or red +# @attr [Integer] our_score Team's score (kills or games won in series) +# @attr [Integer] opponent_score Opponent's score +# @attr [String] vod_url Link to video recording of the match +# +# @example Creating a match +# match = Match.create!( +# organization: org, +# match_type: "scrim", +# game_start: Time.current, +# victory: true +# ) +# +# @example Finding recent victories +# recent_wins = Match.victories.recent(7) +# +class Match < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + has_many :player_match_stats, dependent: :destroy + has_many :players, through: :player_match_stats + has_many :schedules, dependent: :nullify + has_many :vod_reviews, dependent: :destroy + + # Validations + validates :match_type, presence: true, inclusion: { in: Constants::Match::TYPES } + validates :riot_match_id, uniqueness: true, allow_blank: true + validates :our_side, inclusion: { in: Constants::Match::SIDES }, allow_blank: true + validates :game_duration, numericality: { greater_than: 0 }, allow_blank: true + + # Callbacks + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_type, ->(type) { where(match_type: type) } + scope :victories, -> { where(victory: true) } + scope :defeats, -> { where(victory: false) } + scope :recent, ->(days = 30) { where(game_start: days.days.ago..Time.current) } + scope :in_date_range, ->(start_date, end_date) { where(game_start: start_date..end_date) } + scope :with_opponent, ->(opponent) { where('opponent_name ILIKE ?', "%#{opponent}%") } + + # Instance methods + def result_text + return 'Unknown' if victory.nil? + + victory? ? 'Victory' : 'Defeat' + end + + def duration_formatted + return 'Unknown' if game_duration.blank? + + minutes = game_duration / 60 + seconds = game_duration % 60 + "#{minutes}:#{seconds.to_s.rjust(2, '0')}" + end + + def score_display + return 'Unknown' if our_score.blank? || opponent_score.blank? + + "#{our_score} - #{opponent_score}" + end + + def kda_summary + stats = player_match_stats.includes(:player) + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + kda = (total_kills + total_assists).to_f / deaths + + { + kills: total_kills, + deaths: total_deaths, + assists: total_assists, + kda: kda.round(2) + } + end + + def gold_advantage + return nil if our_score.blank? || opponent_score.blank? + + our_gold = player_match_stats.sum(:gold_earned) + # Assuming opponent gold is estimated based on game duration and average values + estimated_opponent_gold = game_duration.present? ? game_duration * 350 * 5 : nil + + return nil if estimated_opponent_gold.blank? + + our_gold - estimated_opponent_gold + end + + def mvp_player + return nil if player_match_stats.empty? + + player_match_stats + .joins(:player) + .order(performance_score: :desc, kills: :desc, assists: :desc) + .first&.player + end + + def team_composition + player_match_stats.includes(:player).map do |stat| + { + player: stat.player.summoner_name, + champion: stat.champion, + role: stat.role + } + end + end + + def has_replay? + replay_file_url.present? + end + + def has_vod? + vod_url.present? + end + + private + + def log_audit_trail + AuditLog.create!( + organization: organization, + action: 'update', + entity_type: 'Match', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 20c464e..efd99c5 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,36 +1,61 @@ -class Notification < ApplicationRecord - # Associations - belongs_to :user - - # Validations - validates :title, presence: true, length: { maximum: 200 } - validates :message, presence: true - validates :type, presence: true, inclusion: { - in: %w[info success warning error match schedule system], - message: "%{value} is not a valid notification type" - } - - # Scopes - scope :unread, -> { where(is_read: false) } - scope :read, -> { where(is_read: true) } - scope :recent, -> { order(created_at: :desc) } - scope :by_type, ->(type) { where(type: type) } - - # Callbacks - before_create :set_default_channels - - # Instance methods - def mark_as_read! - update!(is_read: true, read_at: Time.current) - end - - def unread? - !is_read - end - - private - - def set_default_channels - self.channels ||= ['in_app'] - end -end +# frozen_string_literal: true + +# Model representing user notifications for system events and updates +# +# This model manages in-app notifications for users, supporting multiple notification +# types (info, success, warning, error, match, schedule, system) and delivery channels. +# Notifications can be marked as read/unread and are tracked with timestamps. +# +# Associated with: +# - User: The user who receives the notification +# +# @example Create a notification +# notification = Notification.create( +# user: user, +# title: 'Match Scheduled', +# message: 'Your match against Team Alpha is scheduled for tomorrow at 3 PM', +# type: 'match', +# channels: ['in_app', 'email'] +# ) +# +# @example Query unread notifications +# user.notifications.unread.recent +# +# @example Mark notification as read +# notification.mark_as_read! +class Notification < ApplicationRecord + # Associations + belongs_to :user + + # Validations + validates :title, presence: true, length: { maximum: 200 } + validates :message, presence: true + validates :type, presence: true, inclusion: { + in: %w[info success warning error match schedule system], + message: '%{value} is not a valid notification type' + } + + # Scopes + scope :unread, -> { where(is_read: false) } + scope :read, -> { where(is_read: true) } + scope :recent, -> { order(created_at: :desc) } + scope :by_type, ->(type) { where(type: type) } + + # Callbacks + before_create :set_default_channels + + # Instance methods + def mark_as_read! + update!(is_read: true, read_at: Time.current) + end + + def unread? + !is_read + end + + private + + def set_default_channels + self.channels ||= ['in_app'] + end +end diff --git a/app/models/opponent_team.rb b/app/models/opponent_team.rb new file mode 100644 index 0000000..993ea2b --- /dev/null +++ b/app/models/opponent_team.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +class OpponentTeam < ApplicationRecord + # Concerns + include Constants + + # Associations + has_many :scrims, dependent: :nullify + has_many :competitive_matches, dependent: :nullify + + # Validations + validates :name, presence: true, length: { maximum: 255 } + validates :tag, length: { maximum: 10 } + + validates :region, inclusion: { + in: Constants::REGIONS, + message: '%{value} is not a valid region' + }, allow_blank: true + + validates :tier, inclusion: { + in: Constants::OpponentTeam::TIERS, + message: '%{value} is not a valid tier' + }, allow_blank: true + + # Callbacks + before_save :normalize_name_and_tag + + # Scopes + scope :by_region, ->(region) { where(region: region) } + scope :by_tier, ->(tier) { where(tier: tier) } + scope :by_league, ->(league) { where(league: league) } + scope :professional, -> { where(tier: 'tier_1') } + scope :semi_pro, -> { where(tier: 'tier_2') } + scope :amateur, -> { where(tier: 'tier_3') } + scope :with_scrims, -> { where('total_scrims > 0') } + scope :ordered_by_scrim_count, -> { order(total_scrims: :desc) } + + # Instance methods + def scrim_win_rate + return 0 if total_scrims.zero? + + ((scrims_won.to_f / total_scrims) * 100).round(2) + end + + def scrim_record + "#{scrims_won}W - #{scrims_lost}L" + end + + # Updates scrim statistics (raises on failure) + # + # @param victory [Boolean] Whether the scrim was won + # @raise [ActiveRecord::RecordInvalid] if save fails + def update_scrim_stats!(victory:) + self.total_scrims += 1 + + if victory + self.scrims_won += 1 + else + self.scrims_lost += 1 + end + + save! + end + + # Safe version of update_scrim_stats! (returns boolean) + # + # @param victory [Boolean] Whether the scrim was won + # @return [Boolean] true if update succeeded, false otherwise + def update_scrim_stats(victory:) + update_scrim_stats!(victory: victory) + true + rescue ActiveRecord::RecordInvalid + false + end + + def tier_display + case tier + when 'tier_1' + 'Professional' + when 'tier_2' + 'Semi-Pro' + when 'tier_3' + 'Amateur' + else + 'Unknown' + end + end + + # Returns full team name with tag if present + # @return [String] Team name (e.g., "T1 (T1)" or just "T1") + def full_name + tag&.then { |t| "#{name} (#{t})" } || name + end + + def contact_available? + contact_email.present? || discord_server.present? + end + + # Analytics methods + + # Returns the most preferred champion for a given role + # @param role [String] The role (top, jungle, mid, adc, support) + # @return [String, nil] Champion name or nil if not found + def preferred_champion_by_role(role) + preferred_champions&.dig(role)&.first + end + + # Returns all strength tags for the team + # @return [Array] Array of strength tags + def all_strengths_tags + strengths || [] + end + + # Returns all weakness tags for the team + # @return [Array] Array of weakness tags + def all_weaknesses_tags + weaknesses || [] + end + + def add_strength(strength) + return if strengths.include?(strength) + + self.strengths ||= [] + self.strengths << strength + save + end + + def add_weakness(weakness) + return if weaknesses.include?(weakness) + + self.weaknesses ||= [] + self.weaknesses << weakness + save + end + + def remove_strength(strength) + return unless strengths.include?(strength) + + self.strengths.delete(strength) + save + end + + def remove_weakness(weakness) + return unless weaknesses.include?(weakness) + + self.weaknesses.delete(weakness) + save + end + + private + + def normalize_name_and_tag + self.name = name.strip if name.present? + self.tag = tag.strip.upcase if tag.present? + end +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 7b1a913..a08f4a3 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,44 +1,83 @@ -class Organization < ApplicationRecord - # Associations - has_many :users, dependent: :destroy - has_many :players, dependent: :destroy - has_many :matches, dependent: :destroy - has_many :scouting_targets, dependent: :destroy - has_many :schedules, dependent: :destroy - has_many :vod_reviews, dependent: :destroy - has_many :team_goals, dependent: :destroy - has_many :audit_logs, dependent: :destroy - - # Validations - validates :name, presence: true, length: { maximum: 255 } - validates :slug, presence: true, uniqueness: true, length: { maximum: 100 } - validates :region, presence: true, inclusion: { in: %w[BR NA EUW KR EUNE EUW1 LAN LAS OCE RU TR JP] } - validates :tier, inclusion: { in: %w[amateur semi_pro professional] }, allow_blank: true - validates :subscription_plan, inclusion: { in: %w[free amateur semi_pro professional enterprise] }, allow_blank: true - validates :subscription_status, inclusion: { in: %w[active inactive trial expired] }, allow_blank: true - - # Callbacks - before_validation :generate_slug, on: :create - - # Scopes - scope :by_region, ->(region) { where(region: region) } - scope :by_tier, ->(tier) { where(tier: tier) } - scope :active_subscription, -> { where(subscription_status: 'active') } - - private - - def generate_slug - return if slug.present? - - base_slug = name.parameterize - counter = 1 - generated_slug = base_slug - - while Organization.exists?(slug: generated_slug) - generated_slug = "#{base_slug}-#{counter}" - counter += 1 - end - - self.slug = generated_slug - end -end \ No newline at end of file +# frozen_string_literal: true + +# Represents a League of Legends esports organization +# +# Organizations are the top-level entities in the system. Each organization +# has players, matches, schedules, and is associated with a specific tier +# that determines available features and limits. +# +# The tier system controls access to features: +# - tier_3_amateur: Basic features for amateur teams +# - tier_2_semi_pro: Advanced features including scrim tracking +# - tier_1_professional: Full feature set with competitive data +# +# @attr [String] name Organization's full name (required) +# @attr [String] slug URL-friendly unique identifier (auto-generated) +# @attr [String] region Server region (BR, NA, EUW, etc.) +# @attr [String] tier Access tier determining available features +# @attr [String] subscription_plan Current subscription plan +# @attr [String] subscription_status Subscription status: active, inactive, trial, or expired +# +# @example Creating a new organization +# org = Organization.create!( +# name: "T1 Esports", +# region: "KR", +# tier: "tier_1_professional" +# ) +# +# @example Checking feature access +# org.can_access_scrims? # => true for tier_2+ +# org.can_access_competitive_data? # => true for tier_1 only +# +class Organization < ApplicationRecord + # Concerns + include TierFeatures + include Constants + + # Associations + has_many :users, dependent: :destroy + has_many :players, dependent: :destroy + has_many :matches, dependent: :destroy + has_many :scouting_targets, dependent: :destroy + has_many :schedules, dependent: :destroy + has_many :vod_reviews, dependent: :destroy + has_many :team_goals, dependent: :destroy + has_many :audit_logs, dependent: :destroy + + # New tier-based associations + has_many :scrims, dependent: :destroy + has_many :competitive_matches, dependent: :destroy + + # Validations + validates :name, presence: true, length: { maximum: 255 } + validates :slug, presence: true, uniqueness: true, length: { maximum: 100 } + validates :region, presence: true, inclusion: { in: Constants::REGIONS } + validates :tier, inclusion: { in: Constants::Organization::TIERS }, allow_blank: true + validates :subscription_plan, inclusion: { in: Constants::Organization::SUBSCRIPTION_PLANS }, allow_blank: true + validates :subscription_status, inclusion: { in: Constants::Organization::SUBSCRIPTION_STATUSES }, allow_blank: true + + # Callbacks + before_validation :generate_slug, on: :create + + # Scopes + scope :by_region, ->(region) { where(region: region) } + scope :by_tier, ->(tier) { where(tier: tier) } + scope :active_subscription, -> { where(subscription_status: 'active') } + + private + + def generate_slug + return if slug.present? + + base_slug = name.parameterize + counter = 1 + generated_slug = base_slug + + while ::Organization.exists?(slug: generated_slug) + generated_slug = "#{base_slug}-#{counter}" + counter += 1 + end + + self.slug = generated_slug + end +end diff --git a/app/models/password_reset_token.rb b/app/models/password_reset_token.rb new file mode 100644 index 0000000..874b98f --- /dev/null +++ b/app/models/password_reset_token.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class PasswordResetToken < ApplicationRecord + belongs_to :user + + validates :token, presence: true, uniqueness: true + validates :expires_at, presence: true + + scope :valid, -> { where('expires_at > ? AND used_at IS NULL', Time.current) } + scope :expired, -> { where('expires_at <= ?', Time.current) } + scope :used, -> { where.not(used_at: nil) } + + before_validation :generate_token, on: :create + before_validation :set_expiration, on: :create + + def mark_as_used! + update!(used_at: Time.current) + end + + def valid_token? + expires_at > Time.current && used_at.nil? + end + + def expired? + expires_at <= Time.current + end + + def used? + used_at.present? + end + + def self.generate_secure_token + SecureRandom.urlsafe_base64(32) + end + + def self.cleanup_old_tokens + where('expires_at < ? OR used_at < ?', 24.hours.ago, 24.hours.ago).delete_all + end + + private + + def generate_token + self.token ||= self.class.generate_secure_token + end + + def set_expiration + self.expires_at ||= 1.hour.from_now + end +end diff --git a/app/models/player.rb b/app/models/player.rb index 05be110..6e27f1d 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -1,130 +1,167 @@ -class Player < ApplicationRecord - # Associations - belongs_to :organization - 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 - - # Validations - validates :summoner_name, presence: true, length: { maximum: 100 } - validates :real_name, length: { maximum: 255 } - validates :role, presence: true, inclusion: { in: %w[top jungle mid adc support] } - validates :country, length: { maximum: 2 } - validates :status, inclusion: { in: %w[active inactive benched trial] } - 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 - validates :solo_queue_tier, inclusion: { - in: %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER] - }, allow_blank: true - validates :solo_queue_rank, inclusion: { in: %w[I II III IV] }, allow_blank: true - validates :flex_queue_tier, inclusion: { - in: %w[IRON BRONZE SILVER GOLD PLATINUM EMERALD DIAMOND MASTER GRANDMASTER CHALLENGER] - }, allow_blank: true - validates :flex_queue_rank, inclusion: { in: %w[I II III IV] }, allow_blank: true - - # Callbacks - before_save :normalize_summoner_name - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_role, ->(role) { where(role: role) } - scope :by_status, ->(status) { where(status: status) } - scope :active, -> { where(status: 'active') } - scope :with_contracts, -> { where.not(contract_start_date: nil) } - scope :contracts_expiring_soon, ->(days = 30) { - where(contract_end_date: Date.current..Date.current + days.days) - } - scope :by_tier, ->(tier) { where(solo_queue_tier: tier) } - scope :ordered_by_role, -> { - order(Arel.sql( - "CASE role - WHEN 'top' THEN 1 - WHEN 'jungle' THEN 2 - WHEN 'mid' THEN 3 - WHEN 'adc' THEN 4 - WHEN 'support' THEN 5 - ELSE 6 - END" - )) - } - - # Instance methods - def current_rank_display - return 'Unranked' if solo_queue_tier.blank? - - rank_part = solo_queue_rank.present? ? " #{solo_queue_rank}" : "" - lp_part = solo_queue_lp.present? ? " (#{solo_queue_lp} LP)" : "" - - "#{solo_queue_tier.titleize}#{rank_part}#{lp_part}" - end - - def peak_rank_display - return 'No peak recorded' if peak_tier.blank? - - rank_part = peak_rank.present? ? " #{peak_rank}" : "" - season_part = peak_season.present? ? " (S#{peak_season})" : "" - - "#{peak_tier.titleize}#{rank_part}#{season_part}" - end - - def contract_status - return 'No contract' if contract_start_date.blank? || contract_end_date.blank? - - if contract_end_date < Date.current - 'Expired' - elsif contract_end_date <= Date.current + 30.days - 'Expiring soon' - else - 'Active' - end - end - - def age - return nil if birth_date.blank? - - ((Date.current - birth_date) / 365.25).floor - end - - def win_rate - return 0 if solo_queue_wins.to_i + solo_queue_losses.to_i == 0 - - total_games = solo_queue_wins.to_i + solo_queue_losses.to_i - (solo_queue_wins.to_f / total_games * 100).round(1) - end - - def main_champions - champion_pools.order(games_played: :desc, average_kda: :desc).limit(3).pluck(:champion) - end - - def needs_sync? - last_sync_at.blank? || last_sync_at < 1.hour.ago - end - - def social_links - links = {} - links[:twitter] = "https://twitter.com/#{twitter_handle}" if twitter_handle.present? - links[:twitch] = "https://twitch.tv/#{twitch_channel}" if twitch_channel.present? - links[:instagram] = "https://instagram.com/#{instagram_handle}" if instagram_handle.present? - links - end - - private - - 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', - entity_type: 'Player', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +# Represents a player (athlete) in a League of Legends organization +# +# Players are the core entities in the roster management system. Each player +# belongs to an organization and has associated match statistics, champion pools, +# and rank information synced from the Riot Games API. +# +# @attr [String] summoner_name The player's in-game summoner name (required) +# @attr [String] real_name The player's real legal name (optional) +# @attr [String] role The player's primary position: top, jungle, mid, adc, or support +# @attr [String] status Player's roster status: active, inactive, benched, or trial +# @attr [String] riot_puuid Riot's universal unique identifier for the player +# @attr [String] riot_summoner_id Riot's summoner ID for API calls +# @attr [Integer] jersey_number Player's team jersey number (unique per organization) +# @attr [String] solo_queue_tier Current ranked tier (IRON to CHALLENGER) +# @attr [String] solo_queue_rank Current ranked division (I to IV) +# @attr [Integer] solo_queue_lp Current League Points in ranked +# @attr [Date] contract_start_date Contract start date +# @attr [Date] contract_end_date Contract end date +# +# @example Creating a new player +# player = Player.create!( +# summoner_name: "Faker", +# role: "mid", +# organization: org, +# status: "active" +# ) +# +# @example Finding active players by role +# mid_laners = Player.active.by_role("mid") +# +class Player < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + 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 + + # Validations + 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 :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 + validates :solo_queue_tier, inclusion: { in: Constants::Player::QUEUE_TIERS }, allow_blank: true + validates :solo_queue_rank, inclusion: { in: Constants::Player::QUEUE_RANKS }, allow_blank: true + 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 + + # Callbacks + before_save :normalize_summoner_name + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_role, ->(role) { where(role: role) } + scope :by_status, ->(status) { where(status: status) } + scope :active, -> { where(status: 'active') } + scope :with_contracts, -> { where.not(contract_start_date: nil) } + scope :contracts_expiring_soon, lambda { |days = 30| + where(contract_end_date: Date.current..Date.current + days.days) + } + scope :by_tier, ->(tier) { where(solo_queue_tier: tier) } + scope :ordered_by_role, lambda { + order(Arel.sql( + "CASE role + WHEN 'top' THEN 1 + WHEN 'jungle' THEN 2 + WHEN 'mid' THEN 3 + WHEN 'adc' THEN 4 + WHEN 'support' THEN 5 + ELSE 6 + END" + )) + } + + # Instance methods + # Returns formatted display of current ranked status + # @return [String] Formatted rank (e.g., "Diamond II (75 LP)" or "Unranked") + def current_rank_display + return 'Unranked' if solo_queue_tier.blank? + + rank_part = solo_queue_rank&.then { |r| " #{r}" } || '' + lp_part = solo_queue_lp&.then { |lp| " (#{lp} LP)" } || '' + + "#{solo_queue_tier.titleize}#{rank_part}#{lp_part}" + end + + # Returns formatted display of peak rank achieved + # @return [String] Formatted peak rank (e.g., "Master I (S13)" or "No peak recorded") + def peak_rank_display + return 'No peak recorded' if peak_tier.blank? + + rank_part = peak_rank&.then { |r| " #{r}" } || '' + season_part = peak_season&.then { |s| " (S#{s})" } || '' + + "#{peak_tier.titleize}#{rank_part}#{season_part}" + end + + def contract_status + return 'No contract' if contract_start_date.blank? || contract_end_date.blank? + + if contract_end_date < Date.current + 'Expired' + elsif contract_end_date <= Date.current + 30.days + 'Expiring soon' + else + 'Active' + end + end + + def age + return nil if birth_date.blank? + + ((Date.current - birth_date) / 365.25).floor + end + + def win_rate + return 0 if (solo_queue_wins.to_i + solo_queue_losses.to_i).zero? + + total_games = solo_queue_wins.to_i + solo_queue_losses.to_i + (solo_queue_wins.to_f / total_games * 100).round(1) + end + + def main_champions + champion_pools.order(games_played: :desc, average_kda: :desc).limit(3).pluck(:champion) + end + + def needs_sync? + last_sync_at.blank? || last_sync_at < 1.hour.ago + end + + # Returns hash of social media links for the player + # @return [Hash] Social media URLs (only includes present handles) + def social_links + { + twitter: twitter_handle&.then { |h| "https://twitter.com/#{h}" }, + twitch: twitch_channel&.then { |c| "https://twitch.tv/#{c}" }, + instagram: instagram_handle&.then { |h| "https://instagram.com/#{h}" } + }.compact + end + + private + + 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', + entity_type: 'Player', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/player_match_stat.rb b/app/models/player_match_stat.rb index c869739..22695d1 100644 --- a/app/models/player_match_stat.rb +++ b/app/models/player_match_stat.rb @@ -1,196 +1,196 @@ -class PlayerMatchStat < ApplicationRecord - # Associations - belongs_to :match - belongs_to :player - - # Validations - validates :champion, presence: true - validates :kills, :deaths, :assists, :cs, numericality: { greater_than_or_equal_to: 0 } - validates :player_id, uniqueness: { scope: :match_id } - - # Callbacks - before_save :calculate_derived_stats - after_create :update_champion_pool - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_champion, ->(champion) { where(champion: champion) } - scope :by_role, ->(role) { where(role: role) } - scope :recent, ->(days = 30) { joins(:match).where(matches: { game_start: days.days.ago..Time.current }) } - scope :victories, -> { joins(:match).where(matches: { victory: true }) } - scope :defeats, -> { joins(:match).where(matches: { victory: false }) } - - # Instance methods - def kda_ratio - return 0 if deaths.zero? - - (kills + assists).to_f / deaths - end - - def kda_display - "#{kills}/#{deaths}/#{assists}" - end - - def kill_participation_percentage - return 0 if kill_participation.blank? - - (kill_participation * 100).round(1) - end - - def damage_share_percentage - return 0 if damage_share.blank? - - (damage_share * 100).round(1) - end - - def gold_share_percentage - return 0 if gold_share.blank? - - (gold_share * 100).round(1) - end - - def multikill_count - double_kills + triple_kills + quadra_kills + penta_kills - end - - def grade_performance - # Simple performance grading based on KDA, CS, and damage - score = 0 - - # KDA scoring - kda = kda_ratio - score += case kda - when 0...1 then 1 - when 1...2 then 2 - when 2...3 then 3 - when 3...4 then 4 - else 5 - end - - # CS scoring (assuming 10 CS per minute is excellent) - cs_per_min_value = cs_per_min || 0 - score += case cs_per_min_value - when 0...4 then 1 - when 4...6 then 2 - when 6...8 then 3 - when 8...10 then 4 - else 5 - end - - # Damage share scoring - damage_percentage = damage_share_percentage - score += case damage_percentage - when 0...15 then 1 - when 15...20 then 2 - when 20...25 then 3 - when 25...30 then 4 - else 5 - end - - # Vision scoring - vision_per_min = match.game_duration.present? ? vision_score.to_f / (match.game_duration / 60.0) : 0 - score += case vision_per_min - when 0...1 then 1 - when 1...1.5 then 2 - when 1.5...2 then 3 - when 2...2.5 then 4 - else 5 - end - - # Average and convert to letter grade - average = score / 4.0 - case average - when 0...1.5 then 'D' - when 1.5...2.5 then 'C' - when 2.5...3.5 then 'B' - when 3.5...4.5 then 'A' - else 'S' - end - end - - def item_names - # This would be populated by Riot API data - # For now, return item IDs as placeholder - items.map { |item_id| "Item #{item_id}" } - end - - def rune_names - # This would be populated by Riot API data - # For now, return rune IDs as placeholder - runes.map { |rune_id| "Rune #{rune_id}" } - end - - private - - def calculate_derived_stats - if match&.game_duration.present? && match.game_duration > 0 - minutes = match.game_duration / 60.0 - self.cs_per_min = cs.to_f / minutes if cs.present? - self.gold_per_min = gold_earned.to_f / minutes if gold_earned.present? - end - - # Calculate performance score (0-100) - self.performance_score = calculate_performance_score - end - - def calculate_performance_score - return 0 unless match - - score = 0 - - # KDA component (40 points max) - kda = kda_ratio - score += [kda * 10, 40].min - - # CS component (20 points max) - cs_score = (cs_per_min || 0) * 2.5 - score += [cs_score, 20].min - - # Damage component (20 points max) - damage_score = (damage_share || 0) * 100 * 0.8 - score += [damage_score, 20].min - - # Vision component (10 points max) - vision_score_normalized = vision_score.to_f / 100 - score += [vision_score_normalized * 10, 10].min - - # Victory bonus (10 points max) - score += 10 if match.victory? - - [score, 100].min.round(2) - end - - def update_champion_pool - pool = player.champion_pools.find_or_initialize_by(champion: champion) - - pool.games_played += 1 - pool.games_won += 1 if match.victory? - - # Update averages - pool.average_kda = calculate_average_for_champion(:kda_ratio) - pool.average_cs_per_min = calculate_average_for_champion(:cs_per_min) - pool.average_damage_share = calculate_average_for_champion(:damage_share) - - pool.last_played = match.game_start || Time.current - pool.save! - end - - def calculate_average_for_champion(stat_method) - stats = player.player_match_stats.joins(:match).where(champion: champion) - values = stats.map { |stat| stat.send(stat_method) }.compact - return 0 if values.empty? - - values.sum / values.size.to_f - end - - def log_audit_trail - AuditLog.create!( - organization: player.organization, - action: 'update', - entity_type: 'PlayerMatchStat', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +class PlayerMatchStat < ApplicationRecord + belongs_to :match + belongs_to :player + + validates :champion, presence: true + validates :kills, :deaths, :assists, :cs, numericality: { greater_than_or_equal_to: 0 } + validates :player_id, uniqueness: { scope: :match_id } + + before_save :calculate_derived_stats + after_create :update_champion_pool + after_update :log_audit_trail, if: :saved_changes? + + scope :by_champion, ->(champion) { where(champion: champion) } + scope :by_role, ->(role) { where(role: role) } + scope :recent, ->(days = 30) { joins(:match).where(matches: { game_start: days.days.ago..Time.current }) } + scope :victories, -> { joins(:match).where(matches: { victory: true }) } + scope :defeats, -> { joins(:match).where(matches: { victory: false }) } + + def kda_ratio + return 0 if deaths.zero? + + (kills + assists).to_f / deaths + end + + def kda_display + "#{kills}/#{deaths}/#{assists}" + end + + def kill_participation_percentage + return 0 if kill_participation.blank? + + (kill_participation * 100).round(1) + end + + def damage_share_percentage + return 0 if damage_share.blank? + + (damage_share * 100).round(1) + end + + def gold_share_percentage + return 0 if gold_share.blank? + + (gold_share * 100).round(1) + end + + def multikill_count + double_kills + triple_kills + quadra_kills + penta_kills + end + + def grade_performance + total_score = kda_score + cs_score + damage_score + vision_performance_score + average_score = total_score / 4.0 + + score_to_grade(average_score) + end + + def item_names + # This would be populated by Riot API data + # For now, return item IDs as placeholder + items.map { |item_id| "Item #{item_id}" } + end + + def rune_names + # This would be populated by Riot API data + # For now, return rune IDs as placeholder + runes.map { |rune_id| "Rune #{rune_id}" } + end + + private + + def kda_score + case kda_ratio + when 0...1 then 1 + when 1...2 then 2 + when 2...3 then 3 + when 3...4 then 4 + else 5 + end + end + + def cs_score + cs_value = cs_per_min || 0 + case cs_value + when 0...4 then 1 + when 4...6 then 2 + when 6...8 then 3 + when 8...10 then 4 + else 5 + end + end + + def damage_score + case damage_share_percentage + when 0...15 then 1 + when 15...20 then 2 + when 20...25 then 3 + when 25...30 then 4 + else 5 + end + end + + def vision_performance_score + vision_per_min = match&.game_duration.present? ? vision_score.to_f / (match.game_duration / 60.0) : 0 + case vision_per_min + when 0...1 then 1 + when 1...1.5 then 2 + when 1.5...2 then 3 + when 2...2.5 then 4 + else 5 + end + end + + def score_to_grade(average) + case average + when 0...1.5 then 'D' + when 1.5...2.5 then 'C' + when 2.5...3.5 then 'B' + when 3.5...4.5 then 'A' + else 'S' + end + end + + def calculate_derived_stats + if match&.game_duration.present? && match.game_duration.positive? + minutes = match.game_duration / 60.0 + self.cs_per_min = cs.to_f / minutes if cs.present? + self.gold_per_min = gold_earned.to_f / minutes if gold_earned.present? + end + + # Calculate performance score (0-100) + self.performance_score = calculate_performance_score + end + + def calculate_performance_score + return 0 unless match + + score = 0 + + # KDA component (40 points max) + kda = kda_ratio + score += [kda * 10, 40].min + + # CS component (20 points max) + cs_score = (cs_per_min || 0) * 2.5 + score += [cs_score, 20].min + + # Damage component (20 points max) + damage_score = (damage_share || 0) * 100 * 0.8 + score += [damage_score, 20].min + + # Vision component (10 points max) + vision_score_normalized = vision_score.to_f / 100 + score += [vision_score_normalized * 10, 10].min + + # Victory bonus (10 points max) + score += 10 if match.victory? + + [score, 100].min.round(2) + end + + def update_champion_pool + pool = player.champion_pools.find_or_initialize_by(champion: champion) + + pool.games_played += 1 + pool.games_won += 1 if match.victory? + + pool.average_kda = calculate_average_for_champion(:kda_ratio) + pool.average_cs_per_min = calculate_average_for_champion(:cs_per_min) + pool.average_damage_share = calculate_average_for_champion(:damage_share) + + pool.last_played = match.game_start || Time.current + pool.save! + end + + def calculate_average_for_champion(stat_method) + stats = player.player_match_stats.joins(:match).where(champion: champion) + values = stats.map { |stat| stat.send(stat_method) }.compact + return 0 if values.empty? + + values.sum / values.size.to_f + end + + def log_audit_trail + AuditLog.create!( + organization: player.organization, + action: 'update', + entity_type: 'PlayerMatchStat', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/schedule.rb b/app/models/schedule.rb index 6d5b23c..7d8daaa 100644 --- a/app/models/schedule.rb +++ b/app/models/schedule.rb @@ -1,160 +1,167 @@ -class Schedule < ApplicationRecord - # Associations - belongs_to :organization - belongs_to :match, optional: true - belongs_to :created_by, class_name: 'User', optional: true - - # Validations - validates :title, presence: true, length: { maximum: 255 } - validates :event_type, presence: true, inclusion: { in: %w[match scrim practice meeting review] } - validates :start_time, :end_time, presence: true - validates :status, inclusion: { in: %w[scheduled ongoing completed cancelled] } - validate :end_time_after_start_time - - # Callbacks - before_save :set_timezone_if_blank - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_type, ->(type) { where(event_type: type) } - scope :by_status, ->(status) { where(status: status) } - scope :upcoming, -> { where('start_time > ?', Time.current) } - scope :today, -> { where(start_time: Date.current.beginning_of_day..Date.current.end_of_day) } - scope :this_week, -> { where(start_time: Date.current.beginning_of_week..Date.current.end_of_week) } - scope :in_date_range, ->(start_date, end_date) { where(start_time: start_date..end_date) } - scope :for_player, ->(player_id) { where('? = ANY(required_players) OR ? = ANY(optional_players)', player_id, player_id) } - - # Instance methods - def duration_minutes - return 0 unless start_time && end_time - - ((end_time - start_time) / 1.minute).round - end - - def duration_formatted - minutes = duration_minutes - hours = minutes / 60 - mins = minutes % 60 - - if hours > 0 - "#{hours}h #{mins}m" - else - "#{mins}m" - end - end - - def status_color - case status - when 'scheduled' then 'blue' - when 'ongoing' then 'green' - when 'completed' then 'gray' - when 'cancelled' then 'red' - else 'gray' - end - end - - def is_today? - start_time.to_date == Date.current - end - - def is_upcoming? - start_time > Time.current - end - - def is_past? - end_time < Time.current - end - - def is_ongoing? - Time.current.between?(start_time, end_time) - end - - def can_be_cancelled? - %w[scheduled].include?(status) && is_upcoming? - end - - def can_be_completed? - %w[scheduled ongoing].include?(status) - end - - def required_player_names - return [] if required_players.blank? - - Player.where(id: required_players).pluck(:summoner_name) - end - - def optional_player_names - return [] if optional_players.blank? - - Player.where(id: optional_players).pluck(:summoner_name) - end - - def all_participants - required_player_names + optional_player_names - end - - def reminder_times - return [] if reminder_minutes.blank? - - reminder_minutes.map do |minutes| - start_time - minutes.minutes - end - end - - def next_reminder - now = Time.current - reminder_times.select { |time| time > now }.min - end - - def conflict_with?(other_schedule) - return false if other_schedule == self - - time_overlap?(other_schedule) && participant_overlap?(other_schedule) - end - - def mark_as_completed! - update!(status: 'completed') - end - - def mark_as_cancelled! - update!(status: 'cancelled') - end - - def mark_as_ongoing! - update!(status: 'ongoing') - end - - private - - def end_time_after_start_time - return unless start_time && end_time - - errors.add(:end_time, 'must be after start time') if end_time <= start_time - end - - def set_timezone_if_blank - self.timezone = 'UTC' if timezone.blank? - end - - def time_overlap?(other) - start_time < other.end_time && end_time > other.start_time - end - - def participant_overlap?(other) - our_participants = required_players + optional_players - other_participants = other.required_players + other.optional_players - - (our_participants & other_participants).any? - end - - def log_audit_trail - AuditLog.create!( - organization: organization, - action: 'update', - entity_type: 'Schedule', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +class Schedule < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + belongs_to :match, optional: true + belongs_to :created_by, class_name: 'User', optional: true + + # Validations + validates :title, presence: true, length: { maximum: 255 } + validates :event_type, presence: true, inclusion: { in: Constants::Schedule::EVENT_TYPES } + validates :start_time, :end_time, presence: true + validates :status, inclusion: { in: Constants::Schedule::STATUSES } + validate :end_time_after_start_time + + # Callbacks + before_save :set_timezone_if_blank + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_type, ->(type) { where(event_type: type) } + scope :by_status, ->(status) { where(status: status) } + scope :upcoming, -> { where('start_time > ?', Time.current) } + scope :today, -> { where(start_time: Date.current.beginning_of_day..Date.current.end_of_day) } + scope :this_week, -> { where(start_time: Date.current.beginning_of_week..Date.current.end_of_week) } + scope :in_date_range, ->(start_date, end_date) { where(start_time: start_date..end_date) } + scope :for_player, lambda { |player_id| + where('? = ANY(required_players) OR ? = ANY(optional_players)', player_id, player_id) + } + + # Instance methods + def duration_minutes + return 0 unless start_time && end_time + + ((end_time - start_time) / 1.minute).round + end + + def duration_formatted + minutes = duration_minutes + hours = minutes / 60 + mins = minutes % 60 + + if hours.positive? + "#{hours}h #{mins}m" + else + "#{mins}m" + end + end + + def status_color + case status + when 'scheduled' then 'blue' + when 'ongoing' then 'green' + when 'completed' then 'gray' + when 'cancelled' then 'red' + else 'gray' + end + end + + def is_today? + start_time.to_date == Date.current + end + + def is_upcoming? + start_time > Time.current + end + + def is_past? + end_time < Time.current + end + + def is_ongoing? + Time.current.between?(start_time, end_time) + end + + def can_be_cancelled? + %w[scheduled].include?(status) && is_upcoming? + end + + def can_be_completed? + %w[scheduled ongoing].include?(status) + end + + def required_player_names + return [] if required_players.blank? + + Player.where(id: required_players).pluck(:summoner_name) + end + + def optional_player_names + return [] if optional_players.blank? + + Player.where(id: optional_players).pluck(:summoner_name) + end + + def all_participants + required_player_names + optional_player_names + end + + def reminder_times + return [] if reminder_minutes.blank? + + reminder_minutes.map do |minutes| + start_time - minutes.minutes + end + end + + def next_reminder + now = Time.current + reminder_times.select { |time| time > now }.min + end + + def conflict_with?(other_schedule) + return false if other_schedule == self + + time_overlap?(other_schedule) && participant_overlap?(other_schedule) + end + + def mark_as_completed! + update!(status: 'completed') + end + + def mark_as_cancelled! + update!(status: 'cancelled') + end + + def mark_as_ongoing! + update!(status: 'ongoing') + end + + private + + def end_time_after_start_time + return unless start_time && end_time + + errors.add(:end_time, 'must be after start time') if end_time <= start_time + end + + def set_timezone_if_blank + self.timezone = 'UTC' if timezone.blank? + end + + def time_overlap?(other) + start_time < other.end_time && end_time > other.start_time + end + + def participant_overlap?(other) + our_participants = required_players + optional_players + other_participants = other.required_players + other.optional_players + + our_participants.intersect?(other_participants) + end + + def log_audit_trail + AuditLog.create!( + organization: organization, + action: 'update', + entity_type: 'Schedule', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/scouting_target.rb b/app/models/scouting_target.rb index c2b7173..7e8bf5a 100644 --- a/app/models/scouting_target.rb +++ b/app/models/scouting_target.rb @@ -1,198 +1,214 @@ -class ScoutingTarget < ApplicationRecord - # Associations - belongs_to :organization - belongs_to :added_by, class_name: 'User', optional: true - belongs_to :assigned_to, class_name: 'User', optional: true - - # Validations - validates :summoner_name, presence: true, length: { maximum: 100 } - validates :region, presence: true, inclusion: { in: %w[BR NA EUW KR EUNE LAN LAS OCE RU TR JP] } - validates :role, presence: true, inclusion: { in: %w[top jungle mid adc support] } - validates :status, inclusion: { in: %w[watching contacted negotiating rejected signed] } - validates :priority, inclusion: { in: %w[low medium high critical] } - validates :riot_puuid, uniqueness: true, allow_blank: true - validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true - - # Callbacks - before_save :normalize_summoner_name - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_status, ->(status) { where(status: status) } - scope :by_priority, ->(priority) { where(priority: priority) } - scope :by_role, ->(role) { where(role: role) } - scope :by_region, ->(region) { where(region: region) } - scope :active, -> { where(status: %w[watching contacted negotiating]) } - scope :high_priority, -> { where(priority: %w[high critical]) } - scope :needs_review, -> { where('last_reviewed IS NULL OR last_reviewed < ?', 1.week.ago) } - scope :assigned_to_user, ->(user_id) { where(assigned_to_id: user_id) } - - # Instance methods - def current_rank_display - return 'Unranked' if current_tier.blank? - - rank_part = current_rank.present? ? " #{current_rank}" : "" - lp_part = current_lp.present? ? " (#{current_lp} LP)" : "" - - "#{current_tier.titleize}#{rank_part}#{lp_part}" - end - - def status_color - case status - when 'watching' then 'blue' - when 'contacted' then 'yellow' - when 'negotiating' then 'orange' - when 'rejected' then 'red' - when 'signed' then 'green' - else 'gray' - end - end - - def priority_color - case priority - when 'low' then 'gray' - when 'medium' then 'blue' - when 'high' then 'orange' - when 'critical' then 'red' - else 'gray' - end - end - - def priority_score - case priority - when 'low' then 1 - when 'medium' then 2 - when 'high' then 3 - when 'critical' then 4 - else 0 - end - end - - def performance_trend_color - case performance_trend - when 'improving' then 'green' - when 'stable' then 'blue' - when 'declining' then 'red' - else 'gray' - end - end - - def needs_review? - last_reviewed.blank? || last_reviewed < 1.week.ago - end - - def days_since_review - return 'Never' if last_reviewed.blank? - - days = (Date.current - last_reviewed.to_date).to_i - case days - when 0 then 'Today' - when 1 then 'Yesterday' - else "#{days} days ago" - end - end - - def contact_info - info = {} - info[:email] = email if email.present? - info[:phone] = phone if phone.present? - info[:discord] = discord_username if discord_username.present? - info[:twitter] = "https://twitter.com/#{twitter_handle}" if twitter_handle.present? - info - end - - def main_champions - champion_pool.first(3) - end - - def estimated_salary_range - # This would be based on tier, region, and performance - case current_tier&.upcase - when 'CHALLENGER', 'GRANDMASTER' - case region.upcase - when 'BR' then '$3,000 - $8,000' - when 'NA', 'EUW' then '$5,000 - $15,000' - when 'KR' then '$8,000 - $20,000' - else '$2,000 - $6,000' - end - when 'MASTER' - case region.upcase - when 'BR' then '$1,500 - $4,000' - when 'NA', 'EUW' then '$2,500 - $8,000' - when 'KR' then '$4,000 - $12,000' - else '$1,000 - $3,000' - end - else - '$500 - $2,000' - end - end - - def scouting_score - score = 0 - - # Rank scoring - score += case current_tier&.upcase - when 'CHALLENGER' then 100 - when 'GRANDMASTER' then 90 - when 'MASTER' then 80 - when 'DIAMOND' then 60 - when 'EMERALD' then 40 - when 'PLATINUM' then 25 - else 10 - end - - # Performance trend - score += case performance_trend - when 'improving' then 20 - when 'stable' then 10 - when 'declining' then -10 - else 0 - end - - # Champion pool diversity - pool_size = champion_pool.size - score += case pool_size - when 0..2 then -10 - when 3..5 then 0 - when 6..8 then 10 - else 5 - end - - [score, 0].max - end - - def mark_as_reviewed!(user = nil) - update!( - last_reviewed: Time.current, - assigned_to: user || assigned_to - ) - end - - def advance_status! - new_status = case status - when 'watching' then 'contacted' - when 'contacted' then 'negotiating' - when 'negotiating' then 'signed' - else status - end - - update!(status: new_status, last_reviewed: Time.current) - end - - private - - 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', - entity_type: 'ScoutingTarget', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +class ScoutingTarget < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + belongs_to :added_by, class_name: 'User', optional: true + belongs_to :assigned_to, class_name: 'User', optional: true + + # Validations + validates :summoner_name, presence: true, length: { maximum: 100 } + validates :region, presence: true, inclusion: { in: Constants::REGIONS } + validates :role, presence: true, inclusion: { in: Constants::Player::ROLES } + validates :status, inclusion: { in: Constants::ScoutingTarget::STATUSES } + validates :priority, inclusion: { in: Constants::ScoutingTarget::PRIORITIES } + validates :riot_puuid, uniqueness: true, allow_blank: true + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true + + # Callbacks + before_save :normalize_summoner_name + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_status, ->(status) { where(status: status) } + scope :by_priority, ->(priority) { where(priority: priority) } + scope :by_role, ->(role) { where(role: role) } + scope :by_region, ->(region) { where(region: region) } + scope :active, -> { where(status: %w[watching contacted negotiating]) } + scope :high_priority, -> { where(priority: %w[high critical]) } + scope :needs_review, -> { where('last_reviewed IS NULL OR last_reviewed < ?', 1.week.ago) } + scope :assigned_to_user, ->(user_id) { where(assigned_to_id: user_id) } + + # Instance methods + # Returns formatted display of current ranked status + # @return [String] Formatted rank (e.g., "Diamond II (75 LP)" or "Unranked") + def current_rank_display + return 'Unranked' if current_tier.blank? + + rank_part = current_rank&.then { |r| " #{r}" } || '' + lp_part = current_lp&.then { |lp| " (#{lp} LP)" } || '' + + "#{current_tier.titleize}#{rank_part}#{lp_part}" + end + + def status_color + case status + when 'watching' then 'blue' + when 'contacted' then 'yellow' + when 'negotiating' then 'orange' + when 'rejected' then 'red' + when 'signed' then 'green' + else 'gray' + end + end + + def priority_color + case priority + when 'low' then 'gray' + when 'medium' then 'blue' + when 'high' then 'orange' + when 'critical' then 'red' + else 'gray' + end + end + + def priority_score + case priority + when 'low' then 1 + when 'medium' then 2 + when 'high' then 3 + when 'critical' then 4 + else 0 + end + end + + def performance_trend_color + case performance_trend + when 'improving' then 'green' + when 'stable' then 'blue' + when 'declining' then 'red' + else 'gray' + end + end + + def needs_review? + last_reviewed.blank? || last_reviewed < 1.week.ago + end + + def days_since_review + return 'Never' if last_reviewed.blank? + + days = (Date.current - last_reviewed.to_date).to_i + case days + when 0 then 'Today' + when 1 then 'Yesterday' + else "#{days} days ago" + end + end + + # Returns hash of contact information for the target + # @return [Hash] Contact details (only includes present values) + def contact_info + { + email: email, + phone: phone, + discord: discord_username, + twitter: twitter_handle&.then { |h| "https://twitter.com/#{h}" } + }.compact + end + + def main_champions + champion_pool.first(3) + end + + def estimated_salary_range + # This would be based on tier, region, and performance + case current_tier&.upcase + when 'CHALLENGER', 'GRANDMASTER' + case region.upcase + when 'BR' then '$3,000 - $8,000' + when 'NA', 'EUW' then '$5,000 - $15,000' + when 'KR' then '$8,000 - $20,000' + else '$2,000 - $6,000' + end + when 'MASTER' + case region.upcase + when 'BR' then '$1,500 - $4,000' + when 'NA', 'EUW' then '$2,500 - $8,000' + when 'KR' then '$4,000 - $12,000' + else '$1,000 - $3,000' + end + else + '$500 - $2,000' + end + end + + # Calculates overall scouting score (0-130) + # + # @return [Integer] Scouting score based on rank, trend, and champion pool + def scouting_score + total = rank_score + trend_score + pool_diversity_score + [total, 0].max + end + + def mark_as_reviewed!(user = nil) + update!( + last_reviewed: Time.current, + assigned_to: user || assigned_to + ) + end + + def advance_status! + new_status = case status + when 'watching' then 'contacted' + when 'contacted' then 'negotiating' + when 'negotiating' then 'signed' + else status + end + + update!(status: new_status, last_reviewed: Time.current) + end + + private + + # Scores based on current rank (10-100 points) + def rank_score + case current_tier&.upcase + when 'CHALLENGER' then 100 + when 'GRANDMASTER' then 90 + when 'MASTER' then 80 + when 'DIAMOND' then 60 + when 'EMERALD' then 40 + when 'PLATINUM' then 25 + else 10 + end + end + + # Scores based on performance trend (-10 to 20 points) + def trend_score + case performance_trend + when 'improving' then 20 + when 'stable' then 10 + when 'declining' then -10 + else 0 + end + end + + # Scores based on champion pool diversity (-10 to 10 points) + def pool_diversity_score + case champion_pool.size + when 0..2 then -10 + when 3..5 then 0 + when 6..8 then 10 + else 5 + end + end + + 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', + entity_type: 'ScoutingTarget', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/scrim.rb b/app/models/scrim.rb new file mode 100644 index 0000000..e959e56 --- /dev/null +++ b/app/models/scrim.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# Model representing practice scrimmage sessions and their outcomes +# +# This model manages scrim (practice match) scheduling, tracking, and results. +# It stores game plans, focus areas, opponent information, game results, and +# objectives with outcomes. Supports confidential scrims and progress tracking. +# +# Associated with: +# - Organization: The organization participating in the scrim +# - Match: Link to match record if available (optional) +# - OpponentTeam: The practice opponent (optional) +# +# @example Schedule a scrim +# scrim = Scrim.create( +# organization: org, +# opponent_team: team, +# scrim_type: 'internal', +# focus_area: 'early_game', +# games_planned: 3, +# scheduled_at: 2.days.from_now, +# is_confidential: false +# ) +# +# @example Record game results +# scrim.add_game_result( +# victory: true, +# duration: 1800, +# notes: 'Good early game execution' +# ) +# +# @example Query upcoming scrims +# Scrim.upcoming.by_focus_area('team_fighting') +class Scrim < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + belongs_to :match, optional: true + belongs_to :opponent_team, optional: true + + # Validations + validates :scrim_type, inclusion: { + in: Constants::Scrim::TYPES, + message: '%{value} is not a valid scrim type' + }, allow_blank: true + + validates :focus_area, inclusion: { + in: Constants::Scrim::FOCUS_AREAS, + message: '%{value} is not a valid focus area' + }, allow_blank: true + + validates :visibility, inclusion: { + in: Constants::Scrim::VISIBILITY_LEVELS, + message: '%{value} is not a valid visibility level' + }, allow_blank: true + + validates :games_planned, numericality: { greater_than: 0 }, allow_nil: true + validates :games_completed, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + + validate :games_completed_not_greater_than_planned + + # Scopes + scope :upcoming, -> { where('scheduled_at > ?', Time.current).order(scheduled_at: :asc) } + scope :past, -> { where('scheduled_at <= ?', Time.current).order(scheduled_at: :desc) } + scope :by_type, ->(type) { where(scrim_type: type) } + scope :by_focus_area, ->(area) { where(focus_area: area) } + scope :completed, -> { where.not(games_completed: nil).where('games_completed >= games_planned') } + scope :in_progress, -> { where.not(games_completed: nil).where('games_completed < games_planned') } + scope :confidential, -> { where(is_confidential: true) } + scope :publicly_visible, -> { where(is_confidential: false) } + scope :recent, ->(days = 30) { where('scheduled_at > ?', days.days.ago).order(scheduled_at: :desc) } + + # Instance methods + def completion_percentage + return 0 unless games_planned&.positive? + return 0 unless games_completed + + ((games_completed.to_f / games_planned) * 100).round(2) + end + + def status + return 'upcoming' unless scheduled_at&.<= Time.current + + if games_completed&.zero? || !games_completed + 'not_started' + elsif games_completed >= (games_planned || 1) + 'completed' + else + 'in_progress' + end + end + + def win_rate + return 0 if game_results.blank? + + wins = game_results.count { |result| result['victory'] == true } + total = game_results.size + + return 0 if total.zero? + + ((wins.to_f / total) * 100).round(2) + end + + def add_game_result(victory:, duration: nil, notes: nil) + result = { + game_number: (game_results.size + 1), + victory: victory, + duration: duration, + notes: notes, + played_at: Time.current + } + + game_results << result + self.games_completed = (games_completed || 0) + 1 + + save + end + + def objectives_met? + return false if objectives.blank? || outcomes.blank? + + objectives.keys.all? { |key| outcomes[key].present? } + end + + private + + def games_completed_not_greater_than_planned + return unless games_planned && games_completed + return unless games_completed > games_planned + + errors.add(:games_completed, "cannot be greater than games planned (#{games_planned})") + end +end diff --git a/app/models/team_goal.rb b/app/models/team_goal.rb index f2b5a1d..389284e 100644 --- a/app/models/team_goal.rb +++ b/app/models/team_goal.rb @@ -1,199 +1,243 @@ -class TeamGoal < ApplicationRecord - # Associations - belongs_to :organization - belongs_to :player, optional: true - belongs_to :assigned_to, class_name: 'User', optional: true - belongs_to :created_by, class_name: 'User', optional: true - - # Validations - validates :title, presence: true, length: { maximum: 255 } - validates :category, inclusion: { in: %w[performance rank tournament skill] }, allow_blank: true - validates :metric_type, inclusion: { in: %w[win_rate kda cs_per_min vision_score damage_share rank_climb] }, allow_blank: true - validates :start_date, :end_date, presence: true - validates :status, inclusion: { in: %w[active completed failed cancelled] } - validates :progress, numericality: { in: 0..100 } - validate :end_date_after_start_date - - # Callbacks - before_save :calculate_progress_if_needed - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_status, ->(status) { where(status: status) } - scope :by_category, ->(category) { where(category: category) } - scope :active, -> { where(status: 'active') } - scope :team_goals, -> { where(player_id: nil) } - scope :player_goals, -> { where.not(player_id: nil) } - scope :for_player, ->(player_id) { where(player_id: player_id) } - scope :expiring_soon, ->(days = 7) { where(end_date: Date.current..Date.current + days.days) } - scope :overdue, -> { where('end_date < ? AND status = ?', Date.current, 'active') } - - # Instance methods - def is_team_goal? - player_id.nil? - end - - def is_player_goal? - player_id.present? - end - - def days_remaining - return 0 if end_date < Date.current - - (end_date - Date.current).to_i - end - - def days_total - (end_date - start_date).to_i - end - - def days_elapsed - return days_total if Date.current > end_date - - [(Date.current - start_date).to_i, 0].max - end - - def time_progress_percentage - return 100 if Date.current >= end_date - - (days_elapsed.to_f / days_total * 100).round(1) - end - - def is_overdue? - Date.current > end_date && status == 'active' - end - - def is_expiring_soon?(days = 7) - days_remaining <= days && status == 'active' - end - - def status_color - case status - when 'active' then is_overdue? ? 'red' : 'blue' - when 'completed' then 'green' - when 'failed' then 'red' - when 'cancelled' then 'gray' - else 'gray' - end - end - - def progress_color - case progress - when 0...25 then 'red' - when 25...50 then 'orange' - when 50...75 then 'yellow' - when 75...90 then 'blue' - when 90..100 then 'green' - else 'gray' - end - end - - def target_display - return 'N/A' if target_value.blank? - - case metric_type - when 'win_rate' then "#{target_value}%" - when 'kda' then target_value.to_s - when 'cs_per_min' then "#{target_value} CS/min" - when 'vision_score' then "#{target_value} Vision Score" - when 'damage_share' then "#{target_value}% Damage Share" - when 'rank_climb' then rank_display(target_value.to_i) - else target_value.to_s - end - end - - def current_display - return 'N/A' if current_value.blank? - - case metric_type - when 'win_rate' then "#{current_value}%" - when 'kda' then current_value.to_s - when 'cs_per_min' then "#{current_value} CS/min" - when 'vision_score' then "#{current_value} Vision Score" - when 'damage_share' then "#{current_value}% Damage Share" - when 'rank_climb' then rank_display(current_value.to_i) - else current_value.to_s - end - end - - def completion_percentage - return 0 if target_value.blank? || current_value.blank? || target_value.zero? - - [(current_value / target_value * 100).round(1), 100].min - end - - def mark_as_completed! - update!( - status: 'completed', - progress: 100, - current_value: target_value - ) - end - - def mark_as_failed! - update!(status: 'failed') - end - - def mark_as_cancelled! - update!(status: 'cancelled') - end - - def update_progress!(new_current_value) - self.current_value = new_current_value - calculate_progress_if_needed - save! - end - - def assigned_to_name - assigned_to&.full_name || assigned_to&.email&.split('@')&.first || 'Unassigned' - end - - def player_name - player&.summoner_name || 'Team Goal' - end - - def self.metrics_for_role(role) - case role - when 'adc', 'mid' - %w[win_rate kda cs_per_min damage_share] - when 'support' - %w[win_rate kda vision_score] - when 'jungle' - %w[win_rate kda vision_score damage_share] - when 'top' - %w[win_rate kda cs_per_min] - else - %w[win_rate kda] - end - end - - private - - def end_date_after_start_date - return unless start_date && end_date - - errors.add(:end_date, 'must be after start date') if end_date <= start_date - end - - def calculate_progress_if_needed - return unless target_value.present? && current_value.present? - - self.progress = completion_percentage.round - end - - def rank_display(tier_number) - tiers = %w[Iron Bronze Silver Gold Platinum Emerald Diamond Master Grandmaster Challenger] - tiers[tier_number] || 'Unknown' - end - - def log_audit_trail - AuditLog.create!( - organization: organization, - action: 'update', - entity_type: 'TeamGoal', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +# Model representing team and individual player goals with progress tracking +# +# This model manages goal-setting and tracking for both team-wide objectives and +# individual player targets. It supports various metrics (win rate, KDA, CS/min, etc.), +# tracks progress over time, and provides deadline management with status indicators. +# +# Associated with: +# - Organization: The organization setting the goal +# - Player: The player for individual goals (nil for team goals) +# - AssignedTo: The user responsible for achieving the goal (optional) +# - CreatedBy: The user who created the goal (optional) +# +# @example Create a team goal +# goal = TeamGoal.create( +# organization: org, +# title: 'Reach 65% Win Rate', +# category: 'performance', +# metric_type: 'win_rate', +# target_value: 65, +# current_value: 52, +# start_date: Date.today, +# end_date: 1.month.from_now, +# status: 'active' +# ) +# +# @example Create a player goal +# goal = TeamGoal.create( +# organization: org, +# player: player, +# title: 'Improve CS/min to 8.5', +# metric_type: 'cs_per_min', +# target_value: 8.5, +# current_value: 7.2 +# ) +# +# @example Track progress +# goal.update_progress!(8.0) +# goal.mark_as_completed! if goal.progress >= 100 +class TeamGoal < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + belongs_to :player, optional: true + belongs_to :assigned_to, class_name: 'User', optional: true + belongs_to :created_by, class_name: 'User', optional: true + + # Validations + validates :title, presence: true, length: { maximum: 255 } + validates :category, inclusion: { in: Constants::TeamGoal::CATEGORIES }, allow_blank: true + validates :metric_type, inclusion: { in: Constants::TeamGoal::METRIC_TYPES }, allow_blank: true + validates :start_date, :end_date, presence: true + validates :status, inclusion: { in: Constants::TeamGoal::STATUSES } + validates :progress, numericality: { in: 0..100 } + validate :end_date_after_start_date + + # Callbacks + before_save :calculate_progress_if_needed + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_status, ->(status) { where(status: status) } + scope :by_category, ->(category) { where(category: category) } + scope :active, -> { where(status: 'active') } + scope :team_goals, -> { where(player_id: nil) } + scope :player_goals, -> { where.not(player_id: nil) } + scope :for_player, ->(player_id) { where(player_id: player_id) } + scope :expiring_soon, ->(days = 7) { where(end_date: Date.current..Date.current + days.days) } + scope :overdue, -> { where('end_date < ? AND status = ?', Date.current, 'active') } + + # Instance methods + def is_team_goal? + player_id.nil? + end + + def is_player_goal? + player_id.present? + end + + def days_remaining + return 0 if end_date < Date.current + + (end_date - Date.current).to_i + end + + def days_total + (end_date - start_date).to_i + end + + def days_elapsed + return days_total if Date.current > end_date + + [(Date.current - start_date).to_i, 0].max + end + + def time_progress_percentage + return 100 if Date.current >= end_date + + (days_elapsed.to_f / days_total * 100).round(1) + end + + def is_overdue? + Date.current > end_date && status == 'active' + end + + def is_expiring_soon?(days = 7) + days_remaining <= days && status == 'active' + end + + def status_color + case status + when 'active' then is_overdue? ? 'red' : 'blue' + when 'completed' then 'green' + when 'failed' then 'red' + when 'cancelled' then 'gray' + else 'gray' + end + end + + def progress_color + case progress + when 0...25 then 'red' + when 25...50 then 'orange' + when 50...75 then 'yellow' + when 75...90 then 'blue' + when 90..100 then 'green' + else 'gray' + end + end + + def target_display + return 'N/A' if target_value.blank? + + case metric_type + when 'win_rate' then "#{target_value}%" + when 'kda' then target_value.to_s + when 'cs_per_min' then "#{target_value} CS/min" + when 'vision_score' then "#{target_value} Vision Score" + when 'damage_share' then "#{target_value}% Damage Share" + when 'rank_climb' then rank_display(target_value.to_i) + else target_value.to_s + end + end + + def current_display + return 'N/A' if current_value.blank? + + case metric_type + when 'win_rate' then "#{current_value}%" + when 'kda' then current_value.to_s + when 'cs_per_min' then "#{current_value} CS/min" + when 'vision_score' then "#{current_value} Vision Score" + when 'damage_share' then "#{current_value}% Damage Share" + when 'rank_climb' then rank_display(current_value.to_i) + else current_value.to_s + end + end + + def completion_percentage + return 0 if target_value.blank? || current_value.blank? || target_value.zero? + + [(current_value / target_value * 100).round(1), 100].min + end + + def mark_as_completed! + update!( + status: 'completed', + progress: 100, + current_value: target_value + ) + end + + def mark_as_failed! + update!(status: 'failed') + end + + def mark_as_cancelled! + update!(status: 'cancelled') + end + + def update_progress!(new_current_value) + self.current_value = new_current_value + calculate_progress_if_needed + save! + end + + def assigned_to_name + return 'Unassigned' unless assigned_to + assigned_to.full_name || (assigned_to.email&.split('@')&.first) || 'Unassigned' + end + + def player_name + player&.summoner_name || 'Team Goal' + end + + def self.metrics_for_role(role) + case role + when 'adc', 'mid' + %w[win_rate kda cs_per_min damage_share] + when 'support' + %w[win_rate kda vision_score] + when 'jungle' + %w[win_rate kda vision_score damage_share] + when 'top' + %w[win_rate kda cs_per_min] + else + %w[win_rate kda] + end + end + + private + + def end_date_after_start_date + return unless start_date && end_date + + errors.add(:end_date, 'must be after start date') if end_date <= start_date + end + + def calculate_progress_if_needed + return unless target_value.present? && current_value.present? + + self.progress = completion_percentage.round + end + + def rank_display(tier_number) + tiers = %w[Iron Bronze Silver Gold Platinum Emerald Diamond Master Grandmaster Challenger] + tiers[tier_number] || 'Unknown' + end + + def log_audit_trail + AuditLog.create!( + organization: organization, + action: 'update', + entity_type: 'TeamGoal', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/token_blacklist.rb b/app/models/token_blacklist.rb new file mode 100644 index 0000000..68203f1 --- /dev/null +++ b/app/models/token_blacklist.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class TokenBlacklist < ApplicationRecord + validates :jti, presence: true, uniqueness: true + validates :expires_at, presence: true + + scope :expired, -> { where('expires_at <= ?', Time.current) } + scope :valid, -> { where('expires_at > ?', Time.current) } + + def self.blacklisted?(jti) + valid.exists?(jti: jti) + end + + def self.add_to_blacklist(jti, expires_at) + create!(jti: jti, expires_at: expires_at) + rescue ActiveRecord::RecordInvalid + nil + end + + def self.cleanup_expired + expired.delete_all + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 63fa0a0..b1346a9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,74 +1,80 @@ -class User < ApplicationRecord - has_secure_password - - # Associations - belongs_to :organization - has_many :added_scouting_targets, class_name: 'ScoutingTarget', foreign_key: 'added_by_id', dependent: :nullify - has_many :assigned_scouting_targets, class_name: 'ScoutingTarget', foreign_key: 'assigned_to_id', dependent: :nullify - has_many :created_schedules, class_name: 'Schedule', foreign_key: 'created_by_id', dependent: :nullify - has_many :reviewed_vods, class_name: 'VodReview', foreign_key: 'reviewer_id', dependent: :nullify - has_many :created_vod_timestamps, class_name: 'VodTimestamp', foreign_key: 'created_by_id', dependent: :nullify - has_many :assigned_goals, class_name: 'TeamGoal', foreign_key: 'assigned_to_id', dependent: :nullify - has_many :created_goals, class_name: 'TeamGoal', foreign_key: 'created_by_id', dependent: :nullify - has_many :notifications, dependent: :destroy - has_many :audit_logs, dependent: :destroy - - # Validations - validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :full_name, length: { maximum: 255 } - validates :role, presence: true, inclusion: { in: %w[owner admin coach analyst viewer] } - validates :timezone, length: { maximum: 100 } - validates :language, length: { maximum: 10 } - - # Callbacks - before_save :downcase_email - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_role, ->(role) { where(role: role) } - scope :by_organization, ->(org_id) { where(organization_id: org_id) } - scope :active, -> { where.not(last_login_at: nil) } - - # Instance methods - def admin_or_owner? - %w[admin owner].include?(role) - end - - def can_manage_users? - %w[owner admin].include?(role) - end - - def can_manage_players? - %w[owner admin coach].include?(role) - end - - def can_view_analytics? - %w[owner admin coach analyst].include?(role) - end - - def full_role_name - role.titleize - end - - def update_last_login! - update_column(:last_login_at, Time.current) - end - - private - - def downcase_email - self.email = email.downcase.strip if email.present? - end - - def log_audit_trail - AuditLog.create!( - organization: organization, - user: self, - action: 'update', - entity_type: 'User', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +class User < ApplicationRecord + has_secure_password + + # Concerns + include Constants + + # Associations + belongs_to :organization + has_many :added_scouting_targets, class_name: 'ScoutingTarget', foreign_key: 'added_by_id', dependent: :nullify + has_many :assigned_scouting_targets, class_name: 'ScoutingTarget', foreign_key: 'assigned_to_id', dependent: :nullify + has_many :created_schedules, class_name: 'Schedule', foreign_key: 'created_by_id', dependent: :nullify + has_many :reviewed_vods, class_name: 'VodReview', foreign_key: 'reviewer_id', dependent: :nullify + has_many :created_vod_timestamps, class_name: 'VodTimestamp', foreign_key: 'created_by_id', dependent: :nullify + has_many :assigned_goals, class_name: 'TeamGoal', foreign_key: 'assigned_to_id', dependent: :nullify + has_many :created_goals, class_name: 'TeamGoal', foreign_key: 'created_by_id', dependent: :nullify + has_many :notifications, dependent: :destroy + has_many :audit_logs, dependent: :destroy + has_many :password_reset_tokens, dependent: :destroy + + # Validations + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :full_name, length: { maximum: 255 } + validates :role, presence: true, inclusion: { in: Constants::User::ROLES } + validates :timezone, length: { maximum: 100 } + validates :language, length: { maximum: 10 } + + # Callbacks + before_save :downcase_email + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_role, ->(role) { where(role: role) } + scope :by_organization, ->(org_id) { where(organization_id: org_id) } + scope :active, -> { where.not(last_login_at: nil) } + + # Instance methods + def admin_or_owner? + %w[admin owner].include?(role) + end + + def can_manage_users? + %w[owner admin].include?(role) + end + + def can_manage_players? + %w[owner admin coach].include?(role) + end + + def can_view_analytics? + %w[owner admin coach analyst].include?(role) + end + + def full_role_name + role.titleize + end + + def update_last_login! + update_column(:last_login_at, Time.current) + end + + private + + def downcase_email + self.email = email.downcase.strip if email.present? + end + + def log_audit_trail + AuditLog.create!( + organization: organization, + user: self, + action: 'update', + entity_type: 'User', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/vod_review.rb b/app/models/vod_review.rb index 361a098..402877e 100644 --- a/app/models/vod_review.rb +++ b/app/models/vod_review.rb @@ -1,143 +1,175 @@ -class VodReview < ApplicationRecord - # Associations - belongs_to :organization - belongs_to :match, optional: true - belongs_to :reviewer, class_name: 'User', optional: true - has_many :vod_timestamps, dependent: :destroy - - # Validations - validates :title, presence: true, length: { maximum: 255 } - validates :video_url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp } - validates :review_type, inclusion: { in: %w[team individual opponent] }, allow_blank: true - validates :status, inclusion: { in: %w[draft published archived] } - validates :share_link, uniqueness: true, allow_blank: true - - # Callbacks - before_create :generate_share_link, if: -> { is_public? } - after_update :log_audit_trail, if: :saved_changes? - - # Scopes - scope :by_status, ->(status) { where(status: status) } - scope :by_type, ->(type) { where(review_type: type) } - scope :published, -> { where(status: 'published') } - scope :public_reviews, -> { where(is_public: true) } - scope :for_match, ->(match_id) { where(match_id: match_id) } - scope :recent, ->(days = 30) { where(created_at: days.days.ago..Time.current) } - - # Instance methods - def duration_formatted - return 'Unknown' if duration.blank? - - hours = duration / 3600 - minutes = (duration % 3600) / 60 - seconds = duration % 60 - - if hours > 0 - "#{hours}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}" - else - "#{minutes}:#{seconds.to_s.rjust(2, '0')}" - end - end - - def status_color - case status - when 'draft' then 'yellow' - when 'published' then 'green' - when 'archived' then 'gray' - else 'gray' - end - end - - def can_be_edited_by?(user) - reviewer == user || user.admin_or_owner? - end - - def can_be_viewed_by?(user) - return true if is_public? - return true if reviewer == user || user.admin_or_owner? - - shared_with_players.include?(user.id) - end - - def shared_player_names - return [] if shared_with_players.blank? - - Player.where(id: shared_with_players).pluck(:summoner_name) - end - - def timestamp_count - vod_timestamps.count - end - - def timestamp_categories - vod_timestamps.group(:category).count - end - - def important_timestamps - vod_timestamps.where(importance: %w[high critical]).order(:timestamp_seconds) - end - - def publish! - update!( - status: 'published', - share_link: generate_share_link_value - ) - end - - def archive! - update!(status: 'archived') - end - - def make_public! - update!( - is_public: true, - share_link: share_link.presence || generate_share_link_value - ) - end - - def make_private! - update!(is_public: false) - end - - def share_with_player!(player_id) - return if shared_with_players.include?(player_id) - - update!(shared_with_players: shared_with_players + [player_id]) - end - - def unshare_with_player!(player_id) - update!(shared_with_players: shared_with_players - [player_id]) - end - - def share_with_all_players! - player_ids = organization.players.pluck(:id) - update!(shared_with_players: player_ids) - end - - def public_url - return nil unless is_public? && share_link.present? - - "#{ENV['FRONTEND_URL']}/vod-reviews/#{share_link}" - end - - private - - def generate_share_link - self.share_link = generate_share_link_value - end - - def generate_share_link_value - SecureRandom.urlsafe_base64(16) - end - - def log_audit_trail - AuditLog.create!( - organization: organization, - action: 'update', - entity_type: 'VodReview', - entity_id: id, - old_values: saved_changes.transform_values(&:first), - new_values: saved_changes.transform_values(&:last) - ) - end -end \ No newline at end of file +# frozen_string_literal: true + +# Model representing video (VOD) reviews with timestamped analysis +# +# This model manages video-on-demand reviews for matches and practice sessions. +# It stores video URLs, review content, timestamps with categorized feedback, +# and supports sharing with specific players or making reviews public. +# +# Associated with: +# - Organization: The organization conducting the review +# - Match: The match being reviewed (optional) +# - Reviewer: The user conducting the review (optional) +# - VodTimestamps: Timestamped feedback points (has_many, dependent destroy) +# +# @example Create a VOD review +# review = VodReview.create( +# organization: org, +# match: match, +# reviewer: coach, +# title: 'Semifinals Game 3 Review', +# video_url: 'https://youtube.com/watch?v=example', +# review_type: 'match_review', +# status: 'draft' +# ) +# +# @example Publish and share review +# review.publish! +# review.make_public! +# review.share_with_all_players! +# +# @example Access important timestamps +# review.important_timestamps.each { |ts| puts ts.note } +class VodReview < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :organization + belongs_to :match, optional: true + belongs_to :reviewer, class_name: 'User', optional: true + has_many :vod_timestamps, dependent: :destroy + + # Validations + validates :title, presence: true, length: { maximum: 255 } + validates :video_url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp } + validates :review_type, inclusion: { in: Constants::VodReview::TYPES }, allow_blank: true + validates :status, inclusion: { in: Constants::VodReview::STATUSES } + validates :share_link, uniqueness: true, allow_blank: true + + # Callbacks + before_create :generate_share_link, if: -> { is_public? } + after_update :log_audit_trail, if: :saved_changes? + + # Scopes + scope :by_status, ->(status) { where(status: status) } + scope :by_type, ->(type) { where(review_type: type) } + scope :published, -> { where(status: 'published') } + scope :public_reviews, -> { where(is_public: true) } + scope :for_match, ->(match_id) { where(match_id: match_id) } + scope :recent, ->(days = 30) { where(created_at: days.days.ago..Time.current) } + + # Instance methods + def duration_formatted + return 'Unknown' if duration.blank? + + hours = duration / 3600 + minutes = (duration % 3600) / 60 + seconds = duration % 60 + + if hours.positive? + "#{hours}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}" + else + "#{minutes}:#{seconds.to_s.rjust(2, '0')}" + end + end + + def status_color + return 'yellow' if status == 'draft' + return 'green' if status == 'published' + 'gray' + end + + def can_be_edited_by?(user) + reviewer == user || user.admin_or_owner? + end + + def can_be_viewed_by?(user) + return true if is_public? + return true if reviewer == user || user.admin_or_owner? + + shared_with_players.include?(user.id) + end + + def shared_player_names + return [] if shared_with_players.blank? + + Player.where(id: shared_with_players).pluck(:summoner_name) + end + + def timestamp_count + vod_timestamps.count + end + + def timestamp_categories + vod_timestamps.group(:category).count + end + + def important_timestamps + vod_timestamps.where(importance: %w[high critical]).order(:timestamp_seconds) + end + + def publish! + update!( + status: 'published', + share_link: generate_share_link_value + ) + end + + def archive! + update!(status: 'archived') + end + + def make_public! + update!( + is_public: true, + share_link: share_link.presence || generate_share_link_value + ) + end + + def make_private! + update!(is_public: false) + end + + def share_with_player!(player_id) + return if shared_with_players.include?(player_id) + + update!(shared_with_players: shared_with_players + [player_id]) + end + + def unshare_with_player!(player_id) + update!(shared_with_players: shared_with_players - [player_id]) + end + + def share_with_all_players! + player_ids = organization.players.pluck(:id) + update!(shared_with_players: player_ids) + end + + def public_url + return nil unless is_public? && share_link.present? + + "#{ENV['FRONTEND_URL']}/vod-reviews/#{share_link}" + end + + private + + def generate_share_link + self.share_link = generate_share_link_value + end + + def generate_share_link_value + SecureRandom.urlsafe_base64(16) + end + + def log_audit_trail + AuditLog.create!( + organization: organization, + action: 'update', + entity_type: 'VodReview', + entity_id: id, + old_values: saved_changes.transform_values(&:first), + new_values: saved_changes.transform_values(&:last) + ) + end +end diff --git a/app/models/vod_timestamp.rb b/app/models/vod_timestamp.rb index c5237c9..dd28c44 100644 --- a/app/models/vod_timestamp.rb +++ b/app/models/vod_timestamp.rb @@ -1,131 +1,133 @@ -class VodTimestamp < ApplicationRecord - # Associations - belongs_to :vod_review - belongs_to :target_player, class_name: 'Player', optional: true - belongs_to :created_by, class_name: 'User', optional: true - - # Validations - validates :timestamp_seconds, presence: true, numericality: { greater_than_or_equal_to: 0 } - validates :title, presence: true, length: { maximum: 255 } - validates :category, inclusion: { in: %w[mistake good_play team_fight objective laning] }, allow_blank: true - validates :importance, inclusion: { in: %w[low normal high critical] } - validates :target_type, inclusion: { in: %w[player team opponent] }, allow_blank: true - - # Scopes - scope :by_category, ->(category) { where(category: category) } - scope :by_importance, ->(importance) { where(importance: importance) } - scope :by_target_type, ->(type) { where(target_type: type) } - scope :important, -> { where(importance: %w[high critical]) } - scope :chronological, -> { order(:timestamp_seconds) } - scope :for_player, ->(player_id) { where(target_player_id: player_id) } - - # Instance methods - def timestamp_formatted - hours = timestamp_seconds / 3600 - minutes = (timestamp_seconds % 3600) / 60 - seconds = timestamp_seconds % 60 - - if hours > 0 - "#{hours}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}" - else - "#{minutes}:#{seconds.to_s.rjust(2, '0')}" - end - end - - def importance_color - case importance - when 'low' then 'gray' - when 'normal' then 'blue' - when 'high' then 'orange' - when 'critical' then 'red' - else 'gray' - end - end - - def category_color - case category - when 'mistake' then 'red' - when 'good_play' then 'green' - when 'team_fight' then 'purple' - when 'objective' then 'blue' - when 'laning' then 'yellow' - else 'gray' - end - end - - def category_icon - case category - when 'mistake' then '⚠️' - when 'good_play' then '✅' - when 'team_fight' then '⚔️' - when 'objective' then '🎯' - when 'laning' then '🛡️' - else '📝' - end - end - - def target_display - case target_type - when 'player' - target_player&.summoner_name || 'Unknown Player' - when 'team' - 'Team' - when 'opponent' - 'Opponent' - else - 'General' - end - end - - def video_url_with_timestamp - base_url = vod_review.video_url - return base_url unless base_url.present? - - # Handle YouTube URLs - if base_url.include?('youtube.com') || base_url.include?('youtu.be') - separator = base_url.include?('?') ? '&' : '?' - "#{base_url}#{separator}t=#{timestamp_seconds}s" - # Handle Twitch URLs - elsif base_url.include?('twitch.tv') - separator = base_url.include?('?') ? '&' : '?' - "#{base_url}#{separator}t=#{timestamp_seconds}s" - else - # For other video platforms, just return the base URL - base_url - end - end - - def is_important? - %w[high critical].include?(importance) - end - - def is_mistake? - category == 'mistake' - end - - def is_highlight? - category == 'good_play' - end - - def organization - vod_review.organization - end - - def can_be_edited_by?(user) - created_by == user || user.admin_or_owner? - end - - def next_timestamp - vod_review.vod_timestamps - .where('timestamp_seconds > ?', timestamp_seconds) - .order(:timestamp_seconds) - .first - end - - def previous_timestamp - vod_review.vod_timestamps - .where('timestamp_seconds < ?', timestamp_seconds) - .order(:timestamp_seconds) - .last - end -end \ No newline at end of file +# frozen_string_literal: true + +class VodTimestamp < ApplicationRecord + # Concerns + include Constants + + # Associations + belongs_to :vod_review + belongs_to :target_player, class_name: 'Player', optional: true + belongs_to :created_by, class_name: 'User', optional: true + + # Validations + validates :timestamp_seconds, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :title, presence: true, length: { maximum: 255 } + validates :category, inclusion: { in: Constants::VodTimestamp::CATEGORIES }, allow_blank: true + validates :importance, inclusion: { in: Constants::VodTimestamp::IMPORTANCE_LEVELS } + validates :target_type, inclusion: { in: Constants::VodTimestamp::TARGET_TYPES }, allow_blank: true + + # Scopes + scope :by_category, ->(category) { where(category: category) } + scope :by_importance, ->(importance) { where(importance: importance) } + scope :by_target_type, ->(type) { where(target_type: type) } + scope :important, -> { where(importance: %w[high critical]) } + scope :chronological, -> { order(:timestamp_seconds) } + scope :for_player, ->(player_id) { where(target_player_id: player_id) } + + # Instance methods + def timestamp_formatted + hours = timestamp_seconds / 3600 + minutes = (timestamp_seconds % 3600) / 60 + seconds = timestamp_seconds % 60 + + if hours.positive? + "#{hours}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}" + else + "#{minutes}:#{seconds.to_s.rjust(2, '0')}" + end + end + + def importance_color + return 'blue' if importance == 'normal' + return 'orange' if importance == 'high' + return 'red' if importance == 'critical' + 'gray' + end + + def category_color + case category + when 'mistake' then 'red' + when 'good_play' then 'green' + when 'team_fight' then 'purple' + when 'objective' then 'blue' + when 'laning' then 'yellow' + else 'gray' + end + end + + def category_icon + case category + when 'mistake' then '⚠️' + when 'good_play' then '✅' + when 'team_fight' then '⚔️' + when 'objective' then '🎯' + when 'laning' then '🛡️' + else '📝' + end + end + + def target_display + case target_type + when 'player' + target_player&.summoner_name || 'Unknown Player' + when 'team' + 'Team' + when 'opponent' + 'Opponent' + else + 'General' + end + end + + def video_url_with_timestamp + base_url = vod_review.video_url + return base_url unless base_url.present? + + # Handle YouTube URLs + if base_url.include?('youtube.com') || base_url.include?('youtu.be') + separator = base_url.include?('?') ? '&' : '?' + "#{base_url}#{separator}t=#{timestamp_seconds}s" + # Handle Twitch URLs + elsif base_url.include?('twitch.tv') + separator = base_url.include?('?') ? '&' : '?' + "#{base_url}#{separator}t=#{timestamp_seconds}s" + else + # For other video platforms, just return the base URL + base_url + end + end + + def is_important? + %w[high critical].include?(importance) + end + + def is_mistake? + category == 'mistake' + end + + def is_highlight? + category == 'good_play' + end + + def organization + vod_review.organization + end + + def can_be_edited_by?(user) + created_by == user || user.admin_or_owner? + end + + def next_timestamp + vod_review.vod_timestamps + .where('timestamp_seconds > ?', timestamp_seconds) + .order(:timestamp_seconds) + .first + end + + def previous_timestamp + vod_review.vod_timestamps + .where('timestamp_seconds < ?', timestamp_seconds) + .order(:timestamp_seconds) + .last + end +end diff --git a/app/modules/analytics/concerns/analytics_calculations.rb b/app/modules/analytics/concerns/analytics_calculations.rb new file mode 100644 index 0000000..77e29a6 --- /dev/null +++ b/app/modules/analytics/concerns/analytics_calculations.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +module Analytics + module Concerns + # Shared utility methods for analytics calculations + # Used across controllers and services to avoid code duplication + # + # Can be used as module methods or included in classes: + # Analytics::Concerns::AnalyticsCalculations.calculate_win_rate(matches) + # # or + # include Analytics::Concerns::AnalyticsCalculations + # calculate_win_rate(matches) + module AnalyticsCalculations + extend ActiveSupport::Concern + + # Calculates win rate percentage from a collection of matches + # + # @param matches [Array, ActiveRecord::Relation] Collection of matches + # @return [Float] Win rate percentage + def calculate_win_rate(matches) + return 0.0 if matches.empty? + + total = Array.wrap(matches).size + wins = matches.try(:victories)&.count || matches.count(&:victory?) + + ((wins.to_f / total) * 100).round(1) + end + module_function :calculate_win_rate + + # Calculates average KDA (Kill/Death/Assist ratio) from player stats + # + # @param stats [Array, ActiveRecord::Relation] Player statistics + # @return [Float] Average KDA + def calculate_avg_kda(stats) + return 0.0 if stats.empty? + + total_kills = stats.try(:sum, :kills) || stats.sum(&:kills) + total_deaths = stats.try(:sum, :deaths) || stats.sum(&:deaths) + total_assists = stats.try(:sum, :assists) || stats.sum(&:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + module_function :calculate_avg_kda + + # Calculates KDA for a single set of stats + # + # @param kills [Integer] Number of kills + # @param deaths [Integer] Number of deaths + # @param assists [Integer] Number of assists + # @return [Float] KDA ratio + def calculate_kda(kills, deaths, assists) + deaths_divisor = deaths.zero? ? 1 : deaths + ((kills + assists).to_f / deaths_divisor).round(2) + end + module_function :calculate_kda + + # Generates win/loss form string (e.g., "WWLWL") + # + # @param matches [Array] Collection of matches + # @return [String] Form string + def calculate_recent_form(matches) + matches.map { |m| m.victory? ? 'W' : 'L' }.join('') + end + module_function :calculate_recent_form + + # Calculates creep score per minute + # + # @param total_cs [Integer] Total creep score + # @param game_duration_seconds [Integer] Game duration in seconds + # @return [Float] CS per minute + def calculate_cs_per_min(total_cs, game_duration_seconds) + return 0.0 if game_duration_seconds.zero? + + (total_cs.to_f / (game_duration_seconds / 60.0)).round(1) + end + module_function :calculate_cs_per_min + + # Calculates gold earned per minute + # + # @param total_gold [Integer] Total gold earned + # @param game_duration_seconds [Integer] Game duration in seconds + # @return [Float] Gold per minute + def calculate_gold_per_min(total_gold, game_duration_seconds) + return 0.0 if game_duration_seconds.zero? + + (total_gold.to_f / (game_duration_seconds / 60.0)).round(0) + end + module_function :calculate_gold_per_min + + # Calculates damage dealt per minute + # + # @param total_damage [Integer] Total damage dealt + # @param game_duration_seconds [Integer] Game duration in seconds + # @return [Float] Damage per minute + def calculate_damage_per_min(total_damage, game_duration_seconds) + return 0.0 if game_duration_seconds.zero? + + (total_damage.to_f / (game_duration_seconds / 60.0)).round(0) + end + module_function :calculate_damage_per_min + + # Formats duration in seconds to MM:SS format + # + # @param duration_seconds [Integer] Duration in seconds + # @return [String] Formatted duration + def format_duration(duration_seconds) + return '00:00' if duration_seconds.nil? || duration_seconds.zero? + + minutes = duration_seconds / 60 + seconds = duration_seconds % 60 + "#{minutes}:#{seconds.to_s.rjust(2, '0')}" + end + module_function :format_duration + + # Calculates win rate trend over time + # + # @param matches [Array] Collection of matches + # @param group_by [Symbol] Grouping period (:day, :week, :month) + # @return [Array] Trend data by period + def calculate_win_rate_trend(matches, group_by: :week) + grouped = matches.group_by do |match| + case group_by + when :day + match.game_start.beginning_of_day + when :month + match.game_start.beginning_of_month + else # :week + match.game_start.beginning_of_week + end + end + + grouped.map do |period, period_matches| + wins = period_matches.count(&:victory?) + total = period_matches.size + win_rate = total.zero? ? 0.0 : ((wins.to_f / total) * 100).round(1) + + { + period: period.strftime('%Y-%m-%d'), + matches: total, + wins: wins, + losses: total - wins, + win_rate: win_rate + } + end.sort_by { |data| data[:period] } + end + module_function :calculate_win_rate_trend + + # Calculates performance statistics grouped by role + # + # @param matches [Array] Collection of matches + # @param damage_field [Symbol] Damage field to use + # @return [Array] Performance data by role + def calculate_performance_by_role(matches, damage_field: :damage_dealt_total) + stats = PlayerMatchStat.joins(:player).where(match: matches) + grouped_stats = group_stats_by_role(stats, damage_field) + + grouped_stats.map { |stat| format_role_stat(stat) } + end + module_function :calculate_performance_by_role + + class << self + private + + def group_stats_by_role(stats, damage_field) + stats.group('players.role').select( + 'players.role', + 'COUNT(*) as games', + 'AVG(player_match_stats.kills) as avg_kills', + 'AVG(player_match_stats.deaths) as avg_deaths', + 'AVG(player_match_stats.assists) as avg_assists', + 'AVG(player_match_stats.gold_earned) as avg_gold', + "AVG(player_match_stats.#{damage_field}) as avg_damage", + 'AVG(player_match_stats.vision_score) as avg_vision' + ) + end + + def format_role_stat(stat) + { + role: stat.role, + games: stat.games, + avg_kda: format_avg_kda(stat), + avg_gold: stat.avg_gold&.round(0) || 0, + avg_damage: stat.avg_damage&.round(0) || 0, + avg_vision: stat.avg_vision&.round(1) || 0 + } + end + + def format_avg_kda(stat) + { + kills: stat.avg_kills&.round(1) || 0, + deaths: stat.avg_deaths&.round(1) || 0, + assists: stat.avg_assists&.round(1) || 0 + } + end + end + end + end +end diff --git a/app/modules/analytics/controllers/champions_controller.rb b/app/modules/analytics/controllers/champions_controller.rb new file mode 100644 index 0000000..59d9fa7 --- /dev/null +++ b/app/modules/analytics/controllers/champions_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class ChampionsController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.where(player: player) + .group(:champion) + .select( + 'champion', + 'COUNT(*) as games_played', + 'SUM(CASE WHEN matches.victory THEN 1 ELSE 0 END) as wins', + 'AVG((kills + assists)::float / NULLIF(deaths, 0)) as avg_kda' + ) + .joins(:match) + .order('games_played DESC') + + champion_stats = stats.map do |stat| + win_rate = stat.games_played.zero? ? 0 : (stat.wins.to_f / stat.games_played) + { + champion: stat.champion, + games_played: stat.games_played, + win_rate: win_rate, + avg_kda: stat.avg_kda&.round(2) || 0, + mastery_grade: calculate_mastery_grade(win_rate, stat.avg_kda) + } + end + + champion_data = { + player: PlayerSerializer.render_as_hash(player), + champion_stats: champion_stats, + top_champions: champion_stats.take(5), + champion_diversity: { + total_champions: champion_stats.count, + highly_played: champion_stats.count { |c| c[:games_played] >= 10 }, + average_games: if champion_stats.empty? + 0 + else + (champion_stats.sum do |c| + c[:games_played] + end / champion_stats.count.to_f).round(1) + end + } + } + + render_success(champion_data) + end + + private + + def calculate_mastery_grade(win_rate, avg_kda) + score = (win_rate * 100 * 0.6) + ((avg_kda || 0) * 10 * 0.4) + + case score + when 80..Float::INFINITY then 'S' + when 70...80 then 'A' + when 60...70 then 'B' + when 50...60 then 'C' + else 'D' + end + end + end + end +end diff --git a/app/modules/analytics/controllers/kda_trend_controller.rb b/app/modules/analytics/controllers/kda_trend_controller.rb new file mode 100644 index 0000000..c9e89e6 --- /dev/null +++ b/app/modules/analytics/controllers/kda_trend_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class KdaTrendController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + # Get recent matches for the player + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(50) + .includes(:match) + + trend_data = { + player: PlayerSerializer.render_as_hash(player), + kda_by_match: stats.map do |stat| + kda = stat.deaths.zero? ? (stat.kills + stat.assists).to_f : ((stat.kills + stat.assists).to_f / stat.deaths) + { + match_id: stat.match.id, + date: stat.match.game_start, + kills: stat.kills, + deaths: stat.deaths, + assists: stat.assists, + kda: kda.round(2), + champion: stat.champion, + victory: stat.match.victory + } + end, + averages: { + last_10_games: calculate_kda_average(stats.limit(10)), + last_20_games: calculate_kda_average(stats.limit(20)), + overall: calculate_kda_average(stats) + } + } + + render_success(trend_data) + end + + private + + def calculate_kda_average(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + end + end +end diff --git a/app/modules/analytics/controllers/laning_controller.rb b/app/modules/analytics/controllers/laning_controller.rb new file mode 100644 index 0000000..e594b60 --- /dev/null +++ b/app/modules/analytics/controllers/laning_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class LaningController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + laning_data = { + player: PlayerSerializer.render_as_hash(player), + cs_performance: { + avg_cs_total: stats.average('minions_killed + jungle_minions_killed')&.round(1), + avg_cs_per_min: calculate_avg_cs_per_min(stats), + best_cs_game: stats.maximum('minions_killed + jungle_minions_killed'), + worst_cs_game: stats.minimum('minions_killed + jungle_minions_killed') + }, + gold_performance: { + avg_gold: stats.average(:gold_earned)&.round(0), + best_gold_game: stats.maximum(:gold_earned), + worst_gold_game: stats.minimum(:gold_earned) + }, + cs_by_match: stats.map do |stat| + match_duration_mins = stat.match.game_duration ? stat.match.game_duration / 60.0 : 25 + cs_total = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + cs_per_min = cs_total / match_duration_mins + + { + match_id: stat.match.id, + date: stat.match.game_start, + cs_total: cs_total, + cs_per_min: cs_per_min.round(1), + gold: stat.gold_earned, + champion: stat.champion, + victory: stat.match.victory + } + end + } + + render_success(laning_data) + end + + private + + def calculate_avg_cs_per_min(stats) + total_cs = 0 + total_minutes = 0 + + stats.each do |stat| + next unless stat.match.game_duration + + cs = (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + minutes = stat.match.game_duration / 60.0 + total_cs += cs + total_minutes += minutes + end + + return 0 if total_minutes.zero? + + (total_cs / total_minutes).round(1) + end + end + end +end diff --git a/app/modules/analytics/controllers/performance_controller.rb b/app/modules/analytics/controllers/performance_controller.rb new file mode 100644 index 0000000..e934408 --- /dev/null +++ b/app/modules/analytics/controllers/performance_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class PerformanceController < Api::V1::BaseController + include Analytics::Concerns::AnalyticsCalculations + + def index + # Team performance analytics + matches = organization_scoped(Match) + players = organization_scoped(Player).active + + # Date range filter + matches = if params[:start_date].present? && params[:end_date].present? + matches.in_date_range(params[:start_date], params[:end_date]) + else + matches.recent(30) # Default to last 30 days + end + + performance_data = { + overview: calculate_team_overview(matches), + win_rate_trend: calculate_win_rate_trend(matches), + performance_by_role: calculate_performance_by_role(matches, damage_field: :total_damage_dealt), + best_performers: identify_best_performers(players, matches), + match_type_breakdown: calculate_match_type_breakdown(matches) + } + + render_success(performance_data) + end + + private + + def calculate_team_overview(matches) + stats = PlayerMatchStat.where(match: matches) + + { + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: calculate_win_rate(matches), + avg_game_duration: matches.average(:game_duration)&.round(0), + avg_kda: calculate_avg_kda(stats), + avg_kills_per_game: stats.average(:kills)&.round(1), + avg_deaths_per_game: stats.average(:deaths)&.round(1), + avg_assists_per_game: stats.average(:assists)&.round(1), + avg_gold_per_game: stats.average(:gold_earned)&.round(0), + avg_damage_per_game: stats.average(:total_damage_dealt)&.round(0), + avg_vision_score: stats.average(:vision_score)&.round(1) + } + end + + def identify_best_performers(players, matches) + players.map do |player| + stats = PlayerMatchStat.where(player: player, match: matches) + next if stats.empty? + + { + player: PlayerSerializer.render_as_hash(player), + games: stats.count, + avg_kda: calculate_avg_kda(stats), + avg_performance_score: stats.average(:performance_score)&.round(1) || 0, + mvp_count: stats.joins(:match).where(matches: { victory: true }).count + } + end.compact.sort_by { |p| -p[:avg_performance_score] }.take(5) + end + + def calculate_match_type_breakdown(matches) + matches.group(:match_type).select( + 'match_type', + 'COUNT(*) as total', + 'SUM(CASE WHEN victory THEN 1 ELSE 0 END) as wins' + ).map do |stat| + win_rate = stat.total.zero? ? 0 : ((stat.wins.to_f / stat.total) * 100).round(1) + { + match_type: stat.match_type, + total: stat.total, + wins: stat.wins, + losses: stat.total - stat.wins, + win_rate: win_rate + } + end + end + end + end +end diff --git a/app/modules/analytics/controllers/team_comparison_controller.rb b/app/modules/analytics/controllers/team_comparison_controller.rb new file mode 100644 index 0000000..9d9b2e2 --- /dev/null +++ b/app/modules/analytics/controllers/team_comparison_controller.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + # Controller for team performance comparison and analytics + # Provides endpoints to compare player statistics, team averages, and role rankings + class TeamComparisonController < Api::V1::BaseController + def index + players = fetch_active_players + matches = fetch_filtered_matches + + comparison_data = build_comparison_data(players, matches) + render_success(comparison_data) + end + + private + + def fetch_active_players + organization_scoped(Player).active.includes(:player_match_stats) + end + + def fetch_filtered_matches + matches = organization_scoped(Match) + apply_date_range_filter(matches) + end + + def apply_date_range_filter(matches) + return matches.in_date_range(params[:start_date], params[:end_date]) if date_range_provided? + + matches.recent(30) + end + + def date_range_provided? + params[:start_date].present? && params[:end_date].present? + end + + def build_comparison_data(players, matches) + { + players: build_player_comparisons(players, matches), + team_averages: calculate_team_averages(matches), + role_rankings: calculate_role_rankings(players, matches) + } + end + + def build_player_comparisons(players, matches) + player_stats = players.map { |player| build_player_stats(player, matches) } + sorted_player_stats = player_stats.compact + sorted_player_stats.sort_by { |p| -p[:avg_performance_score] } + end + + def build_player_stats(player, matches) + stats = PlayerMatchStat.where(player: player, match: matches) + return nil if stats.empty? + + { + player: PlayerSerializer.render_as_hash(player), + games_played: stats.count, + kda: calculate_kda(stats), + avg_damage: calculate_average(stats, :total_damage_dealt, 0), + avg_gold: calculate_average(stats, :gold_earned, 0), + avg_cs: calculate_cs_average(stats), + avg_vision_score: calculate_average(stats, :vision_score, 1), + avg_performance_score: calculate_average(stats, :performance_score, 1), + multikills: build_multikills_hash(stats) + } + end + + def calculate_average(stats, column, precision) + stats.average(column)&.round(precision) || 0 + end + + def calculate_cs_average(stats) + stats.average('minions_killed + jungle_minions_killed')&.round(1) || 0 + end + + def build_multikills_hash(stats) + { + double: stats.sum(:double_kills), + triple: stats.sum(:triple_kills), + quadra: stats.sum(:quadra_kills), + penta: stats.sum(:penta_kills) + } + end + + def calculate_kda(stats) + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + + def calculate_team_averages(matches) + all_stats = PlayerMatchStat.where(match: matches) + + { + avg_kda: calculate_kda(all_stats), + avg_damage: calculate_average(all_stats, :total_damage_dealt, 0), + avg_gold: calculate_average(all_stats, :gold_earned, 0), + avg_cs: calculate_cs_average(all_stats), + avg_vision_score: calculate_average(all_stats, :vision_score, 1) + } + end + + def calculate_role_rankings(players, matches) + rankings = {} + + %w[top jungle mid adc support].each do |role| + rankings[role] = calculate_role_ranking(players, matches, role) + end + + rankings + end + + def calculate_role_ranking(players, matches, role) + role_players = players.where(role: role) + role_data = role_players.map { |player| build_role_player_stats(player, matches) } + sorted_data = role_data.compact + sorted_data.sort_by { |p| -p[:avg_performance] } + end + + def build_role_player_stats(player, matches) + stats = PlayerMatchStat.where(player: player, match: matches) + return nil if stats.empty? + + { + player_id: player.id, + summoner_name: player.summoner_name, + avg_performance: stats.average(:performance_score)&.round(1) || 0, + games: stats.count + } + end + end + end +end diff --git a/app/modules/analytics/controllers/teamfights_controller.rb b/app/modules/analytics/controllers/teamfights_controller.rb new file mode 100644 index 0000000..9e8e899 --- /dev/null +++ b/app/modules/analytics/controllers/teamfights_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class TeamfightsController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + teamfight_data = { + player: PlayerSerializer.render_as_hash(player), + damage_performance: { + avg_damage_dealt: stats.average(:total_damage_dealt)&.round(0), + avg_damage_taken: stats.average(:total_damage_taken)&.round(0), + best_damage_game: stats.maximum(:total_damage_dealt), + avg_damage_per_min: calculate_avg_damage_per_min(stats) + }, + participation: { + avg_kills: stats.average(:kills)&.round(1), + avg_assists: stats.average(:assists)&.round(1), + avg_deaths: stats.average(:deaths)&.round(1), + multikill_stats: { + double_kills: stats.sum(:double_kills), + triple_kills: stats.sum(:triple_kills), + quadra_kills: stats.sum(:quadra_kills), + penta_kills: stats.sum(:penta_kills) + } + }, + by_match: stats.map do |stat| + { + match_id: stat.match.id, + date: stat.match.game_start, + kills: stat.kills, + deaths: stat.deaths, + assists: stat.assists, + damage_dealt: stat.total_damage_dealt, + damage_taken: stat.total_damage_taken, + multikills: stat.double_kills + stat.triple_kills + stat.quadra_kills + stat.penta_kills, + champion: stat.champion, + victory: stat.match.victory + } + end + } + + render_success(teamfight_data) + end + + private + + def calculate_avg_damage_per_min(stats) + total_damage = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration && stat.total_damage_dealt + total_damage += stat.total_damage_dealt + total_minutes += stat.match.game_duration / 60.0 + end + end + + return 0 if total_minutes.zero? + + (total_damage / total_minutes).round(0) + end + end + end +end diff --git a/app/modules/analytics/controllers/vision_controller.rb b/app/modules/analytics/controllers/vision_controller.rb new file mode 100644 index 0000000..3611a81 --- /dev/null +++ b/app/modules/analytics/controllers/vision_controller.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Analytics + module Controllers + class VisionController < Api::V1::BaseController + def show + player = organization_scoped(Player).find(params[:player_id]) + + stats = PlayerMatchStat.joins(:match) + .where(player: player, match: { organization: current_organization }) + .order('matches.game_start DESC') + .limit(20) + + vision_data = { + player: PlayerSerializer.render_as_hash(player), + vision_stats: { + avg_vision_score: stats.average(:vision_score)&.round(1), + avg_wards_placed: stats.average(:wards_placed)&.round(1), + avg_wards_killed: stats.average(:wards_killed)&.round(1), + best_vision_game: stats.maximum(:vision_score), + total_wards_placed: stats.sum(:wards_placed), + total_wards_killed: stats.sum(:wards_killed) + }, + vision_per_min: calculate_avg_vision_per_min(stats), + by_match: stats.map do |stat| + { + match_id: stat.match.id, + date: stat.match.game_start, + vision_score: stat.vision_score, + wards_placed: stat.wards_placed, + wards_killed: stat.wards_killed, + champion: stat.champion, + role: stat.role, + victory: stat.match.victory + } + end, + role_comparison: calculate_role_comparison(player) + } + + render_success(vision_data) + end + + private + + def calculate_avg_vision_per_min(stats) + total_vision = 0 + total_minutes = 0 + + stats.each do |stat| + if stat.match.game_duration && stat.vision_score + total_vision += stat.vision_score + total_minutes += stat.match.game_duration / 60.0 + end + end + + return 0 if total_minutes.zero? + + (total_vision / total_minutes).round(2) + end + + def calculate_role_comparison(player) + # Compare player's vision score to team average for same role + team_stats = PlayerMatchStat.joins(:player) + .where(players: { organization: current_organization, role: player.role }) + .where.not(players: { id: player.id }) + + player_stats = PlayerMatchStat.where(player: player) + + { + player_avg: player_stats.average(:vision_score)&.round(1) || 0, + role_avg: team_stats.average(:vision_score)&.round(1) || 0, + percentile: calculate_percentile(player_stats.average(:vision_score), team_stats) + } + end + + def calculate_percentile(player_avg, team_stats) + return 0 if player_avg.nil? || team_stats.empty? + + all_averages = team_stats.group(:player_id).average(:vision_score).values + all_averages << player_avg + all_averages.sort! + + rank = all_averages.index(player_avg) + 1 + ((rank.to_f / all_averages.size) * 100).round(0) + end + end + end +end diff --git a/app/modules/analytics/services/elasticsearch_client.rb b/app/modules/analytics/services/elasticsearch_client.rb new file mode 100644 index 0000000..64c0b9b --- /dev/null +++ b/app/modules/analytics/services/elasticsearch_client.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Analytics + module Services + class ElasticsearchClient + def initialize(url: ENV.fetch('ELASTICSEARCH_URL', 'http://localhost:9200')) + @client = Elasticsearch::Client.new(url: url) + end + + def ping + @client.ping + rescue StandardError => e + Rails.logger.error("Elasticsearch ping failed: #{e.message}") + false + end + + def search(index:, body: {}) + @client.search(index: index, body: body) + end + end + end +end \ No newline at end of file diff --git a/app/modules/analytics/services/performance_analytics_service.rb b/app/modules/analytics/services/performance_analytics_service.rb new file mode 100644 index 0000000..8a36191 --- /dev/null +++ b/app/modules/analytics/services/performance_analytics_service.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +module Analytics + module Services + # Service for calculating performance analytics + # + # Extracts complex analytics calculations from PerformanceController + # to follow Single Responsibility Principle and reduce controller complexity. + # + # @example Calculate performance for matches + # service = PerformanceAnalyticsService.new(matches, players) + # data = service.calculate_performance_data(player_id: 123) + # + class PerformanceAnalyticsService + include Analytics::Concerns::AnalyticsCalculations + + attr_reader :matches, :players + + def initialize(matches, players) + @matches = matches + @players = players + end + + # Calculates complete performance data + # + # @param player_id [Integer, nil] Optional player ID for individual stats + # @return [Hash] Performance analytics data + def calculate_performance_data(player_id: nil) + data = { + overview: team_overview, + win_rate_trend: win_rate_trend, + performance_by_role: performance_by_role, + best_performers: best_performers, + match_type_breakdown: match_type_breakdown + } + + if player_id + player = @players.find_by(id: player_id) + data[:player_stats] = player_statistics(player) if player + end + + data + end + + private + + # Calculates team overview statistics + def team_overview + stats = PlayerMatchStat.where(match: @matches) + + { + total_matches: @matches.count, + wins: @matches.victories.count, + losses: @matches.defeats.count, + win_rate: calculate_win_rate(@matches), + avg_game_duration: @matches.average(:game_duration)&.round(0), + avg_kda: calculate_avg_kda(stats), + avg_kills_per_game: stats.average(:kills)&.round(1), + avg_deaths_per_game: stats.average(:deaths)&.round(1), + avg_assists_per_game: stats.average(:assists)&.round(1), + avg_gold_per_game: stats.average(:gold_earned)&.round(0), + avg_damage_per_game: stats.average(:damage_dealt_total)&.round(0), + avg_vision_score: stats.average(:vision_score)&.round(1) + } + end + + # Calculates win rate trend over time + def win_rate_trend + calculate_win_rate_trend(@matches, group_by: :week) + end + + # Calculates performance statistics grouped by role + def performance_by_role + stats = PlayerMatchStat.joins(:player).where(match: @matches) + + stats.group('players.role').select( + 'players.role', + 'COUNT(*) as games', + 'AVG(player_match_stats.kills) as avg_kills', + 'AVG(player_match_stats.deaths) as avg_deaths', + 'AVG(player_match_stats.assists) as avg_assists', + 'AVG(player_match_stats.gold_earned) as avg_gold', + 'AVG(player_match_stats.damage_dealt_total) as avg_damage', + 'AVG(player_match_stats.vision_score) as avg_vision' + ).map do |stat| + { + role: stat.role, + games: stat.games, + avg_kda: build_kda_hash(stat), + avg_gold: stat.avg_gold&.round(0) || 0, + avg_damage: stat.avg_damage&.round(0) || 0, + avg_vision: stat.avg_vision&.round(1) || 0 + } + end + end + + # Identifies top performing players + def best_performers + @players.map do |player| + stats = PlayerMatchStat.where(player: player, match: @matches) + next if stats.empty? + + { + player: player_hash(player), + games: stats.count, + avg_kda: calculate_avg_kda(stats), + avg_performance_score: stats.average(:performance_score)&.round(1) || 0, + mvp_count: stats.joins(:match).where(matches: { victory: true }).count + } + end.compact.sort_by { |p| -p[:avg_performance_score] }.take(5) + end + + # Calculates match statistics grouped by match type + def match_type_breakdown + @matches.group(:match_type).select( + 'match_type', + 'COUNT(*) as total', + 'SUM(CASE WHEN victory THEN 1 ELSE 0 END) as wins' + ).map do |stat| + total = stat.total.to_i + wins = stat.wins.to_i + win_rate = total.zero? ? 0.0 : ((wins.to_f / total) * 100).round(1) + + { + match_type: stat.match_type, + total: total, + wins: wins, + losses: total - wins, + win_rate: win_rate + } + end + end + + # Calculates individual player statistics + # + # @param player [Player] The player to calculate stats for + # @return [Hash, nil] Player statistics or nil if no data + def player_statistics(player) + return nil unless player + + stats = PlayerMatchStat.where(player: player, match: @matches) + return nil if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + games_played = stats.count + + wins = stats.joins(:match).where(matches: { victory: true }).count + win_rate = games_played.zero? ? 0.0 : (wins.to_f / games_played) + + kda = calculate_kda(total_kills, total_deaths, total_assists) + + total_cs = stats.sum(:cs) + total_duration = @matches.where(id: stats.pluck(:match_id)).sum(:game_duration) + + { + player_id: player.id, + summoner_name: player.summoner_name, + games_played: games_played, + win_rate: win_rate, + kda: kda, + cs_per_min: calculate_cs_per_min(total_cs, total_duration), + gold_per_min: calculate_gold_per_min(stats.sum(:gold_earned), total_duration), + vision_score: stats.average(:vision_score)&.round(1) || 0.0, + damage_share: 0.0, + avg_kills: (total_kills.to_f / games_played).round(1), + avg_deaths: (total_deaths.to_f / games_played).round(1), + avg_assists: (total_assists.to_f / games_played).round(1) + } + end + + # Helper to build KDA hash from stat object + def build_kda_hash(stat) + { + kills: stat.avg_kills&.round(1) || 0, + deaths: stat.avg_deaths&.round(1) || 0, + assists: stat.avg_assists&.round(1) || 0 + } + end + + # Helper to serialize player to hash + def player_hash(player) + PlayerSerializer.render_as_hash(player) + end + end + end +end diff --git a/app/modules/authentication/controllers/auth_controller.rb b/app/modules/authentication/controllers/auth_controller.rb index e29e6d8..ca7515b 100644 --- a/app/modules/authentication/controllers/auth_controller.rb +++ b/app/modules/authentication/controllers/auth_controller.rb @@ -1,249 +1,319 @@ -module Authentication - module Controllers - class AuthController < Api::V1::BaseController - skip_before_action :authenticate_request!, only: [:register, :login, :forgot_password, :reset_password, :refresh] - - # POST /api/v1/auth/register - def register - ActiveRecord::Base.transaction do - organization = create_organization! - user = create_user!(organization) - tokens = Authentication::Services::JwtService.generate_tokens(user) - - log_user_action( - action: 'register', - entity_type: 'User', - entity_id: user.id - ) - - render_created( - { - user: UserSerializer.new(user).serializable_hash[:data][:attributes], - organization: OrganizationSerializer.new(organization).serializable_hash[:data][:attributes], - **tokens - }, - message: 'Registration successful' - ) - end - rescue ActiveRecord::RecordInvalid => e - render_validation_errors(e) - rescue => e - render_error(message: 'Registration failed', code: 'REGISTRATION_ERROR') - end - - # POST /api/v1/auth/login - def login - user = authenticate_user! - - if user - tokens = Authentication::Services::JwtService.generate_tokens(user) - user.update_last_login! - - log_user_action( - action: 'login', - entity_type: 'User', - entity_id: user.id - ) - - render_success( - { - user: UserSerializer.new(user).serializable_hash[:data][:attributes], - organization: OrganizationSerializer.new(user.organization).serializable_hash[:data][:attributes], - **tokens - }, - message: 'Login successful' - ) - else - render_error( - message: 'Invalid email or password', - code: 'INVALID_CREDENTIALS', - status: :unauthorized - ) - end - end - - # POST /api/v1/auth/refresh - def refresh - refresh_token = params[:refresh_token] - - if refresh_token.blank? - return render_error( - message: 'Refresh token is required', - code: 'MISSING_REFRESH_TOKEN', - status: :bad_request - ) - end - - begin - tokens = Authentication::Services::JwtService.refresh_access_token(refresh_token) - render_success(tokens, message: 'Token refreshed successfully') - rescue Authentication::Services::JwtService::AuthenticationError => e - render_error( - message: e.message, - code: 'INVALID_REFRESH_TOKEN', - status: :unauthorized - ) - end - end - - # POST /api/v1/auth/logout - def logout - # For JWT, we don't need to do anything server-side for logout - # The client should remove the token - - log_user_action( - action: 'logout', - entity_type: 'User', - entity_id: current_user.id - ) - - render_success({}, message: 'Logout successful') - end - - # POST /api/v1/auth/forgot-password - def forgot_password - email = params[:email]&.downcase&.strip - - if email.blank? - return render_error( - message: 'Email is required', - code: 'MISSING_EMAIL', - status: :bad_request - ) - end - - user = User.find_by(email: email) - - if user - # Generate password reset token - reset_token = generate_reset_token(user) - - # Here you would send an email with the reset token - # For now, we'll just return success - - log_user_action( - action: 'password_reset_requested', - entity_type: 'User', - entity_id: user.id - ) - end - - # Always return success to prevent email enumeration - render_success( - {}, - message: 'If the email exists, a password reset link has been sent' - ) - end - - # POST /api/v1/auth/reset-password - def reset_password - token = params[:token] - new_password = params[:password] - password_confirmation = params[:password_confirmation] - - if token.blank? || new_password.blank? - return render_error( - message: 'Token and password are required', - code: 'MISSING_PARAMETERS', - status: :bad_request - ) - end - - if new_password != password_confirmation - return render_error( - message: 'Password confirmation does not match', - code: 'PASSWORD_MISMATCH', - status: :bad_request - ) - end - - user = verify_reset_token(token) - - if user - user.update!(password: new_password) - - log_user_action( - action: 'password_reset_completed', - entity_type: 'User', - entity_id: user.id - ) - - render_success({}, message: 'Password reset successful') - else - render_error( - message: 'Invalid or expired reset token', - code: 'INVALID_RESET_TOKEN', - status: :bad_request - ) - end - end - - # GET /api/v1/auth/me - def me - render_success( - { - user: UserSerializer.new(current_user).serializable_hash[:data][:attributes], - organization: OrganizationSerializer.new(current_organization).serializable_hash[:data][:attributes] - } - ) - end - - private - - def create_organization! - Organization.create!(organization_params) - end - - def create_user!(organization) - User.create!(user_params.merge( - organization: organization, - role: 'owner' # First user is always the owner - )) - end - - def authenticate_user! - email = params[:email]&.downcase&.strip - password = params[:password] - - return nil if email.blank? || password.blank? - - user = User.find_by(email: email) - user&.authenticate(password) ? user : nil - end - - def organization_params - params.require(:organization).permit(:name, :region, :tier) - end - - def user_params - params.require(:user).permit(:email, :password, :full_name, :timezone, :language) - end - - def generate_reset_token(user) - # In a real app, you'd store this token in the database or Redis - # For now, we'll use JWT with a short expiration - payload = { - user_id: user.id, - type: 'password_reset', - exp: 1.hour.from_now.to_i, - iat: Time.current.to_i - } - - JWT.encode(payload, Authentication::Services::JwtService::SECRET_KEY, 'HS256') - end - - def verify_reset_token(token) - begin - decoded = JWT.decode(token, Authentication::Services::JwtService::SECRET_KEY, true, { algorithm: 'HS256' }) - payload = HashWithIndifferentAccess.new(decoded[0]) - - return nil unless payload[:type] == 'password_reset' - - User.find(payload[:user_id]) - rescue JWT::DecodeError, JWT::ExpiredSignature, ActiveRecord::RecordNotFound - nil - end - end - end - end -end \ No newline at end of file +# frozen_string_literal: true + +module Authentication + module Controllers + # Authentication Controller + # + # Handles all authentication-related operations including user registration, + # login, logout, token refresh, and password reset flows. + # + # Features: + # - JWT-based authentication with access and refresh tokens + # - Secure password reset via email + # - Audit logging for all auth events + # - Token blacklisting for logout + # + # @example Register a new user + # POST /api/v1/auth/register + # { + # "user": { "email": "user@example.com", "password": "secret" }, + # "organization": { "name": "My Team", "region": "BR" } + # } + # + # @example Login + # POST /api/v1/auth/login + # { "email": "user@example.com", "password": "secret" } + # + class AuthController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: %i[register login forgot_password reset_password refresh] + + # Registers a new user and organization + # + # Creates a new organization and assigns the user as the owner. + # Sends a welcome email and returns JWT tokens for immediate authentication. + # + # POST /api/v1/auth/register + # + # @return [JSON] User, organization, and JWT tokens + def register + ActiveRecord::Base.transaction do + organization = create_organization! + user = create_user!(organization) + tokens = Authentication::Services::JwtService.generate_tokens(user) + + AuditLog.create!( + organization: organization, + user: user, + action: 'register', + entity_type: 'User', + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + + UserMailer.welcome(user).deliver_later + + render_created( + { + user: JSON.parse(UserSerializer.render(user)), + organization: JSON.parse(OrganizationSerializer.render(organization)) + }.merge(tokens), + message: 'Registration successful' + ) + end + rescue ActiveRecord::RecordInvalid => e + render_validation_errors(e) + rescue StandardError => _e + render_error(message: 'Registration failed', code: 'REGISTRATION_ERROR') + end + + # Authenticates a user and returns JWT tokens + # + # Validates credentials and generates access/refresh tokens. + # Updates the user's last login timestamp and logs the event. + # + # POST /api/v1/auth/login + # + # @return [JSON] User, organization, and JWT tokens + def login + user = authenticate_user! + + if user + tokens = Authentication::Services::JwtService.generate_tokens(user) + user.update_last_login! + + AuditLog.create!( + organization: user.organization, + user: user, + action: 'login', + entity_type: 'User', + entity_id: user.id, + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + + render_success( + { + user: JSON.parse(UserSerializer.render(user)), + organization: JSON.parse(OrganizationSerializer.render(user.organization)) + }.merge(tokens), + message: 'Login successful' + ) + else + render_error( + message: 'Invalid email or password', + code: 'INVALID_CREDENTIALS', + status: :unauthorized + ) + end + end + + # Refreshes an access token using a refresh token + # + # Validates the refresh token and generates a new access token. + # + # POST /api/v1/auth/refresh + # + # @param refresh_token [String] The refresh token from previous authentication + # @return [JSON] New access token and refresh token + def refresh + refresh_token = params[:refresh_token] + + if refresh_token.blank? + return render_error( + message: 'Refresh token is required', + code: 'MISSING_REFRESH_TOKEN', + status: :bad_request + ) + end + + begin + tokens = Authentication::Services::JwtService.refresh_access_token(refresh_token) + render_success(tokens, message: 'Token refreshed successfully') + rescue Authentication::Services::JwtService::AuthenticationError => e + render_error( + message: e.message, + code: 'INVALID_REFRESH_TOKEN', + status: :unauthorized + ) + end + end + + # Logs out the current user + # + # Blacklists the current access token to prevent further use. + # The user must login again to obtain new tokens. + # + # POST /api/v1/auth/logout + # + # @return [JSON] Success message + def logout + # Blacklist the current access token + token = request.headers['Authorization']&.split&.last + Authentication::Services::JwtService.blacklist_token(token) if token + + log_user_action( + action: 'logout', + entity_type: 'User', + entity_id: current_user.id + ) + + render_success({}, message: 'Logout successful') + end + + # Initiates password reset flow + # + # Generates a password reset token and sends it via email. + # Always returns success to prevent email enumeration. + # + # POST /api/v1/auth/forgot-password + # + # @param email [String] User's email address + # @return [JSON] Success message + def forgot_password + email = params[:email]&.downcase&.strip + + if email.blank? + return render_error( + message: 'Email is required', + code: 'MISSING_EMAIL', + status: :bad_request + ) + end + + user = User.find_by(email: email) + + if user + reset_token = user.password_reset_tokens.create!( + ip_address: request.remote_ip, + user_agent: request.user_agent + ) + + UserMailer.password_reset(user, reset_token).deliver_later + + 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 + + render_success( + {}, + message: 'If the email exists, a password reset link has been sent' + ) + end + + # Resets user password using reset token + # + # Validates the reset token and updates the user's password. + # Marks the token as used and sends a confirmation email. + # + # POST /api/v1/auth/reset-password + # + # @param token [String] Password reset token from email + # @param password [String] New password + # @param password_confirmation [String] Password confirmation + # @return [JSON] Success or error message + def reset_password + token = params[:token] + new_password = params[:password] + password_confirmation = params[:password_confirmation] + + if token.blank? || new_password.blank? + return render_error( + message: 'Token and password are required', + code: 'MISSING_PARAMETERS', + status: :bad_request + ) + end + + if new_password != password_confirmation + return render_error( + message: 'Password confirmation does not match', + code: 'PASSWORD_MISMATCH', + status: :bad_request + ) + end + + 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! + + UserMailer.password_reset_confirmation(user).deliver_later + + 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 + ) + + render_success({}, message: 'Password reset successful') + else + render_error( + message: 'Invalid or expired reset token', + code: 'INVALID_RESET_TOKEN', + status: :bad_request + ) + end + end + + # Returns current authenticated user information + # + # GET /api/v1/auth/me + # + # @return [JSON] Current user and organization data + def me + render_success( + { + user: JSON.parse(UserSerializer.render(current_user)), + organization: JSON.parse(OrganizationSerializer.render(current_organization)) + } + ) + end + + private + + def create_organization! + Organization.create!(organization_params) + end + + def create_user!(organization) + User.create!(user_params.merge( + organization: organization, + role: 'owner' # First user is always the owner + )) + end + + def authenticate_user! + email = params[:email]&.downcase&.strip + password = params[:password] + + return nil if email.blank? || password.blank? + + user = User.find_by(email: email) + user&.authenticate(password) ? user : nil + end + + def organization_params + params.require(:organization).permit(:name, :region, :tier) + end + + def user_params + params.require(:user).permit(:email, :password, :full_name, :timezone, :language) + end + end + end +end diff --git a/app/modules/authentication/services/jwt_service.rb b/app/modules/authentication/services/jwt_service.rb index 50928b8..ef9c018 100644 --- a/app/modules/authentication/services/jwt_service.rb +++ b/app/modules/authentication/services/jwt_service.rb @@ -1,77 +1,111 @@ -module Authentication - module Services - class JwtService - SECRET_KEY = ENV.fetch('JWT_SECRET_KEY') { Rails.application.secret_key_base } - EXPIRATION_HOURS = ENV.fetch('JWT_EXPIRATION_HOURS', 24).to_i - - class << self - def encode(payload) - # Add expiration and issued at time - payload[:exp] = EXPIRATION_HOURS.hours.from_now.to_i - payload[:iat] = Time.current.to_i - - JWT.encode(payload, SECRET_KEY, 'HS256') - end - - def decode(token) - decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' }) - HashWithIndifferentAccess.new(decoded[0]) - rescue JWT::DecodeError => e - raise AuthenticationError, "Invalid token: #{e.message}" - rescue JWT::ExpiredSignature - raise AuthenticationError, 'Token has expired' - end - - def generate_tokens(user) - access_payload = { - user_id: user.id, - organization_id: user.organization_id, - role: user.role, - email: user.email, - type: 'access' - } - - refresh_payload = { - user_id: user.id, - organization_id: user.organization_id, - type: 'refresh', - exp: 7.days.from_now.to_i, - iat: Time.current.to_i - } - - { - access_token: encode(access_payload), - refresh_token: JWT.encode(refresh_payload, SECRET_KEY, 'HS256'), - expires_in: EXPIRATION_HOURS.hours.to_i, - token_type: 'Bearer' - } - end - - def refresh_access_token(refresh_token) - decoded = JWT.decode(refresh_token, SECRET_KEY, true, { algorithm: 'HS256' }) - payload = HashWithIndifferentAccess.new(decoded[0]) - - raise AuthenticationError, 'Invalid refresh token' unless payload[:type] == 'refresh' - - user = User.find(payload[:user_id]) - generate_tokens(user) - rescue JWT::DecodeError => e - raise AuthenticationError, "Invalid refresh token: #{e.message}" - rescue JWT::ExpiredSignature - raise AuthenticationError, 'Refresh token has expired' - rescue ActiveRecord::RecordNotFound - raise AuthenticationError, 'User not found' - end - - def extract_user_from_token(token) - payload = decode(token) - User.find(payload[:user_id]) - rescue ActiveRecord::RecordNotFound - raise AuthenticationError, 'User not found' - end - end - - class AuthenticationError < StandardError; end - end - end -end \ No newline at end of file +# frozen_string_literal: true + +module Authentication + module Services + class JwtService + SECRET_KEY = ENV.fetch('JWT_SECRET_KEY') { Rails.application.secret_key_base } + EXPIRATION_HOURS = ENV.fetch('JWT_EXPIRATION_HOURS', 24).to_i + + class << self + def encode(payload) + payload[:jti] ||= SecureRandom.uuid + payload[:exp] = EXPIRATION_HOURS.hours.from_now.to_i + payload[:iat] = Time.current.to_i + + JWT.encode(payload, SECRET_KEY, 'HS256') + end + + def decode(token) + decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' }) + payload = HashWithIndifferentAccess.new(decoded[0]) + + if payload[:jti].present? && TokenBlacklist.blacklisted?(payload[:jti]) + raise AuthenticationError, 'Token has been revoked' + end + + payload + rescue JWT::DecodeError => e + raise AuthenticationError, "Invalid token: #{e.message}" + rescue JWT::ExpiredSignature + raise AuthenticationError, 'Token has expired' + end + + def generate_tokens(user) + access_jti = SecureRandom.uuid + refresh_jti = SecureRandom.uuid + + access_payload = { + jti: access_jti, + user_id: user.id, + organization_id: user.organization_id, + role: user.role, + email: user.email, + type: 'access' + } + + refresh_payload = { + jti: refresh_jti, + user_id: user.id, + organization_id: user.organization_id, + type: 'refresh', + exp: 7.days.from_now.to_i, + iat: Time.current.to_i + } + + { + access_token: encode(access_payload), + refresh_token: JWT.encode(refresh_payload, SECRET_KEY, 'HS256'), + expires_in: EXPIRATION_HOURS.hours.to_i, + token_type: 'Bearer' + } + end + + def refresh_access_token(refresh_token) + decoded = JWT.decode(refresh_token, SECRET_KEY, true, { algorithm: 'HS256' }) + payload = HashWithIndifferentAccess.new(decoded[0]) + + raise AuthenticationError, 'Invalid refresh token' unless payload[:type] == 'refresh' + + if payload[:jti].present? && TokenBlacklist.blacklisted?(payload[:jti]) + raise AuthenticationError, 'Refresh token has been revoked' + end + + user = User.find(payload[:user_id]) + + blacklist_token(refresh_token) + + generate_tokens(user) + rescue JWT::DecodeError => e + raise AuthenticationError, "Invalid refresh token: #{e.message}" + rescue JWT::ExpiredSignature + raise AuthenticationError, 'Refresh token has expired' + rescue ActiveRecord::RecordNotFound + raise AuthenticationError, 'User not found' + end + + def extract_user_from_token(token) + payload = decode(token) + User.find(payload[:user_id]) + rescue ActiveRecord::RecordNotFound + raise AuthenticationError, 'User not found' + end + + def blacklist_token(token) + decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' }) + payload = HashWithIndifferentAccess.new(decoded[0]) + + return unless payload[:jti].present? + + expires_at = Time.at(payload[:exp]) if payload[:exp] + expires_at ||= EXPIRATION_HOURS.hours.from_now + + TokenBlacklist.add_to_blacklist(payload[:jti], expires_at) + rescue JWT::DecodeError, JWT::ExpiredSignature + nil + end + end + + class AuthenticationError < StandardError; end + end + end +end diff --git a/app/modules/competitive/controllers/draft_comparison_controller.rb b/app/modules/competitive/controllers/draft_comparison_controller.rb new file mode 100644 index 0000000..45f76c4 --- /dev/null +++ b/app/modules/competitive/controllers/draft_comparison_controller.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Competitive + module Controllers + class DraftComparisonController < Api::V1::BaseController + # POST /api/v1/competitive/draft-comparison + # Compare user's draft with professional meta + def compare + validate_draft_params! + + comparison = ::Competitive::Services::DraftComparatorService.compare_draft( + our_picks: params[:our_picks], + opponent_picks: params[:opponent_picks] || [], + our_bans: params[:our_bans] || [], + opponent_bans: params[:opponent_bans] || [], + patch: params[:patch], + organization: current_organization + ) + + render json: { + message: 'Draft comparison completed successfully', + data: ::Competitive::Serializers::DraftComparisonSerializer.render_as_hash(comparison) + } + rescue ArgumentError => e + render json: { + error: { + code: 'INVALID_PARAMS', + message: e.message + } + }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "[DraftComparison] Error: #{e.message}\n#{e.backtrace.join("\n")}" + render json: { + error: { + code: 'COMPARISON_ERROR', + message: 'Failed to compare draft', + details: e.message + } + }, status: :internal_server_error + end + + # GET /api/v1/competitive/meta/:role + # Get meta picks and bans for a specific role + def meta_by_role + role = params[:role] + patch = params[:patch] + + raise ArgumentError, 'Role is required' if role.blank? + + meta_data = ::Competitive::Services::DraftComparatorService.new.meta_analysis( + role: role, + patch: patch + ) + + render json: { + message: "Meta analysis for #{role} retrieved successfully", + data: meta_data + } + rescue ArgumentError => e + render json: { + error: { + code: 'INVALID_PARAMS', + message: e.message + } + }, status: :unprocessable_entity + end + + # GET /api/v1/competitive/composition-winrate + # Calculate winrate of a specific composition + def composition_winrate + champions = params[:champions] + patch = params[:patch] + + raise ArgumentError, 'Champions array is required' if champions.blank? + + winrate = ::Competitive::Services::DraftComparatorService.new.composition_winrate( + champions: champions, + patch: patch + ) + + render json: { + message: 'Composition winrate calculated successfully', + data: { + champions: champions, + patch: patch, + winrate: winrate, + note: 'Based on professional matches in our database' + } + } + rescue ArgumentError => e + render json: { + error: { + code: 'INVALID_PARAMS', + message: e.message + } + }, status: :unprocessable_entity + end + + # GET /api/v1/competitive/counters + # Suggest counters for an opponent pick + def suggest_counters + opponent_pick = params[:opponent_pick] + role = params[:role] + patch = params[:patch] + + raise ArgumentError, 'opponent_pick and role are required' if opponent_pick.blank? || role.blank? + + counters = ::Competitive::Services::DraftComparatorService.new.suggest_counters( + opponent_pick: opponent_pick, + role: role, + patch: patch + ) + + render json: { + message: 'Counter picks retrieved successfully', + data: { + opponent_pick: opponent_pick, + role: role, + patch: patch, + suggested_counters: counters + } + } + rescue ArgumentError => e + render json: { + error: { + code: 'INVALID_PARAMS', + message: e.message + } + }, status: :unprocessable_entity + end + + private + + def validate_draft_params! + raise ArgumentError, 'our_picks is required' if params[:our_picks].blank? + raise ArgumentError, 'our_picks must be an array' unless params[:our_picks].is_a?(Array) + raise ArgumentError, 'our_picks must contain 1-5 champions' unless params[:our_picks].size.between?(1, 5) + end + end + end +end diff --git a/app/modules/competitive/controllers/pro_matches_controller.rb b/app/modules/competitive/controllers/pro_matches_controller.rb new file mode 100644 index 0000000..adb1846 --- /dev/null +++ b/app/modules/competitive/controllers/pro_matches_controller.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +module Competitive + module Controllers + class ProMatchesController < Api::V1::BaseController + include Paginatable + + before_action :set_pandascore_service + + # GET /api/v1/competitive/pro-matches + # List recent professional matches from database + def index + matches = current_organization.competitive_matches + .ordered_by_date + .page(params[:page] || 1) + .per(params[:per_page] || 20) + + # Apply filters + matches = apply_filters(matches) + + render json: { + message: 'Professional matches retrieved successfully', + data: { + matches: ::Competitive::Serializers::ProMatchSerializer.render_as_hash(matches), + pagination: pagination_meta(matches) + } + } + rescue StandardError => e + Rails.logger.error "[ProMatches] Error in index: #{e.message}" + render json: { + error: { + code: 'PRO_MATCHES_ERROR', + message: 'Failed to retrieve matches', + details: e.message + } + }, status: :internal_server_error + end + + # GET /api/v1/competitive/pro-matches/:id + # Get details of a specific professional match + def show + match = current_organization.competitive_matches.find(params[:id]) + + render json: { + message: 'Match details retrieved successfully', + data: { + match: ::Competitive::Serializers::ProMatchSerializer.render_as_hash(match) + } + } + rescue ActiveRecord::RecordNotFound + render json: { + error: { + code: 'MATCH_NOT_FOUND', + message: 'Professional match not found' + } + }, status: :not_found + 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 + + matches = @pandascore_service.fetch_upcoming_matches( + league: league, + per_page: per_page + ) + + render json: { + message: 'Upcoming matches retrieved successfully', + data: { + matches: matches, + source: 'pandascore', + cached: true + } + } + rescue ::Competitive::Services::PandascoreService::PandascoreError => e + 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] + per_page = params[:per_page]&.to_i || 20 + + matches = @pandascore_service.fetch_past_matches( + league: league, + per_page: per_page + ) + + render json: { + message: 'Past matches retrieved successfully', + data: { + matches: matches, + source: 'pandascore', + cached: true + } + } + rescue ::Competitive::Services::PandascoreService::PandascoreError => e + render json: { + error: { + code: 'PANDASCORE_ERROR', + message: e.message + } + }, status: :service_unavailable + end + + # POST /api/v1/competitive/pro-matches/refresh + # Force refresh of PandaScore cache (owner only) + def refresh + authorize :pro_match, :refresh? + + @pandascore_service.clear_cache + + render json: { + message: 'Cache cleared successfully', + data: { cleared_at: Time.current } + } + rescue Pundit::NotAuthorizedError + render json: { + error: { + code: 'FORBIDDEN', + message: 'Only organization owners can refresh cache' + } + }, status: :forbidden + end + + # POST /api/v1/competitive/pro-matches/import + # Import a match from PandaScore to our database + def import + match_id = params[:match_id] + raise ArgumentError, 'match_id is required' if match_id.blank? + + # Fetch match details from PandaScore + match_data = @pandascore_service.fetch_match_details(match_id) + + # Import to our database (implement import logic) + imported_match = import_match_to_database(match_data) + + render json: { + message: 'Match imported successfully', + data: { + match: ::Competitive::Serializers::ProMatchSerializer.render_as_hash(imported_match) + } + }, status: :created + rescue ::Competitive::Services::PandascoreService::NotFoundError + render json: { + error: { + code: 'MATCH_NOT_FOUND', + message: 'Match not found in PandaScore' + } + }, status: :not_found + rescue ArgumentError => e + render json: { + error: { + code: 'INVALID_PARAMS', + message: e.message + } + }, status: :unprocessable_entity + end + + private + + def set_pandascore_service + @pandascore_service = ::Competitive::Services::PandascoreService.instance + end + + def apply_filters(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? + matches = matches.victories if params[:victories_only] == 'true' + matches = matches.defeats if params[:defeats_only] == 'true' + + if params[:start_date].present? && params[:end_date].present? + matches = matches.in_date_range( + Date.parse(params[:start_date]), + Date.parse(params[:end_date]) + ) + end + + matches + end + + def import_match_to_database(match_data) + # TODO: Implement match import logic + # This would parse PandaScore match data and create a CompetitiveMatch record + # For now, return a placeholder + raise NotImplementedError, 'Match import not yet implemented' + end + end + end +end diff --git a/app/modules/competitive/serializers/draft_comparison_serializer.rb b/app/modules/competitive/serializers/draft_comparison_serializer.rb new file mode 100644 index 0000000..e29543d --- /dev/null +++ b/app/modules/competitive/serializers/draft_comparison_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Competitive + module Serializers + class DraftComparisonSerializer < Blueprinter::Base + fields :similarity_score, + :composition_winrate, + :meta_score, + :insights, + :patch, + :analyzed_at + + field :similar_matches do |comparison| + comparison[:similar_matches] + end + + field :summary do |comparison| + { + total_similar_matches: comparison[:similar_matches]&.size || 0, + avg_similarity: comparison[:similarity_score], + meta_alignment: comparison[:meta_score], + expected_winrate: comparison[:composition_winrate] + } + end + end + end +end diff --git a/app/modules/competitive/serializers/pro_match_serializer.rb b/app/modules/competitive/serializers/pro_match_serializer.rb new file mode 100644 index 0000000..c47d81f --- /dev/null +++ b/app/modules/competitive/serializers/pro_match_serializer.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Competitive + module Serializers + class ProMatchSerializer < Blueprinter::Base + identifier :id + + fields :tournament_name, + :tournament_stage, + :tournament_region, + :match_date, + :match_format, + :game_number, + :our_team_name, + :opponent_team_name, + :victory, + :series_score, + :side, + :patch_version, + :vod_url, + :external_stats_url + + field :our_picks do |match| + match.our_picks.presence || [] + end + + field :opponent_picks do |match| + match.opponent_picks.presence || [] + end + + field :our_bans do |match| + match.our_bans.presence || [] + end + + field :opponent_bans do |match| + match.opponent_bans.presence || [] + end + + field :result, &:result_text + + field :tournament_display, &:tournament_display + + field :game_label, &:game_label + + field :has_complete_draft, &:has_complete_draft? + + field :meta_relevant, &:meta_relevant? + + field :created_at + field :updated_at + end + end +end diff --git a/app/modules/competitive/services/draft_comparator_service.rb b/app/modules/competitive/services/draft_comparator_service.rb new file mode 100644 index 0000000..60cf116 --- /dev/null +++ b/app/modules/competitive/services/draft_comparator_service.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +module Competitive + module Services + # Service for comparing draft compositions with professional meta data + # Delegates pure calculations to Competitive::Utilities::DraftAnalyzer + # + # This service provides draft analysis by comparing user compositions + # against professional match data, including: + # - Finding similar professional matches + # - Calculating composition winrates + # - Meta score analysis (alignment with pro picks) + # - Strategic insights and counter-pick suggestions + # + # @example Compare a draft + # DraftComparatorService.compare_draft( + # our_picks: ['Aatrox', 'Lee Sin', 'Orianna', 'Jinx', 'Thresh'], + # opponent_picks: ['Gnar', 'Graves', 'Sylas', 'Kai\'Sa', 'Nautilus'], + # our_bans: ['Akali', 'Azir', 'Lucian'], + # patch: '14.20', + # organization: current_org + # ) + # + class DraftComparatorService + # Compare user's draft with professional meta data + # @param our_picks [Array] Array of champion names + # @param opponent_picks [Array] Array of champion names + # @param our_bans [Array] Array of banned champion names + # @param opponent_bans [Array] Array of banned champion names + # @param patch [String] Patch version (e.g., '14.20') + # @param organization [Organization] User's organization for scope + # @return [Hash] Comparison results with insights + def self.compare_draft(our_picks:, opponent_picks:, organization:, our_bans: [], opponent_bans: [], patch: nil) + new.compare_draft( + our_picks: our_picks, + opponent_picks: opponent_picks, + our_bans: our_bans, + opponent_bans: opponent_bans, + patch: patch, + organization: organization + ) + end + + # NOTE: opponent_bans parameter reserved for future ban analysis + def compare_draft(our_picks:, opponent_picks:, our_bans:, _opponent_bans:, patch:, organization:) + # Find similar professional matches + similar_matches = find_similar_matches( + champions: our_picks, + patch: patch, + limit: 10 + ) + + # Calculate composition winrate + winrate = composition_winrate( + champions: our_picks, + patch: patch + ) + + # Calculate meta score (how aligned with pro meta) + meta_score = analyzer.calculate_meta_score(our_picks, patch) + + # Generate insights + insights = analyzer.generate_insights( + our_picks: our_picks, + opponent_picks: opponent_picks, + our_bans: our_bans, + similar_matches: similar_matches, + meta_score: meta_score, + patch: patch + ) + + { + similarity_score: analyzer.calculate_similarity_score(our_picks, similar_matches), + similar_matches: similar_matches.map { |m| analyzer.format_match(m) }, + composition_winrate: winrate, + meta_score: meta_score, + insights: insights, + patch: patch, + analyzed_at: Time.current + } + end + + # Find professional matches with similar champion compositions + # @param champions [Array] Champion names to match + # @param patch [String] Patch version + # @param limit [Integer] Max number of matches to return + # @return [Array] Similar matches from database + def find_similar_matches(champions:, patch:, limit: 10) + return [] if champions.blank? + + # Find matches where at least 3 of our champions were picked + matches = CompetitiveMatch + .where.not(our_picks: nil) + .where.not(our_picks: []) + .limit(limit * 3) # Get more for filtering + + # Filter by patch if provided + matches = matches.by_patch(patch) if patch.present? + + # Score and sort by similarity + scored_matches = matches.map do |match| + picked_champions = match.our_picked_champions + common_champions = (champions & picked_champions).size + { + match: match, + similarity: common_champions.to_f / champions.size + } + end + + # Return top matches sorted by similarity + scored_matches + .sort_by { |m| -m[:similarity] } + .select { |m| m[:similarity] >= 0.3 } # At least 30% similar + .first(limit) + .map { |m| m[:match] } + end + + # Calculate winrate of a specific composition in professional play + # @param champions [Array] Champion names + # @param patch [String] Patch version + # @return [Float] Winrate percentage (0-100) + def composition_winrate(champions:, patch:) + return 0.0 if champions.blank? + + matches = find_similar_matches(champions: champions, patch: patch, limit: 50) + return 0.0 if matches.empty? + + victories = matches.count(&:victory?) + ((victories.to_f / matches.size) * 100).round(2) + end + + # Analyze meta picks by role + # @param role [String] Role (top, jungle, mid, adc, support) + # @param patch [String] Patch version + # @return [Hash] Top picks and bans for the role + def meta_analysis(role:, patch:) + matches = fetch_matches_for_meta(patch) + picks, bans = extract_picks_and_bans(matches, role) + + analyzer.build_meta_analysis_response(role, patch, picks, bans, matches.size) + end + + # Suggest counter picks based on professional data + # @param opponent_pick [String] Enemy champion + # @param role [String] Role + # @param patch [String] Patch version + # @return [Array] Suggested counters with winrate + def suggest_counters(opponent_pick:, role:, patch:) + # Find matches where opponent_pick was played + matches = CompetitiveMatch.recent(30) + matches = matches.by_patch(patch) if patch.present? + + counters = Hash.new { |h, k| h[k] = { wins: 0, total: 0 } } + + matches.each do |match| + # Check if opponent picked this champion in this role + opponent_champion = match.opponent_picks.find do |p| + p['champion'] == opponent_pick && p['role']&.downcase == role.downcase + end + + next unless opponent_champion + + # Find what was picked against it in same role + our_champion = match.our_picks.find { |p| p['role']&.downcase == role.downcase } + next unless our_champion && our_champion['champion'] + + counter_name = our_champion['champion'] + counters[counter_name][:total] += 1 + counters[counter_name][:wins] += 1 if match.victory? + end + + # Calculate winrates and sort + counters.map do |champion, stats| + { + champion: champion, + games: stats[:total], + winrate: ((stats[:wins].to_f / stats[:total]) * 100).round(2) + } + end.sort_by { |c| -c[:winrate] }.first(5) + end + + private + + # Returns the analyzer utility module + def analyzer + @analyzer ||= Competitive::Utilities::DraftAnalyzer + end + + # Fetch matches for meta analysis + def fetch_matches_for_meta(patch) + matches = CompetitiveMatch.recent(30) + patch.present? ? matches.by_patch(patch) : matches + end + + # Extract picks and bans from matches for a specific role + def extract_picks_and_bans(matches, role) + picks = [] + bans = [] + + matches.each do |match| + picks.concat(analyzer.extract_role_picks(match, role)) + bans.concat(analyzer.extract_bans(match)) + end + + [picks, bans] + end + end + end +end diff --git a/app/modules/competitive/services/pandascore_service.rb b/app/modules/competitive/services/pandascore_service.rb new file mode 100644 index 0000000..f6debf2 --- /dev/null +++ b/app/modules/competitive/services/pandascore_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +module Competitive + module Services + 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 + class RateLimitError < PandascoreError; end + class NotFoundError < PandascoreError; end + + # Fetch upcoming LoL matches + # @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) + params = { + 'filter[videogame]': 'lol', + sort: 'begin_at', + per_page: per_page + } + + params['filter[league_id]'] = league if league.present? + + cached_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) + params = { + 'filter[videogame]': 'lol', + 'filter[finished]': true, + sort: '-begin_at', + per_page: per_page + } + + params['filter[league_id]'] = league if league.present? + + cached_get('matches/past', params) + end + + # Fetch detailed information about a specific match + # @param match_id [String, Integer] PandaScore match ID + # @return [Hash] Match details including games, teams, players + def fetch_match_details(match_id) + raise ArgumentError, 'Match ID cannot be blank' if match_id.blank? + + cached_get("lol/matches/#{match_id}") + end + + # Fetch active LoL tournaments + # @param active [Boolean] Only active tournaments (default: true) + # @return [Array] Array of tournament data + def fetch_tournaments(active: true) + params = { + 'filter[videogame]': 'lol' + } + + params['filter[live_supported]'] = true if active + + cached_get('lol/tournaments', params) + end + + # Search for a professional team by name + # @param team_name [String] Team name to search + # @return [Hash, nil] Team data or nil if not found + def search_team(team_name) + raise ArgumentError, 'Team name cannot be blank' if team_name.blank? + + params = { + 'filter[videogame]': 'lol', + 'search[name]': team_name + } + + results = cached_get('lol/teams', params) + results.first + rescue NotFoundError + nil + end + + # Fetch champion statistics for a given patch + # @param patch [String] Patch version (e.g., '14.20') + # @return [Hash] Champion pick/ban statistics + def fetch_champions_stats(patch: nil) + params = { 'filter[videogame]': 'lol' } + params['filter[videogame_version]'] = patch if patch.present? + + cached_get('lol/champions', params) + end + + # Clear cache for PandaScore data + # @param pattern [String] Cache key pattern to clear (default: all) + def clear_cache(pattern: 'pandascore:*') + Rails.cache.delete_matched(pattern) + Rails.logger.info "[PandaScore] Cache cleared: #{pattern}" + end + + private + + # 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? + + url = "#{BASE_URL}/#{endpoint}" + params[:token] = API_KEY + + Rails.logger.info "[PandaScore] GET #{endpoint} - Params: #{params.inspect}" + + response = Faraday.get(url, params) do |req| + req.options.timeout = 10 + req.options.open_timeout = 5 + end + + handle_response(response) + rescue Faraday::TimeoutError => e + Rails.logger.error "[PandaScore] Timeout: #{e.message}" + raise PandascoreError, 'Request timed out' + rescue Faraday::Error => e + Rails.logger.error "[PandaScore] Connection error: #{e.message}" + raise PandascoreError, 'Failed to connect to PandaScore API' + end + + # Handle API response and errors + # @param response [Faraday::Response] HTTP response + # @return [Hash, Array] Parsed JSON data + def handle_response(response) + case response.status + when 200 + JSON.parse(response.body) + when 404 + raise NotFoundError, 'Resource not found' + 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 + end + + # Generate cache key for an endpoint + # @param endpoint [String] API endpoint + # @param params [Hash] Query parameters + # @return [String] Cache key + def cache_key(endpoint, params) + normalized_endpoint = endpoint.gsub('/', ':') + param_hash = Digest::SHA256.hexdigest(params.to_json) + "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) + + Rails.cache.fetch(key, expires_in: ttl) do + Rails.logger.info "[PandaScore] Cache miss: #{key}" + make_request(endpoint, params) + end + end + end + end +end diff --git a/app/modules/competitive/utilities/draft_analyzer.rb b/app/modules/competitive/utilities/draft_analyzer.rb new file mode 100644 index 0000000..6c8806e --- /dev/null +++ b/app/modules/competitive/utilities/draft_analyzer.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +module Competitive + module Utilities + # Pure utility methods for draft analysis calculations + # All methods are stateless and can be called as module functions + # + # @example + # Competitive::Utilities::DraftAnalyzer.calculate_meta_score(picks, patch) + module DraftAnalyzer + extend self + + # Calculate how "meta" a composition is (0-100) + # + # @param picks [Array] Champion names + # @param patch [String, nil] Patch version + # @return [Float] Meta score (0-100) + def calculate_meta_score(picks, patch) + return 0 if picks.blank? + + # Get recent pro matches + recent_matches = CompetitiveMatch.recent(14).limit(100) + recent_matches = recent_matches.by_patch(patch) if patch.present? + + return 0 if recent_matches.empty? + + # Count how many times each champion appears + all_picks = recent_matches.flat_map(&:our_picked_champions) + pick_frequency = all_picks.tally + + # Score our picks based on how popular they are + score = picks.sum do |champion| + frequency = pick_frequency[champion] || 0 + (frequency.to_f / recent_matches.size) * 100 + end + + # Average and cap at 100 + [(score / picks.size).round(2), 100].min + end + + # Calculate similarity score between user's picks and similar matches + # + # @param picks [Array] User's champion picks + # @param similar_matches [Array] Similar matches + # @return [Float] Average similarity score (0-100) + def calculate_similarity_score(picks, similar_matches) + return 0 if similar_matches.empty? + + scores = similar_matches.map do |match| + common = (picks & match.our_picked_champions).size + (common.to_f / picks.size) * 100 + end + + (scores.sum / scores.size).round(2) + end + + # Generate strategic insights based on analysis + # + # @param our_picks [Array] User's picks (reserved for future use) + # @param opponent_picks [Array] Opponent's picks + # @param our_bans [Array] User's bans + # @param similar_matches [Array] Similar matches + # @param meta_score [Float] Meta score + # @param patch [String, nil] Patch version + # @return [Array] Array of insight messages + def generate_insights(_our_picks:, opponent_picks:, our_bans:, similar_matches:, meta_score:, patch:) + insights = [] + + # Meta relevance + insights << meta_relevance_message(meta_score) + + # Similar matches performance + insights.concat(similar_matches_insights(similar_matches)) if similar_matches.any? + + # Synergy check (placeholder - can be enhanced) + insights << '💡 Analise sinergia entre seus picks antes do jogo começar' + + # Patch relevance + insights << patch_relevance_message(patch) + + insights + end + + # Format match for API response + # + # @param match [CompetitiveMatch] Match to format + # @return [Hash] Formatted match data + def format_match(match) + { + id: match.id, + tournament: match.tournament_display, + date: match.match_date, + result: match.result_text, + our_picks: match.our_picked_champions, + opponent_picks: match.opponent_picked_champions, + patch: match.patch_version + } + end + + # Calculate pick frequency and rate + # + # @param picks [Array] Champion picks + # @return [Array] Top 10 picks with frequencies + def calculate_pick_frequency(picks) + return [] if picks.empty? + + picks.tally.sort_by { |_k, v| -v }.first(10).map do |champion, count| + { + champion: champion, + picks: count, + pick_rate: ((count.to_f / picks.size) * 100).round(2) + } + end + end + + # Calculate ban frequency and rate + # + # @param bans [Array] Champion bans + # @return [Array] Top 10 bans with frequencies + def calculate_ban_frequency(bans) + return [] if bans.empty? + + bans.tally.sort_by { |_k, v| -v }.first(10).map do |champion, count| + { + champion: champion, + bans: count, + ban_rate: ((count.to_f / bans.size) * 100).round(2) + } + end + end + + # Extract all bans from a match + # + # @param match [CompetitiveMatch] Match to extract bans from + # @return [Array] All banned champions + def extract_bans(match) + match.our_banned_champions + match.opponent_banned_champions + end + + # Extract picks for a specific role from a match + # + # @param match [CompetitiveMatch] Match to extract from + # @param role [String] Role to filter by + # @return [Array] Champion picks for the role + def extract_role_picks(match, role) + picks_for_role = [] + + our_pick = match.our_picks.find { |p| p['role']&.downcase == role.downcase } + picks_for_role << our_pick['champion'] if our_pick && our_pick['champion'] + + opponent_pick = match.opponent_picks.find { |p| p['role']&.downcase == role.downcase } + picks_for_role << opponent_pick['champion'] if opponent_pick && opponent_pick['champion'] + + picks_for_role + end + + # Build meta analysis response with pick/ban frequencies + # + # @param role [String] Role analyzed + # @param patch [String] Patch version + # @param picks [Array] All picks + # @param bans [Array] All bans + # @param total_matches [Integer] Total matches analyzed + # @return [Hash] Meta analysis response + def build_meta_analysis_response(role, patch, picks, bans, total_matches) + { + role: role, + patch: patch, + top_picks: calculate_pick_frequency(picks), + top_bans: calculate_ban_frequency(bans), + total_matches: total_matches + } + end + + private + + # Generate meta relevance insight message + # + # @param meta_score [Float] Meta score + # @return [String] Insight message + def meta_relevance_message(meta_score) + if meta_score >= 70 + "✅ Composição altamente meta (#{meta_score}% alinhada com picks profissionais)" + elsif meta_score >= 40 + "⚠️ Composição moderadamente meta (#{meta_score}% alinhada)" + else + "❌ Composição off-meta (#{meta_score}% alinhada). Considere picks mais populares." + end + end + + # Generate insights from similar matches + # + # @param similar_matches [Array] Similar matches + # @return [Array] Insight messages + def similar_matches_insights(similar_matches) + insights = [] + winrate = ((similar_matches.count(&:victory?).to_f / similar_matches.size) * 100).round(0) + + if winrate >= 60 + insights << "🏆 Composições similares têm #{winrate}% de winrate em jogos profissionais" + elsif winrate <= 40 + insights << "⚠️ Composições similares têm apenas #{winrate}% de winrate" + end + + insights + end + + # Generate patch relevance insight message + # + # @param patch [String, nil] Patch version + # @return [String] Insight message + def patch_relevance_message(patch) + if patch.present? + "📊 Análise baseada no patch #{patch}" + else + '⚠️ Análise cross-patch - considere o patch atual para maior precisão' + end + end + end + end +end diff --git a/app/modules/dashboard/controllers/dashboard_controller.rb b/app/modules/dashboard/controllers/dashboard_controller.rb new file mode 100644 index 0000000..7e00001 --- /dev/null +++ b/app/modules/dashboard/controllers/dashboard_controller.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Dashboard + module Controllers + class DashboardController < Api::V1::BaseController + include Analytics::Concerns::AnalyticsCalculations + + def index + dashboard_data = { + stats: calculate_stats, + recent_matches: recent_matches_data, + upcoming_events: upcoming_events_data, + active_goals: active_goals_data, + roster_status: roster_status_data + } + + render_success(dashboard_data) + end + + def stats + cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}" + cached_stats = Rails.cache.fetch(cache_key, expires_in: 5.minutes) { calculate_stats } + render_success(cached_stats) + end + + def activities + recent_activities = fetch_recent_activities + + render_success({ + activities: recent_activities, + count: recent_activities.size + }) + end + + def schedule + events = organization_scoped(Schedule) + .where('start_time >= ?', Time.current) + .order(start_time: :asc) + .limit(10) + + render_success({ + events: ScheduleSerializer.render_as_hash(events), + count: events.size + }) + end + + private + + def calculate_stats + matches = organization_scoped(Match).recent(30) + players = organization_scoped(Player).active + + { + total_players: players.count, + active_players: players.where(status: 'active').count, + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: calculate_win_rate(matches), + recent_form: calculate_recent_form(matches.order(game_start: :desc).limit(5)), + avg_kda: calculate_avg_kda(PlayerMatchStat.where(match: matches)), + active_goals: organization_scoped(TeamGoal).active.count, + completed_goals: organization_scoped(TeamGoal).where(status: 'completed').count, + upcoming_matches: organization_scoped(Schedule).where('start_time >= ? AND event_type = ?', Time.current, + 'match').count + } + end + + # Methods moved to Analytics::Concerns::AnalyticsCalculations + # - calculate_win_rate + # - calculate_recent_form + # - calculate_avg_kda (renamed from calculate_average_kda) + + def recent_matches_data + matches = organization_scoped(Match) + .order(game_start: :desc) + .limit(5) + + MatchSerializer.render_as_hash(matches) + end + + def upcoming_events_data + events = organization_scoped(Schedule) + .where('start_time >= ?', Time.current) + .order(start_time: :asc) + .limit(5) + + ScheduleSerializer.render_as_hash(events) + end + + def active_goals_data + goals = organization_scoped(TeamGoal) + .active + .order(end_date: :asc) + .limit(5) + + TeamGoalSerializer.render_as_hash(goals) + end + + def roster_status_data + players = organization_scoped(Player).includes(:champion_pools) + + # Order by role to ensure consistent order in by_role hash + by_role_ordered = players.ordered_by_role.group(:role).count + + { + by_role: by_role_ordered, + by_status: players.group(:status).count, + contracts_expiring: players.contracts_expiring_soon.count + } + end + + def fetch_recent_activities + # Fetch recent audit logs and format them + activities = AuditLog + .where(organization: current_organization) + .order(created_at: :desc) + .limit(20) + + activities.map do |log| + { + id: log.id, + action: log.action, + entity_type: log.entity_type, + entity_id: log.entity_id, + user: log.user&.email, + timestamp: log.created_at, + changes: summarize_changes(log) + } + end + end + + def summarize_changes(log) + return nil unless log.new_values.present? + + # Only show important field changes + important_fields = %w[status role summoner_name title victory] + changes = log.new_values.slice(*important_fields) + + return nil if changes.empty? + + changes + end + end + end +end diff --git a/app/modules/matches/controllers/matches_controller.rb b/app/modules/matches/controllers/matches_controller.rb new file mode 100644 index 0000000..59c3440 --- /dev/null +++ b/app/modules/matches/controllers/matches_controller.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +module Matches + module Controllers + class MatchesController < Api::V1::BaseController + include Analytics::Concerns::AnalyticsCalculations + include ParameterValidation + + before_action :set_match, only: %i[show update destroy stats] + + def index + matches = organization_scoped(Match).includes(:player_match_stats, :players) + matches = apply_match_filters(matches) + matches = apply_match_sorting(matches) + + result = paginate(matches) + + render_success({ + matches: MatchSerializer.render_as_hash(result[:data]), + pagination: result[:pagination], + summary: calculate_matches_summary(matches) + }) + end + + def show + match_data = MatchSerializer.render_as_hash(@match) + player_stats = PlayerMatchStatSerializer.render_as_hash( + @match.player_match_stats.includes(:player) + ) + + 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 + }) + end + + def create + match = organization_scoped(Match).new(match_params) + match.organization = current_organization + + if match.save + log_user_action( + action: 'create', + entity_type: 'Match', + entity_id: match.id, + new_values: match.attributes + ) + + render_created({ + match: MatchSerializer.render_as_hash(match) + }, message: 'Match created successfully') + else + render_error( + message: 'Failed to create match', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: match.errors.as_json + ) + end + end + + def update + old_values = @match.attributes.dup + + if @match.update(match_params) + log_user_action( + action: 'update', + entity_type: 'Match', + entity_id: @match.id, + old_values: old_values, + new_values: @match.attributes + ) + + render_updated({ + match: MatchSerializer.render_as_hash(@match) + }) + else + render_error( + message: 'Failed to update match', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @match.errors.as_json + ) + end + end + + def destroy + if @match.destroy + log_user_action( + action: 'delete', + entity_type: 'Match', + entity_id: @match.id, + old_values: @match.attributes + ) + + render_deleted(message: 'Match deleted successfully') + else + render_error( + message: 'Failed to delete match', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + def stats + stats = @match.player_match_stats.includes(:player) + + stats_data = { + match: MatchSerializer.render_as_hash(@match), + team_stats: calculate_team_stats(stats), + player_stats: stats.map do |stat| + player_data = PlayerMatchStatSerializer.render_as_hash(stat) + player_data[:player] = PlayerSerializer.render_as_hash(stat.player) + player_data + end, + comparison: { + total_gold: stats.sum(:gold_earned), + total_damage: stats.sum(:total_damage_dealt), + total_vision_score: stats.sum(:vision_score), + avg_kda: calculate_avg_kda(stats) + } + } + + render_success(stats_data) + end + + def import + player_id = validate_required_param!(:player_id) + count = integer_param(:count, default: 20, min: 1, max: 100) + + player = organization_scoped(Player).find(player_id) + + unless player.riot_puuid.present? + return render_error( + message: 'Player does not have a Riot PUUID. Please sync player from Riot first.', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity + ) + end + + begin + riot_service = RiotApiService.new + region = player.region || 'BR' + + match_ids = riot_service.get_match_history( + puuid: player.riot_puuid, + region: region, + count: count + ) + + imported_count = 0 + match_ids.each do |match_id| + next if Match.exists?(riot_match_id: match_id) + + SyncMatchJob.perform_later(match_id, current_organization.id, region) + imported_count += 1 + end + + render_success({ + message: "Queued #{imported_count} matches for import", + total_matches_found: match_ids.count, + already_imported: match_ids.count - imported_count, + player: PlayerSerializer.render_as_hash(player) + }) + rescue RiotApiService::RiotApiError => e + render_error( + message: "Failed to fetch matches from Riot API: #{e.message}", + code: 'RIOT_API_ERROR', + status: :bad_gateway + ) + rescue StandardError => e + render_error( + message: "Failed to import matches: #{e.message}", + code: 'IMPORT_ERROR', + status: :internal_server_error + ) + end + 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 + + def match_params + params.require(:match).permit( + :match_type, :game_start, :game_end, :game_duration, + :riot_match_id, :patch_version, :tournament_name, :stage, + :opponent_name, :opponent_tag, :victory, + :our_side, :our_score, :opponent_score, + :first_blood, :first_tower, :first_baron, :first_dragon, + :total_kills, :total_deaths, :total_assists, :total_gold, + :vod_url, :replay_file_url, :notes + ) + end + + def calculate_matches_summary(matches) + { + total: matches.count, + victories: matches.victories.count, + defeats: matches.defeats.count, + win_rate: calculate_win_rate(matches), + by_type: matches.group(:match_type).count, + avg_duration: matches.average(:game_duration)&.round(0) + } + end + + def calculate_team_stats(stats) + { + total_kills: stats.sum(:kills), + total_deaths: stats.sum(:deaths), + total_assists: stats.sum(:assists), + total_gold: stats.sum(:gold_earned), + total_damage: stats.sum(:total_damage_dealt), + total_cs: stats.sum(:minions_killed), + total_vision_score: stats.sum(:vision_score) + } + end + end + end +end diff --git a/app/modules/matches/jobs/sync_match_job.rb b/app/modules/matches/jobs/sync_match_job.rb new file mode 100644 index 0000000..5df47b4 --- /dev/null +++ b/app/modules/matches/jobs/sync_match_job.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +module Matches + module Jobs + class SyncMatchJob < ApplicationJob + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(match_id, organization_id, region = 'BR') + organization = Organization.find(organization_id) + riot_service = RiotApiService.new + + match_data = riot_service.get_match_details( + match_id: match_id, + region: region + ) + + match = Match.find_by(riot_match_id: match_data[:match_id]) + if match.present? + Rails.logger.info("Match #{match_id} already exists") + return + end + + match = create_match_record(match_data, organization) + + create_player_match_stats(match, match_data[:participants], organization) + + Rails.logger.info("Successfully synced match #{match_id}") + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Match not found in Riot API: #{match_id} - #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync match #{match_id}: #{e.message}") + raise + end + + private + + def create_match_record(match_data, organization) + Match.create!( + organization: organization, + riot_match_id: match_data[:match_id], + match_type: determine_match_type(match_data[:game_mode]), + game_start: match_data[:game_creation], + game_end: match_data[:game_creation] + match_data[:game_duration].seconds, + game_duration: match_data[:game_duration], + patch_version: match_data[:game_version], + victory: determine_team_victory(match_data[:participants], organization) + ) + end + + def create_player_match_stats(match, participants, organization) + participants.each do |participant_data| + player = organization.players.find_by(riot_puuid: participant_data[:puuid]) + next unless player + + PlayerMatchStat.create!( + match: match, + player: player, + role: normalize_role(participant_data[:role]), + champion: participant_data[:champion_name], + kills: participant_data[:kills], + deaths: participant_data[:deaths], + assists: participant_data[:assists], + gold_earned: participant_data[:gold_earned], + total_damage_dealt: participant_data[:total_damage_dealt], + total_damage_taken: participant_data[:total_damage_taken], + minions_killed: participant_data[:minions_killed], + jungle_minions_killed: participant_data[:neutral_minions_killed], + vision_score: participant_data[:vision_score], + wards_placed: participant_data[:wards_placed], + wards_killed: participant_data[:wards_killed], + champion_level: participant_data[:champion_level], + first_blood_kill: participant_data[:first_blood_kill], + 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) + ) + end + end + + def determine_match_type(game_mode) + game_mode.to_s.upcase == 'CLASSIC' ? 'official' : 'scrim' + end + + def determine_team_victory(participants, organization) + our_player_puuids = organization.players.pluck(:riot_puuid).compact + our_participants = participants.select { |p| our_player_puuids.include?(p[:puuid]) } + + return nil if our_participants.empty? + + our_participants.first[:win] + end + + def normalize_role(role) + role_mapping = { + 'top' => 'top', + 'jungle' => 'jungle', + 'middle' => 'mid', + 'mid' => 'mid', + 'bottom' => 'adc', + 'adc' => 'adc', + 'utility' => 'support', + 'support' => 'support' + } + + role_mapping[role&.downcase] || 'mid' + end + + def calculate_performance_score(participant_data) + # Simple performance score calculation + # This can be made more sophisticated + # future work + kda = calculate_kda( + kills: participant_data[:kills], + deaths: participant_data[:deaths], + assists: participant_data[:assists] + ) + + base_score = kda * 10 + damage_score = (participant_data[:total_damage_dealt] / 1000.0) + vision_score = participant_data[:vision_score] || 0 + + (base_score + (damage_score * 0.1) + vision_score).round(2) + end + + def calculate_kda(kills:, deaths:, assists:) + total = (kills + assists).to_f + return total if deaths.zero? + + total / deaths + end + end + end +end diff --git a/app/modules/players/controllers/players_controller.rb b/app/modules/players/controllers/players_controller.rb new file mode 100644 index 0000000..30169a9 --- /dev/null +++ b/app/modules/players/controllers/players_controller.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true + +module Players + module Controllers + # Controller for managing players within an organization + # Business logic extracted to Services for better organization + class PlayersController < Api::V1::BaseController + before_action :set_player, only: %i[show update destroy stats matches sync_from_riot] + + # GET /api/v1/players + def index + players = organization_scoped(Player).includes(:champion_pools) + + players = players.by_role(params[:role]) if params[:role].present? + players = players.by_status(params[:status]) if params[:status].present? + + if params[:search].present? + search_term = "%#{params[:search]}%" + players = players.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) + end + + result = paginate(players.ordered_by_role.order(:summoner_name)) + + render_success({ + players: PlayerSerializer.render_as_hash(result[:data]), + pagination: result[:pagination] + }) + end + + # GET /api/v1/players/:id + def show + render_success({ + player: PlayerSerializer.render_as_hash(@player) + }) + end + + # POST /api/v1/players + def create + player = organization_scoped(Player).new(player_params) + player.organization = current_organization + + if player.save + log_user_action( + action: 'create', + entity_type: 'Player', + entity_id: player.id, + new_values: player.attributes + ) + + render_created({ + player: PlayerSerializer.render_as_hash(player) + }, message: 'Player created successfully') + else + render_error( + message: 'Failed to create player', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: player.errors.as_json + ) + end + end + + # PATCH/PUT /api/v1/players/:id + def update + old_values = @player.attributes.dup + + if @player.update(player_params) + log_user_action( + action: 'update', + entity_type: 'Player', + entity_id: @player.id, + old_values: old_values, + new_values: @player.attributes + ) + + render_updated({ + player: PlayerSerializer.render_as_hash(@player) + }) + else + render_error( + message: 'Failed to update player', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @player.errors.as_json + ) + end + end + + # DELETE /api/v1/players/:id + def destroy + if @player.destroy + log_user_action( + action: 'delete', + entity_type: 'Player', + entity_id: @player.id, + old_values: @player.attributes + ) + + render_deleted(message: 'Player deleted successfully') + else + render_error( + message: 'Failed to delete player', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + # GET /api/v1/players/:id/stats + def stats + stats_service = Players::Services::StatsService.new(@player) + stats_data = stats_service.calculate_stats + + render_success({ + player: PlayerSerializer.render_as_hash(stats_data[:player]), + overall: stats_data[:overall], + recent_form: stats_data[:recent_form], + champion_pool: ChampionPoolSerializer.render_as_hash(stats_data[:champion_pool]), + performance_by_role: stats_data[:performance_by_role] + }) + end + + # GET /api/v1/players/:id/matches + def matches + matches = @player.matches + .includes(:player_match_stats) + .order(game_start: :desc) + + if params[:start_date].present? && params[:end_date].present? + matches = matches.in_date_range(params[:start_date], params[:end_date]) + end + + result = paginate(matches) + + matches_with_stats = result[:data].map do |match| + player_stat = match.player_match_stats.find_by(player: @player) + { + match: MatchSerializer.render_as_hash(match), + player_stats: player_stat ? PlayerMatchStatSerializer.render_as_hash(player_stat) : nil + } + end + + render_success({ + matches: matches_with_stats, + pagination: result[:pagination] + }) + end + + # POST /api/v1/players/import + def import + summoner_name = params[:summoner_name]&.strip + role = params[:role] + region = params[:region] || 'br1' + + # 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) + + # Handle result + result[:success] ? handle_import_success(result) : handle_import_error(result) + end + + # POST /api/v1/players/:id/sync_from_riot + def sync_from_riot + service = Players::Services::RiotSyncService.new(@player, region: params[:region]) + result = service.sync + + if result[:success] + log_user_action( + action: 'sync_riot', + entity_type: 'Player', + entity_id: @player.id, + new_values: @player.attributes + ) + + render_success({ + player: PlayerSerializer.render_as_hash(@player.reload), + message: 'Player synced successfully from Riot API' + }) + else + render_error( + message: "Failed to sync with Riot API: #{result[:error]}", + code: result[:code] || 'SYNC_ERROR', + status: :service_unavailable + ) + end + end + + # GET /api/v1/players/search_riot_id + def search_riot_id + summoner_name = params[:summoner_name]&.strip + region = params[:region] || 'br1' + + unless summoner_name.present? + return render_error( + message: 'Summoner name is required', + code: 'MISSING_PARAMETERS', + status: :unprocessable_entity + ) + end + + result = Players::Services::RiotSyncService.search_riot_id(summoner_name, region: region) + + if result[:success] && result[:found] + render_success(result.except(:success)) + elsif result[:success] && !result[:found] + render_error( + message: result[:error], + code: 'PLAYER_NOT_FOUND', + status: :not_found, + details: { + game_name: result[:game_name], + tried_tags: result[:tried_tags], + hint: 'Please verify the exact Riot ID in the League client (Settings > Account > Riot ID)' + } + ) + else + render_error( + message: result[:error], + code: result[:code] || 'SEARCH_ERROR', + status: :service_unavailable + ) + end + end + + # POST /api/v1/players/bulk_sync + def bulk_sync + status = params[:status] || 'active' + + players = organization_scoped(Player).where(status: status) + + if players.empty? + return render_error( + message: "No #{status} players found to sync", + code: 'NO_PLAYERS_FOUND', + status: :not_found + ) + end + + riot_api_key = ENV['RIOT_API_KEY'] + unless riot_api_key.present? + return render_error( + message: 'Riot API key not configured', + code: 'RIOT_API_NOT_CONFIGURED', + status: :service_unavailable + ) + end + + players.update_all(sync_status: 'syncing') + + players.each do |player| + SyncPlayerFromRiotJob.perform_later(player.id) + end + + render_success({ + message: "#{players.count} players queued for sync", + players_count: players.count + }) + end + + private + + def set_player + @player = organization_scoped(Player).find(params[:id]) + end + + def player_params + # :role refers to in-game position (top/jungle/mid/adc/support), not user role + # nosemgrep + params.require(:player).permit( + :summoner_name, :real_name, :role, :region, :status, :jersey_number, + :birth_date, :country, :nationality, + :contract_start_date, :contract_end_date, + :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, + :solo_queue_wins, :solo_queue_losses, + :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, + :peak_tier, :peak_rank, :peak_season, + :riot_puuid, :riot_summoner_id, + :twitter_handle, :twitch_channel, :instagram_handle, + :notes + ) + end + + # Validate import parameters + def validate_import_params(summoner_name, role) + unless summoner_name.present? && role.present? + render_error( + message: 'Summoner name and role are required', + code: 'MISSING_PARAMETERS', + status: :unprocessable_entity, + details: { + hint: 'Format: "GameName#TAG" or "GameName-TAG" (e.g., "Faker#KR1" or "Faker-KR1")' + } + ) + return false + end + + unless %w[top jungle mid adc support].include?(role) + render_error( + message: 'Invalid role', + code: 'INVALID_ROLE', + status: :unprocessable_entity + ) + return false + end + + true + end + + # Check if player already exists + def validate_player_uniqueness(summoner_name) + existing_player = organization_scoped(Player).find_by(summoner_name: summoner_name) + return true unless existing_player + + render_error( + message: 'Player already exists in your organization', + code: 'PLAYER_EXISTS', + status: :unprocessable_entity + ) + false + end + + # Import player from Riot API + def import_player_from_riot(summoner_name, role, region) + Players::Services::RiotSyncService.import( + summoner_name: summoner_name, + role: role, + region: region, + organization: current_organization + ) + end + + # Handle successful import + def handle_import_success(result) + log_user_action( + action: 'import_riot', + entity_type: 'Player', + entity_id: result[:player].id, + new_values: result[:player].attributes + ) + + render_created({ + player: PlayerSerializer.render_as_hash(result[:player]), + message: "Player #{result[:summoner_name]} imported successfully from Riot API" + }) + end + + # Handle import error + def handle_import_error(result) + # Determine appropriate HTTP status based on error code + status = case result[:code] + when 'PLAYER_NOT_FOUND', 'INVALID_FORMAT' + :not_found + when 'PLAYER_BELONGS_TO_OTHER_ORGANIZATION' + :forbidden + when 'RIOT_API_ERROR' + # Check if it's a server error (5xx) or rate limit + result[:status_code] && result[:status_code] >= 500 ? :bad_gateway : :service_unavailable + else + :service_unavailable + end + + render_error( + message: result[:error] || 'Failed to import from Riot API', + code: result[:code] || 'IMPORT_ERROR', + status: status + ) + end + 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 new file mode 100644 index 0000000..bd59b7e --- /dev/null +++ b/app/modules/players/jobs/sync_player_from_riot_job.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Players + module Jobs + class SyncPlayerFromRiotJob < ApplicationJob + queue_as :default + + def perform(player_id) + player = Player.find(player_id) + + unless player.riot_puuid.present? || player.summoner_name.present? + player.update(sync_status: 'error', last_sync_at: Time.current) + Rails.logger.error "Player #{player_id} missing Riot info" + return + end + + riot_api_key = ENV['RIOT_API_KEY'] + unless riot_api_key.present? + player.update(sync_status: 'error', last_sync_at: Time.current) + Rails.logger.error 'Riot API key not configured' + return + end + + begin + region = player.region.presence&.downcase || 'br1' + + summoner_data = if player.riot_puuid.present? + fetch_summoner_by_puuid(player.riot_puuid, region, riot_api_key) + else + fetch_summoner_by_name(player.summoner_name, region, riot_api_key) + end + + # Use PUUID for league endpoint (workaround for Riot API bug where summoner_data['id'] is nil) + # See: https://github.com/RiotGames/developer-relations/issues/1092 + ranked_data = fetch_ranked_stats_by_puuid(player.riot_puuid, region, riot_api_key) + + update_data = { + riot_puuid: summoner_data['puuid'], + riot_summoner_id: summoner_data['id'], + summoner_level: summoner_data['summonerLevel'], + profile_icon_id: summoner_data['profileIconId'], + sync_status: 'success', + last_sync_at: Time.current + } + + solo_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_SOLO_5x5' } + if solo_queue + update_data.merge!({ + solo_queue_tier: solo_queue['tier'], + solo_queue_rank: solo_queue['rank'], + solo_queue_lp: solo_queue['leaguePoints'], + solo_queue_wins: solo_queue['wins'], + solo_queue_losses: solo_queue['losses'] + }) + end + + flex_queue = ranked_data.find { |q| q['queueType'] == 'RANKED_FLEX_SR' } + if flex_queue + update_data.merge!({ + flex_queue_tier: flex_queue['tier'], + flex_queue_rank: flex_queue['rank'], + flex_queue_lp: flex_queue['leaguePoints'] + }) + end + + player.update!(update_data) + + Rails.logger.info "Successfully synced player #{player_id} from Riot API" + rescue StandardError => e + Rails.logger.error "Failed to sync player #{player_id}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + player.update(sync_status: 'error', last_sync_at: Time.current) + end + end + + private + + def fetch_summoner_by_name(summoner_name, region, api_key) + require 'net/http' + require 'json' + + game_name, tag_line = summoner_name.split('#') + tag_line ||= region.upcase + + account_url = "https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/#{URI.encode_www_form_component(game_name)}/#{URI.encode_www_form_component(tag_line)}" + account_uri = URI(account_url) + account_request = Net::HTTP::Get.new(account_uri) + account_request['X-Riot-Token'] = api_key + + account_response = Net::HTTP.start(account_uri.hostname, account_uri.port, use_ssl: true) do |http| + http.request(account_request) + end + + unless account_response.is_a?(Net::HTTPSuccess) + raise "Riot API Error: #{account_response.code} - #{account_response.body}" + end + + account_data = JSON.parse(account_response.body) + puuid = account_data['puuid'] + + fetch_summoner_by_puuid(puuid, region, api_key) + end + + def fetch_summoner_by_puuid(puuid, region, api_key) + require 'net/http' + require 'json' + + url = "https://#{region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}" + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + raise "Riot API Error: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + + def fetch_ranked_stats(summoner_id, region, api_key) + require 'net/http' + require 'json' + + url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-summoner/#{summoner_id}" + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + raise "Riot API Error: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + + def fetch_ranked_stats_by_puuid(puuid, region, api_key) + require 'net/http' + require 'json' + + url = "https://#{region}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}" + uri = URI(url) + request = Net::HTTP::Get.new(uri) + request['X-Riot-Token'] = api_key + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + raise "Riot API Error: #{response.code} - #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + end + end +end diff --git a/app/modules/players/jobs/sync_player_job.rb b/app/modules/players/jobs/sync_player_job.rb new file mode 100644 index 0000000..e83842e --- /dev/null +++ b/app/modules/players/jobs/sync_player_job.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Players + module Jobs + class SyncPlayerJob < ApplicationJob + include RankComparison + + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(player_id, region = 'BR') + player = Player.find(player_id) + riot_service = RiotApiService.new + + if player.riot_puuid.blank? + sync_summoner_by_name(player, riot_service, region) + else + sync_summoner_by_puuid(player, riot_service, region) + end + + sync_rank_info(player, riot_service, region) if player.riot_summoner_id.present? + + sync_champion_mastery(player, riot_service, region) if player.riot_puuid.present? + + player.update!(last_sync_at: Time.current) + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Player not found in Riot API: #{player.summoner_name} - #{e.message}") + rescue RiotApiService::UnauthorizedError => e + Rails.logger.error("Riot API authentication failed: #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync player #{player.id}: #{e.message}") + raise + end + + private + + def sync_summoner_by_name(player, riot_service, region) + summoner_data = riot_service.get_summoner_by_name( + summoner_name: player.summoner_name, + region: region + ) + + player.update!( + riot_puuid: summoner_data[:puuid], + riot_summoner_id: summoner_data[:summoner_id] + ) + end + + def sync_summoner_by_puuid(player, riot_service, region) + summoner_data = riot_service.get_summoner_by_puuid( + puuid: player.riot_puuid, + region: region + ) + + return unless player.summoner_name != summoner_data[:summoner_name] + + player.update!(summoner_name: summoner_data[:summoner_name]) + end + + def sync_rank_info(player, riot_service, region) + league_data = riot_service.get_league_entries( + summoner_id: player.riot_summoner_id, + region: region + ) + + update_attributes = {} + + if league_data[:solo_queue].present? + solo = league_data[:solo_queue] + update_attributes.merge!( + solo_queue_tier: solo[:tier], + solo_queue_rank: solo[:rank], + solo_queue_lp: solo[:lp], + solo_queue_wins: solo[:wins], + solo_queue_losses: solo[:losses] + ) + + if should_update_peak?(player, solo[:tier], solo[:rank]) + update_attributes.merge!( + peak_tier: solo[:tier], + peak_rank: solo[:rank], + peak_season: current_season + ) + end + end + + if league_data[:flex_queue].present? + flex = league_data[:flex_queue] + update_attributes.merge!( + flex_queue_tier: flex[:tier], + flex_queue_rank: flex[:rank], + flex_queue_lp: flex[:lp] + ) + end + + player.update!(update_attributes) if update_attributes.present? + end + + def sync_champion_mastery(player, riot_service, region) + mastery_data = riot_service.get_champion_mastery( + puuid: player.riot_puuid, + region: region + ) + + champion_id_map = load_champion_id_map + + mastery_data.take(20).each do |mastery| + champion_name = champion_id_map[mastery[:champion_id]] + next unless champion_name + + champion_pool = player.champion_pools.find_or_initialize_by(champion: champion_name) + champion_pool.update!( + mastery_level: mastery[:champion_level], + mastery_points: mastery[:champion_points], + last_played_at: mastery[:last_played] + ) + end + end + + def current_season + Time.current.year - 2025 # Season 1 was 2011 + end + + def load_champion_id_map + DataDragonService.new.champion_id_map + end + end + end +end diff --git a/app/modules/players/services/riot_api_error.rb b/app/modules/players/services/riot_api_error.rb new file mode 100644 index 0000000..bed8131 --- /dev/null +++ b/app/modules/players/services/riot_api_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Players + module Services + # Custom exception for Riot API errors with status code tracking + class RiotApiError < StandardError + attr_accessor :status_code, :response_body + + def initialize(message = nil) + super + @status_code = nil + @response_body = nil + end + + def not_found? + status_code == 404 + end + + def rate_limited? + status_code == 429 + end + + def server_error? + status_code >= 500 + end + end + end +end diff --git a/app/modules/players/services/riot_sync_service.rb b/app/modules/players/services/riot_sync_service.rb new file mode 100644 index 0000000..f31bb37 --- /dev/null +++ b/app/modules/players/services/riot_sync_service.rb @@ -0,0 +1,491 @@ +# frozen_string_literal: true + +module Players + module Services + # Service responsible for syncing player data with Riot Games API + # + # This service handles all interactions with the Riot Games API including: + # - Importing new players by summoner name + # - Syncing existing player data (rank, stats, profile) + # - Fetching match history + # - Creating player statistics from match data + # + # @example Import a new player + # service = RiotSyncService.new(organization, 'br1') + # result = service.import_player('GameName#TAG', 'mid') + # + # @example Sync existing player + # service = RiotSyncService.new(organization) + # result = service.sync_player(player, import_matches: true) + class RiotSyncService + VALID_REGIONS = %w[br1 na1 euw1 kr eune1 lan las1 oce1 ru tr1 jp1].freeze + AMERICAS = %w[br1 na1 lan las1].freeze + EUROPE = %w[euw1 eune1 ru tr1].freeze + ASIA = %w[kr jp1 oce1].freeze + + # Whitelist of allowed Riot API hostnames to prevent SSRF + REGION_HOSTS = { + 'br1' => 'br1.api.riotgames.com', + 'na1' => 'na1.api.riotgames.com', + 'euw1' => 'euw1.api.riotgames.com', + 'kr' => 'kr.api.riotgames.com', + 'eune1' => 'eune1.api.riotgames.com', + 'lan' => 'lan.api.riotgames.com', + 'las1' => 'las1.api.riotgames.com', + 'oce1' => 'oce1.api.riotgames.com', + 'ru' => 'ru.api.riotgames.com', + 'tr1' => 'tr1.api.riotgames.com', + 'jp1' => 'jp1.api.riotgames.com' + }.freeze + + # Whitelist of allowed regional endpoints for match/account APIs + REGIONAL_ENDPOINT_HOSTS = { + 'americas' => 'americas.api.riotgames.com', + 'europe' => 'europe.api.riotgames.com', + 'asia' => 'asia.api.riotgames.com' + }.freeze + + attr_reader :organization, :api_key, :region + + def initialize(organization, region = nil) + @organization = organization + @api_key = ENV['RIOT_API_KEY'] + @region = sanitize_region(region || organization.region || 'br1') + + raise 'Riot API key not configured' if @api_key.blank? + end + + # Class method to import a new player from Riot API + def self.import(summoner_name:, role:, region:, organization:) + service = new(organization, region) + service.import_player(summoner_name, role) + end + + # Import a new player from Riot API + def import_player(summoner_name, role) + # Parse summoner name in format "GameName#TagLine" + parts = summoner_name.split('#') + if parts.size != 2 + return { + success: false, + error: 'Invalid summoner name format. Use: GameName#TagLine', + code: 'INVALID_FORMAT' + } + end + + game_name = parts[0].strip + tag_line = parts[1].strip + + # Search for the player on Riot API + riot_data = search_riot_id(game_name, tag_line) + + unless riot_data + return { + success: false, + error: 'Player not found on Riot API', + code: 'PLAYER_NOT_FOUND' + } + end + + # Check if player already exists in another organization + existing_player = Player.find_by(riot_puuid: riot_data[:puuid]) + if existing_player && existing_player.organization_id != organization.id + log_security_warning(summoner_name, riot_data, existing_player) + create_security_audit_log(summoner_name, riot_data, existing_player) + + return player_belongs_to_other_org_error + end + + # Create the player in database + player = organization.players.create!( + summoner_name: "#{riot_data[:game_name]}##{riot_data[:tag_line]}", + riot_puuid: riot_data[:puuid], + role: role, + summoner_level: riot_data[:summoner_level], + profile_icon_id: riot_data[:profile_icon_id], + solo_queue_tier: riot_data[:rank_data]['tier'], + solo_queue_rank: riot_data[:rank_data]['rank'], + solo_queue_lp: riot_data[:rank_data]['leaguePoints'] || 0, + solo_queue_wins: riot_data[:rank_data]['wins'] || 0, + solo_queue_losses: riot_data[:rank_data]['losses'] || 0, + last_sync_at: Time.current, + sync_status: 'success', + region: @region + ) + + { + success: true, + player: player, + summoner_name: "#{riot_data[:game_name]}##{riot_data[:tag_line]}", + message: 'Player imported successfully' + } + rescue RiotApiError => e + Rails.logger.error("Failed to import player #{summoner_name}: #{e.message}") + { + success: false, + error: e.message, + code: e.not_found? ? 'PLAYER_NOT_FOUND' : 'RIOT_API_ERROR', + status_code: e.status_code + } + rescue StandardError => e + Rails.logger.error("Failed to import player #{summoner_name}: #{e.message}") + { + success: false, + error: e.message, + code: 'IMPORT_ERROR' + } + end + + # Main sync method + def sync_player(player, import_matches: true) + return { success: false, error: 'Player missing PUUID' } if player.riot_puuid.blank? + + begin + # 1. Fetch current rank and profile + summoner_data = fetch_summoner_by_puuid(player.riot_puuid) + # Use PUUID to fetch rank data (summoner_id is no longer returned by Riot API) + rank_data = fetch_rank_data_by_puuid(player.riot_puuid) + + # 2. Update player with fresh data + update_player_from_riot(player, summoner_data, rank_data) + + # 3. Optionally fetch recent matches + matches_imported = 0 + matches_imported = import_player_matches(player, count: 20) if import_matches + + { + success: true, + player: player, + matches_imported: matches_imported, + message: 'Player synchronized successfully' + } + rescue StandardError => e + Rails.logger.error("RiotSync Error for #{player.summoner_name}: #{e.message}") + { + success: false, + error: e.message, + player: player + } + end + end + + # Fetch summoner by PUUID + def fetch_summoner_by_puuid(puuid) + # Use whitelisted host to prevent SSRF + uri = URI::HTTPS.build( + host: riot_api_host, + path: "/lol/summoner/v4/summoners/by-puuid/#{ERB::Util.url_encode(puuid)}" + ) + response = make_request(uri.to_s) + JSON.parse(response.body) + end + + # Fetch rank data for a summoner by PUUID + # Note: Riot API removed summoner_id from /lol/summoner/v4/summoners/by-puuid response + # So we now use /lol/league/v4/entries/by-puuid/{puuid} instead + def fetch_rank_data_by_puuid(puuid) + # Use whitelisted host to prevent SSRF + uri = URI::HTTPS.build( + host: riot_api_host, + path: "/lol/league/v4/entries/by-puuid/#{ERB::Util.url_encode(puuid)}" + ) + response = make_request(uri.to_s) + data = JSON.parse(response.body) + + # Find RANKED_SOLO_5x5 queue + solo_queue = data.find { |entry| entry['queueType'] == 'RANKED_SOLO_5x5' } + solo_queue || {} + end + + # Legacy method - kept for backwards compatibility + # Note: summoner_id is no longer returned by Riot API, use fetch_rank_data_by_puuid instead + def fetch_rank_data(summoner_id) + return {} if summoner_id.nil? || summoner_id.empty? + + # Use whitelisted host to prevent SSRF + uri = URI::HTTPS.build( + host: riot_api_host, + path: "/lol/league/v4/entries/by-summoner/#{ERB::Util.url_encode(summoner_id)}" + ) + response = make_request(uri.to_s) + data = JSON.parse(response.body) + + # Find RANKED_SOLO_5x5 queue + solo_queue = data.find { |entry| entry['queueType'] == 'RANKED_SOLO_5x5' } + solo_queue || {} + end + + # Import recent matches for a player + def import_player_matches(player, count: 20) + return 0 if player.riot_puuid.blank? + + # 1. Get match IDs + match_ids = fetch_match_ids(player.riot_puuid, count) + return 0 if match_ids.empty? + + # 2. Import each match + imported = 0 + match_ids.each do |match_id| + next if organization.matches.exists?(riot_match_id: match_id) + + match_details = fetch_match_details(match_id) + imported += 1 if import_match(match_details, player) + rescue StandardError => e + Rails.logger.error("Failed to import match #{match_id}: #{e.message}") + end + + imported + end + + # Search for a player by Riot ID (GameName#TagLine) + def search_riot_id(game_name, tag_line) + Rails.logger.info("🎮 Searching for Riot ID: #{game_name}##{tag_line}") + Rails.logger.info("🌍 Region: #{region}") + + regional_endpoint = get_regional_endpoint(region) + Rails.logger.info("🗺️ Regional endpoint: #{regional_endpoint}") + + # Use whitelisted host to prevent SSRF + # Use ERB::Util.url_encode instead of CGI.escape to properly encode spaces as %20 (not +) + encoded_game_name = ERB::Util.url_encode(game_name) + encoded_tag_line = ERB::Util.url_encode(tag_line) + + Rails.logger.info("📝 Encoded game_name: '#{game_name}' -> '#{encoded_game_name}'") + Rails.logger.info("📝 Encoded tag_line: '#{tag_line}' -> '#{encoded_tag_line}'") + + uri = URI::HTTPS.build( + host: regional_api_host(regional_endpoint), + path: "/riot/account/v1/accounts/by-riot-id/#{encoded_game_name}/#{encoded_tag_line}" + ) + + Rails.logger.info("🔗 Full URL: #{uri}") + + response = make_request(uri.to_s) + account_data = JSON.parse(response.body) + + # Now fetch summoner data using PUUID + summoner_data = fetch_summoner_by_puuid(account_data['puuid']) + # Use PUUID to fetch rank data (summoner_id is no longer returned by Riot API) + rank_data = fetch_rank_data_by_puuid(account_data['puuid']) + + { + puuid: account_data['puuid'], + game_name: account_data['gameName'], + tag_line: account_data['tagLine'], + summoner_level: summoner_data['summonerLevel'], + profile_icon_id: summoner_data['profileIconId'], + rank_data: rank_data + } + rescue StandardError => e + Rails.logger.error("❌ Failed to search Riot ID #{game_name}##{tag_line}: #{e.message}") + Rails.logger.error("❌ Exception class: #{e.class.name}") + Rails.logger.error("❌ Backtrace: #{e.backtrace.first(5).join("\n")}") + nil + end + + private + + # Log security warning when attempting to import player from another org + def log_security_warning(summoner_name, riot_data, existing_player) + Rails.logger.warn( + "⚠️ SECURITY: Attempt to import player #{summoner_name} " \ + "(PUUID: #{riot_data[:puuid]}) that belongs to organization " \ + "#{existing_player.organization.name} by organization #{organization.name}" + ) + end + + # Create audit log for security event + def create_security_audit_log(summoner_name, riot_data, existing_player) + AuditLog.create!( + organization: organization, + action: 'import_attempt_blocked', + entity_type: 'Player', + entity_id: existing_player.id, + new_values: { + attempted_summoner_name: summoner_name, + actual_summoner_name: existing_player.summoner_name, + owner_organization_id: existing_player.organization_id, + owner_organization_name: existing_player.organization.name, + reason: 'Player already belongs to another organization', + puuid: riot_data[:puuid] + } + ) + end + + # Error message for player belonging to another organization + def player_belongs_to_other_org_error + { + success: false, + error: 'This player is already registered in another organization. ' \ + 'Players can only be associated with one organization at a time. ' \ + 'Attempting to import players from other organizations may result in ' \ + 'account restrictions.', + code: 'PLAYER_BELONGS_TO_OTHER_ORGANIZATION' + } + end + + # Fetch match IDs + def fetch_match_ids(puuid, count = 20) + regional_endpoint = get_regional_endpoint(region) + + # Use whitelisted host to prevent SSRF + uri = URI::HTTPS.build( + host: regional_api_host(regional_endpoint), + path: "/lol/match/v5/matches/by-puuid/#{ERB::Util.url_encode(puuid)}/ids", + query: URI.encode_www_form(count: count) + ) + response = make_request(uri.to_s) + JSON.parse(response.body) + end + + # Fetch match details + def fetch_match_details(match_id) + regional_endpoint = get_regional_endpoint(region) + + # Use whitelisted host to prevent SSRF + uri = URI::HTTPS.build( + host: regional_api_host(regional_endpoint), + path: "/lol/match/v5/matches/#{ERB::Util.url_encode(match_id)}" + ) + response = make_request(uri.to_s) + JSON.parse(response.body) + end + + # Make HTTP request to Riot API + def make_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})") + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + 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}") + + # Create custom exception with status code for better error handling + error = RiotApiError.new(error_message) + error.status_code = response.code.to_i + error.response_body = response.body + raise error + end + + Rails.logger.info("✅ Riot API request successful: #{response.code}") + response + end + + # Update player with Riot data + def update_player_from_riot(player, summoner_data, rank_data) + player.update!( + summoner_level: summoner_data['summonerLevel'], + profile_icon_id: summoner_data['profileIconId'], + solo_queue_tier: rank_data['tier'], + solo_queue_rank: rank_data['rank'], + solo_queue_lp: rank_data['leaguePoints'], + solo_queue_wins: rank_data['wins'], + solo_queue_losses: rank_data['losses'], + last_sync_at: Time.current, + sync_status: 'success' + ) + end + + # Import a match from Riot data + def import_match(match_data, player) + info = match_data['info'] + metadata = match_data['metadata'] + + # Find player's participant + participant = info['participants'].find do |p| + p['puuid'] == player.riot_puuid + end + + return false unless participant + + # Determine if it was a victory + victory = participant['win'] + + # Create match + match = organization.matches.create!( + riot_match_id: metadata['matchId'], + match_type: 'official', + game_start: Time.zone.at(info['gameStartTimestamp'] / 1000), + game_end: Time.zone.at(info['gameEndTimestamp'] / 1000), + game_duration: info['gameDuration'], + victory: victory, + patch_version: info['gameVersion'], + our_side: participant['teamId'] == 100 ? 'blue' : 'red' + ) + + # Create player stats + create_player_stats(match, player, participant) + + true + end + + # Create player match stats + def create_player_stats(match, player, participant) + match.player_match_stats.create!( + player: player, + champion: participant['championName'], + role: participant['teamPosition']&.downcase || player.role, + kills: participant['kills'], + deaths: participant['deaths'], + assists: participant['assists'], + total_damage_dealt: participant['totalDamageDealtToChampions'], + total_damage_taken: participant['totalDamageTaken'], + gold_earned: participant['goldEarned'], + total_cs: participant['totalMinionsKilled'] + participant['neutralMinionsKilled'], + vision_score: participant['visionScore'], + wards_placed: participant['wardsPlaced'], + wards_killed: participant['wardsKilled'], + first_blood: participant['firstBloodKill'], + double_kills: participant['doubleKills'], + triple_kills: participant['tripleKills'], + quadra_kills: participant['quadraKills'], + penta_kills: participant['pentaKills'] + ) + end + + # Validate and normalize region + def sanitize_region(region) + normalized = region.to_s.downcase.strip + + unless VALID_REGIONS.include?(normalized) + raise ArgumentError, "Invalid region: #{region}. Must be one of: #{VALID_REGIONS.join(', ')}" + end + + normalized + end + + # Get safe Riot API hostname from whitelist (prevents SSRF) + def riot_api_host + host = REGION_HOSTS[@region] + raise SecurityError, "Region #{@region} not in whitelist" if host.nil? + + host + end + + # Get safe regional API hostname from whitelist (prevents SSRF) + def regional_api_host(endpoint_name) + host = REGIONAL_ENDPOINT_HOSTS[endpoint_name] + raise SecurityError, "Regional endpoint #{endpoint_name} not in whitelist" if host.nil? + + host + end + + # Get regional endpoint for match/account APIs + def get_regional_endpoint(platform_region) + return 'europe' if EUROPE.include?(platform_region) + return 'asia' if ASIA.include?(platform_region) + + 'americas' # Default for Americas and unknown regions + end + end + end +end diff --git a/app/modules/players/services/stats_service.rb b/app/modules/players/services/stats_service.rb new file mode 100644 index 0000000..deced4c --- /dev/null +++ b/app/modules/players/services/stats_service.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Players + module Services + class StatsService + include Analytics::Concerns::AnalyticsCalculations + + attr_reader :player + + def initialize(player) + @player = player + end + + def calculate_stats + matches = player.matches.order(game_start: :desc) + recent_matches = matches.limit(20) + player_stats = PlayerMatchStat.where(player: player, match: matches) + + { + player: player, + overall: calculate_overall_stats(matches, player_stats), + recent_form: calculate_recent_form_stats(recent_matches), + champion_pool: player.champion_pools.order(games_played: :desc).limit(5), + performance_by_role: calculate_performance_by_role(player_stats) + } + end + + def self.calculate_win_rate(matches) + return 0 if matches.empty? + + ((matches.victories.count.to_f / matches.count) * 100).round(1) + end + + def self.calculate_avg_kda(stats) + return 0 if stats.empty? + + total_kills = stats.sum(:kills) + total_deaths = stats.sum(:deaths) + total_assists = stats.sum(:assists) + + deaths = total_deaths.zero? ? 1 : total_deaths + ((total_kills + total_assists).to_f / deaths).round(2) + end + + def self.calculate_recent_form(matches) + matches.map { |m| m.victory? ? 'W' : 'L' } + end + + private + + def calculate_overall_stats(matches, player_stats) + { + total_matches: matches.count, + wins: matches.victories.count, + losses: matches.defeats.count, + win_rate: self.class.calculate_win_rate(matches), + avg_kda: self.class.calculate_avg_kda(player_stats), + avg_cs: player_stats.average(:cs)&.round(1) || 0, + avg_vision_score: player_stats.average(:vision_score)&.round(1) || 0, + avg_damage: player_stats.average(:damage_dealt_champions)&.round(0) || 0 + } + end + + def calculate_recent_form_stats(recent_matches) + { + last_5_matches: self.class.calculate_recent_form(recent_matches.limit(5)), + last_10_matches: self.class.calculate_recent_form(recent_matches.limit(10)) + } + end + + def calculate_performance_by_role(stats) + grouped_stats = group_stats_by_player_role(stats) + grouped_stats.map { |stat| format_player_role_stat(stat) } + end + + def group_stats_by_player_role(stats) + stats.group(:role).select( + 'role', + 'COUNT(*) as games', + 'AVG(kills) as avg_kills', + 'AVG(deaths) as avg_deaths', + 'AVG(assists) as avg_assists', + 'AVG(performance_score) as avg_performance' + ) + end + + def format_player_role_stat(stat) + { + role: stat.role, + games: stat.games, + avg_kda: format_avg_kda(stat), + avg_performance: stat.avg_performance&.round(1) || 0 + } + end + end + end +end diff --git a/app/modules/riot_integration/controllers/riot_data_controller.rb b/app/modules/riot_integration/controllers/riot_data_controller.rb new file mode 100644 index 0000000..7d08715 --- /dev/null +++ b/app/modules/riot_integration/controllers/riot_data_controller.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module RiotIntegration + module Controllers + class RiotDataController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: %i[champions champion_details items version] + + # GET /api/v1/riot-data/champions + def champions + service = DataDragonService.new + champions = service.champion_id_map + + render_success({ + champions: champions, + count: champions.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch champion data', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/champions/:champion_key + def champion_details + service = DataDragonService.new + champion = service.champion_by_key(params[:champion_key]) + + if champion.present? + render_success({ + champion: champion + }) + else + render_error('Champion not found', :not_found) + end + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch champion details', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/all-champions + def all_champions + service = DataDragonService.new + champions = service.all_champions + + render_success({ + champions: champions, + count: champions.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch champions', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/items + def items + service = DataDragonService.new + items = service.items + + render_success({ + items: items, + count: items.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch items', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/summoner-spells + def summoner_spells + service = DataDragonService.new + spells = service.summoner_spells + + render_success({ + summoner_spells: spells, + count: spells.count + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch summoner spells', :service_unavailable, details: e.message) + end + + # GET /api/v1/riot-data/version + def version + service = DataDragonService.new + version = service.latest_version + + render_success({ + version: version + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to fetch version', :service_unavailable, details: e.message) + end + + # POST /api/v1/riot-data/clear-cache + def clear_cache + authorize :riot_data, :manage? + + service = DataDragonService.new + service.clear_cache! + + log_user_action( + action: 'clear_cache', + entity_type: 'RiotData', + entity_id: nil, + details: { message: 'Data Dragon cache cleared' } + ) + + render_success({ + message: 'Cache cleared successfully' + }) + end + + # POST /api/v1/riot-data/update-cache + def update_cache + authorize :riot_data, :manage? + + service = DataDragonService.new + service.clear_cache! + + # Preload all data + version = service.latest_version + champions = service.champion_id_map + items = service.items + spells = service.summoner_spells + + log_user_action( + action: 'update_cache', + entity_type: 'RiotData', + entity_id: nil, + details: { + version: version, + champions_count: champions.count, + items_count: items.count, + spells_count: spells.count + } + ) + + render_success({ + message: 'Cache updated successfully', + version: version, + data: { + champions: champions.count, + items: items.count, + summoner_spells: spells.count + } + }) + rescue DataDragonService::DataDragonError => e + render_error('Failed to update cache', :service_unavailable, details: e.message) + end + end + end +end diff --git a/app/modules/riot_integration/controllers/riot_integration_controller.rb b/app/modules/riot_integration/controllers/riot_integration_controller.rb new file mode 100644 index 0000000..538eb02 --- /dev/null +++ b/app/modules/riot_integration/controllers/riot_integration_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module RiotIntegration + module Controllers + class RiotIntegrationController < Api::V1::BaseController + def sync_status + players = organization_scoped(Player) + + total_players = players.count + synced_players = players.where(sync_status: 'success').count + pending_sync = players.where(sync_status: ['pending', nil]).or(players.where(sync_status: nil)).count + failed_sync = players.where(sync_status: 'error').count + + recently_synced = players.where('last_sync_at > ?', 24.hours.ago).count + + needs_sync = players.where(last_sync_at: nil) + .or(players.where('last_sync_at < ?', 1.hour.ago)) + .count + + recent_syncs = players + .where.not(last_sync_at: nil) + .order(last_sync_at: :desc) + .limit(10) + .map do |player| + { + id: player.id, + summoner_name: player.summoner_name, + last_sync_at: player.last_sync_at, + sync_status: player.sync_status || 'pending' + } + end + + render_success({ + stats: { + total_players: total_players, + synced_players: synced_players, + pending_sync: pending_sync, + failed_sync: failed_sync, + recently_synced: recently_synced, + needs_sync: needs_sync + }, + recent_syncs: recent_syncs + }) + end + end + end +end diff --git a/app/modules/riot_integration/services/data_dragon_service.rb b/app/modules/riot_integration/services/data_dragon_service.rb new file mode 100644 index 0000000..a1ca9dc --- /dev/null +++ b/app/modules/riot_integration/services/data_dragon_service.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +class DataDragonService + BASE_URL = 'https://ddragon.leagueoflegends.com' + + class DataDragonError < StandardError; end + + def initialize + @latest_version = nil + end + + def latest_version + @latest_version ||= fetch_latest_version + end + + def champion_id_map + Rails.cache.fetch('riot:champion_id_map', expires_in: 1.week) do + fetch_champion_data + end + end + + def champion_name_map + Rails.cache.fetch('riot:champion_name_map', expires_in: 1.week) do + champion_id_map.invert + end + end + + def all_champions + Rails.cache.fetch('riot:all_champions', expires_in: 1.week) do + fetch_all_champions_data + end + end + + def champion_by_key(champion_key) + all_champions[champion_key] + end + + def profile_icons + Rails.cache.fetch('riot:profile_icons', expires_in: 1.week) do + fetch_profile_icons + end + end + + def summoner_spells + Rails.cache.fetch('riot:summoner_spells', expires_in: 1.week) do + fetch_summoner_spells + end + end + + def items + Rails.cache.fetch('riot:items', expires_in: 1.week) do + fetch_items + end + end + + def clear_cache! + Rails.cache.delete('riot:champion_id_map') + Rails.cache.delete('riot:champion_name_map') + Rails.cache.delete('riot:all_champions') + Rails.cache.delete('riot:profile_icons') + Rails.cache.delete('riot:summoner_spells') + Rails.cache.delete('riot:items') + Rails.cache.delete('riot:latest_version') + @latest_version = nil + end + + private + + def fetch_latest_version + cached_version = Rails.cache.read('riot:latest_version') + return cached_version if cached_version.present? + + url = "#{BASE_URL}/api/versions.json" + response = make_request(url) + versions = JSON.parse(response.body) + + latest = versions.first + Rails.cache.write('riot:latest_version', latest, expires_in: 1.day) + latest + rescue StandardError => e + Rails.logger.error("Failed to fetch latest version: #{e.message}") + # Fallback to a recent known version + '14.1.1' + end + + def fetch_champion_data + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/champion.json" + + response = make_request(url) + data = JSON.parse(response.body) + + 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 + end + + champion_map + rescue StandardError => e + Rails.logger.error("Failed to fetch champion data: #{e.message}") + {} + end + + def fetch_all_champions_data + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/champion.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch all champions data: #{e.message}") + {} + end + + def fetch_profile_icons + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/profileicon.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch profile icons: #{e.message}") + {} + end + + def fetch_summoner_spells + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/summoner.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch summoner spells: #{e.message}") + {} + end + + def fetch_items + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/item.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch items: #{e.message}") + {} + end + + def make_request(url) + conn = Faraday.new do |f| + f.request :retry, max: 3, interval: 0.5, backoff_factor: 2 + f.adapter Faraday.default_adapter + end + + response = conn.get(url) do |req| + req.options.timeout = 10 + end + + raise DataDragonError, "Request failed with status #{response.status}" unless response.success? + + response + rescue Faraday::TimeoutError => e + raise DataDragonError, "Request timeout: #{e.message}" + rescue Faraday::Error => e + raise DataDragonError, "Network error: #{e.message}" + end +end diff --git a/app/modules/riot_integration/services/riot_api_service.rb b/app/modules/riot_integration/services/riot_api_service.rb new file mode 100644 index 0000000..351f5c4 --- /dev/null +++ b/app/modules/riot_integration/services/riot_api_service.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +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' } + }.freeze + + class RiotApiError < StandardError; end + 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? + 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)}" + + response = make_request(url) + parse_summoner_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) + 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) + 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) + 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) + 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 + f.adapter Faraday.default_adapter + end + + response = conn.get(url) do |req| + req.headers['X-Riot-Token'] = @api_key + req.options.timeout = 10 + end + + handle_response(response) + rescue Faraday::TimeoutError => e + raise RiotApiError, "Request timeout: #{e.message}" + rescue Faraday::Error => e + raise RiotApiError, "Network error: #{e.message}" + 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 429 + retry_after = response.headers['Retry-After']&.to_i || 120 + 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}" + 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) + REGIONS.dig(region.upcase, :platform) || raise(RiotApiError, "Unknown region: #{region}") + end + + def regional_route_for_region(region) + REGIONS.dig(region.upcase, :region) || raise(RiotApiError, "Unknown region: #{region}") + end + + def parse_summoner_response(response) + data = JSON.parse(response.body) + { + summoner_id: data['id'], + puuid: data['puuid'], + summoner_name: data['name'], + summoner_level: data['summonerLevel'], + profile_icon_id: data['profileIconId'] + } + end + + 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') + } + end + + def find_queue_entry(entries, queue_type) + entry = entries.find { |e| e['queueType'] == queue_type } + return nil unless entry + + { + tier: entry['tier'], + rank: entry['rank'], + lp: entry['leaguePoints'], + wins: entry['wins'], + losses: entry['losses'] + } + end + + def parse_match_details(response) + data = JSON.parse(response.body) + info = data['info'] + metadata = data['metadata'] + + { + match_id: metadata['matchId'], + game_creation: Time.at(info['gameCreation'] / 1000), + game_duration: info['gameDuration'], + game_mode: info['gameMode'], + game_version: info['gameVersion'], + participants: info['participants'].map { |p| parse_participant(p) } + } + end + + def parse_participant(participant) + { + puuid: participant['puuid'], + summoner_name: participant['summonerName'], + champion_name: participant['championName'], + champion_id: participant['championId'], + team_id: participant['teamId'], + role: participant['teamPosition']&.downcase, + kills: participant['kills'], + deaths: participant['deaths'], + assists: participant['assists'], + gold_earned: participant['goldEarned'], + total_damage_dealt: participant['totalDamageDealtToChampions'], + 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'], + first_blood_kill: participant['firstBloodKill'], + double_kills: participant['doubleKills'], + triple_kills: participant['tripleKills'], + quadra_kills: participant['quadraKills'], + penta_kills: participant['pentaKills'], + win: participant['win'] + } + end + + def parse_champion_mastery(response) + masteries = JSON.parse(response.body) + + masteries.map do |mastery| + { + champion_id: mastery['championId'], + champion_level: mastery['championLevel'], + champion_points: mastery['championPoints'], + last_played: Time.at(mastery['lastPlayTime'] / 1000) + } + end + end +end diff --git a/app/modules/schedules/controllers/schedules_controller.rb b/app/modules/schedules/controllers/schedules_controller.rb new file mode 100644 index 0000000..af726a5 --- /dev/null +++ b/app/modules/schedules/controllers/schedules_controller.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Schedules + module Controllers + class SchedulesController < Api::V1::BaseController + before_action :set_schedule, only: %i[show update destroy] + + def index + schedules = organization_scoped(Schedule).includes(:match) + + schedules = schedules.where(event_type: params[:event_type]) if params[:event_type].present? + schedules = schedules.where(status: params[:status]) if params[:status].present? + + if params[:start_date].present? && params[:end_date].present? + schedules = schedules.where(start_time: params[:start_date]..params[:end_date]) + elsif params[:upcoming] == 'true' + schedules = schedules.where('start_time >= ?', Time.current) + elsif params[:past] == 'true' + schedules = schedules.where('end_time < ?', Time.current) + end + + if params[:today] == 'true' + schedules = schedules.where(start_time: Time.current.beginning_of_day..Time.current.end_of_day) + end + + if params[:this_week] == 'true' + schedules = schedules.where(start_time: Time.current.beginning_of_week..Time.current.end_of_week) + end + + # Whitelist for sort parameters to prevent SQL injection + allowed_sort_orders = %w[asc desc] + sort_order = allowed_sort_orders.include?(params[:sort_order]&.downcase) ? params[:sort_order].downcase : 'asc' + schedules = schedules.order(start_time: sort_order) + + result = paginate(schedules) + + render_success({ + schedules: ScheduleSerializer.render_as_hash(result[:data]), + pagination: result[:pagination] + }) + end + + def show + render_success({ + schedule: ScheduleSerializer.render_as_hash(@schedule) + }) + end + + def create + schedule = organization_scoped(Schedule).new(schedule_params) + schedule.organization = current_organization + + if schedule.save + log_user_action( + action: 'create', + entity_type: 'Schedule', + entity_id: schedule.id, + new_values: schedule.attributes + ) + + render_created({ + schedule: ScheduleSerializer.render_as_hash(schedule) + }, message: 'Event scheduled successfully') + else + render_error( + message: 'Failed to create schedule', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: schedule.errors.as_json + ) + end + end + + def update + old_values = @schedule.attributes.dup + + if @schedule.update(schedule_params) + log_user_action( + action: 'update', + entity_type: 'Schedule', + entity_id: @schedule.id, + old_values: old_values, + new_values: @schedule.attributes + ) + + render_updated({ + schedule: ScheduleSerializer.render_as_hash(@schedule) + }) + else + render_error( + message: 'Failed to update schedule', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @schedule.errors.as_json + ) + end + end + + def destroy + if @schedule.destroy + log_user_action( + action: 'delete', + entity_type: 'Schedule', + entity_id: @schedule.id, + old_values: @schedule.attributes + ) + + render_deleted(message: 'Event deleted successfully') + else + render_error( + message: 'Failed to delete schedule', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_schedule + @schedule = organization_scoped(Schedule).find(params[:id]) + end + + def schedule_params + params.require(:schedule).permit( + :event_type, :title, :description, + :start_time, :end_time, :location, + :opponent_name, :status, :match_id, + :meeting_url, :all_day, :timezone, + :color, :is_recurring, :recurrence_rule, + :recurrence_end_date, :reminder_minutes, + required_players: [], optional_players: [], tags: [] + ) + end + end + end +end diff --git a/app/modules/scouting/controllers/players_controller.rb b/app/modules/scouting/controllers/players_controller.rb new file mode 100644 index 0000000..9690e1c --- /dev/null +++ b/app/modules/scouting/controllers/players_controller.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scouting + class PlayersController < Api::V1::BaseController + before_action :set_scouting_target, only: %i[show update destroy sync] + + def index + targets = organization_scoped(ScoutingTarget).includes(:added_by, :assigned_to) + targets = apply_filters(targets) + targets = apply_sorting(targets) + + result = paginate(targets) + + render_success({ + players: ScoutingTargetSerializer.render_as_hash(result[:data]), + total: result[:pagination][:total_count], + page: result[:pagination][:current_page], + per_page: result[:pagination][:per_page], + total_pages: result[:pagination][:total_pages] + }) + end + + def show + render_success({ + scouting_target: ScoutingTargetSerializer.render_as_hash(@target) + }) + end + + def create + target = organization_scoped(ScoutingTarget).new(scouting_target_params) + target.organization = current_organization + target.added_by = current_user + + if target.save + log_user_action( + action: 'create', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: target.attributes + ) + + render_created({ + scouting_target: ScoutingTargetSerializer.render_as_hash(target) + }, message: 'Scouting target added successfully') + else + render_error( + message: 'Failed to add scouting target', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: target.errors.as_json + ) + end + end + + def update + old_values = @target.attributes.dup + + if @target.update(scouting_target_params) + log_user_action( + action: 'update', + entity_type: 'ScoutingTarget', + entity_id: @target.id, + old_values: old_values, + new_values: @target.attributes + ) + + render_updated({ + scouting_target: ScoutingTargetSerializer.render_as_hash(@target) + }) + else + render_error( + message: 'Failed to update scouting target', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @target.errors.as_json + ) + end + end + + def destroy + if @target.destroy + log_user_action( + action: 'delete', + entity_type: 'ScoutingTarget', + entity_id: @target.id, + old_values: @target.attributes + ) + + render_deleted(message: 'Scouting target removed successfully') + else + render_error( + message: 'Failed to remove scouting target', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + def sync + # This will sync the scouting target with Riot API + # Will be implemented when Riot API service is ready + # todo + render_error( + message: 'Sync functionality not yet implemented', + code: 'NOT_IMPLEMENTED', + status: :not_implemented + ) + end + + private + + def apply_filters(targets) + targets = apply_basic_filters(targets) + targets = apply_age_range_filter(targets) + targets = apply_boolean_filters(targets) + apply_search_filter(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 = targets.by_priority(params[:priority]) if params[:priority].present? + targets = targets.by_region(params[:region]) if params[:region].present? + targets = targets.assigned_to_user(params[:assigned_to_id]) if params[:assigned_to_id].present? + targets + end + + def apply_age_range_filter(targets) + return targets unless params[:age_range].present? && params[:age_range].is_a?(Array) + + min_age, max_age = params[:age_range] + min_age && max_age ? targets.where(age: min_age..max_age) : targets + end + + def apply_boolean_filters(targets) + targets = targets.active if params[:active] == 'true' + targets = targets.high_priority if params[:high_priority] == 'true' + targets = targets.needs_review if params[:needs_review] == 'true' + targets + end + + def apply_search_filter(targets) + return targets unless params[:search].present? + + search_term = "%#{params[:search]}%" + targets.where('summoner_name ILIKE ? OR real_name ILIKE ?', search_term, search_term) + end + + def apply_sorting(targets) + sort_by, sort_order = validate_sort_params + + case sort_by + when 'rank' + apply_rank_sorting(targets, sort_order) + when 'winrate' + apply_winrate_sorting(targets, sort_order) + else + targets.order(sort_by => sort_order) + end + end + + def validate_sort_params + allowed_sort_fields = %w[created_at updated_at summoner_name current_tier priority status role region age rank + winrate] + allowed_sort_orders = %w[asc desc] + + sort_by = allowed_sort_fields.include?(params[:sort_by]) ? params[:sort_by] : 'created_at' + sort_order = allowed_sort_orders.include?(params[:sort_order]&.downcase) ? params[:sort_order].downcase : 'desc' + + [sort_by, sort_order] + end + + def apply_rank_sorting(targets, sort_order) + column = ScoutingTarget.arel_table[:current_lp] + order_clause = sort_order == 'asc' ? column.asc.nulls_last : column.desc.nulls_last + targets.order(order_clause) + end + + def apply_winrate_sorting(targets, sort_order) + column = ScoutingTarget.arel_table[:performance_trend] + order_clause = sort_order == 'asc' ? column.asc.nulls_last : column.desc.nulls_last + targets.order(order_clause) + end + + def set_scouting_target + @target = organization_scoped(ScoutingTarget).find(params[:id]) + end + + def scouting_target_params + # :role refers to in-game position (top/jungle/mid/adc/support), not user role + # nosemgrep + params.require(:scouting_target).permit( + :summoner_name, :real_name, :role, :region, :nationality, + :age, :status, :priority, :current_team, + :current_tier, :current_rank, :current_lp, + :peak_tier, :peak_rank, + :riot_puuid, :riot_summoner_id, + :email, :phone, :discord_username, :twitter_handle, + :scouting_notes, :contact_notes, + :availability, :salary_expectations, + :performance_trend, :assigned_to_id, + champion_pool: [] + ) + end + end + end + end +end diff --git a/app/modules/scouting/controllers/regions_controller.rb b/app/modules/scouting/controllers/regions_controller.rb new file mode 100644 index 0000000..15d3152 --- /dev/null +++ b/app/modules/scouting/controllers/regions_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scouting + class RegionsController < Api::V1::BaseController + skip_before_action :authenticate_request!, only: [:index] + + def index + regions = [ + { code: 'BR', name: 'Brazil', platform: 'BR1' }, + { code: 'NA', name: 'North America', platform: 'NA1' }, + { code: 'EUW', name: 'Europe West', platform: 'EUW1' }, + { code: 'EUNE', name: 'Europe Nordic & East', platform: 'EUN1' }, + { code: 'KR', name: 'Korea', platform: 'KR' }, + { code: 'JP', name: 'Japan', platform: 'JP1' }, + { code: 'OCE', name: 'Oceania', platform: 'OC1' }, + { code: 'LAN', name: 'Latin America North', platform: 'LA1' }, + { code: 'LAS', name: 'Latin America South', platform: 'LA2' }, + { code: 'RU', name: 'Russia', platform: 'RU' }, + { code: 'TR', name: 'Turkey', platform: 'TR1' } + ] + + render_success(regions) + end + end + end + end +end diff --git a/app/modules/scouting/controllers/watchlist_controller.rb b/app/modules/scouting/controllers/watchlist_controller.rb new file mode 100644 index 0000000..67a6aaf --- /dev/null +++ b/app/modules/scouting/controllers/watchlist_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scouting + class WatchlistController < Api::V1::BaseController + def index + # Watchlist is just high-priority scouting targets + targets = organization_scoped(ScoutingTarget) + .where(priority: %w[high critical]) + .where(status: %w[watching contacted negotiating]) + .includes(:added_by, :assigned_to) + .order(priority: :desc, created_at: :desc) + + render_success({ + watchlist: ScoutingTargetSerializer.render_as_hash(targets), + count: targets.size + }) + end + + def create + # Add a scouting target to watchlist by updating its priority + target = organization_scoped(ScoutingTarget).find(params[:scouting_target_id]) + + if target.update(priority: 'high') + log_user_action( + action: 'add_to_watchlist', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: { priority: 'high' } + ) + + render_created({ + scouting_target: ScoutingTargetSerializer.render_as_hash(target) + }, message: 'Added to watchlist') + else + render_error( + message: 'Failed to add to watchlist', + code: 'UPDATE_ERROR', + status: :unprocessable_entity + ) + end + end + + def destroy + target = organization_scoped(ScoutingTarget).find(params[:id]) + + if target.update(priority: 'medium') + log_user_action( + action: 'remove_from_watchlist', + entity_type: 'ScoutingTarget', + entity_id: target.id, + new_values: { priority: 'medium' } + ) + + render_deleted(message: 'Removed from watchlist') + else + render_error( + message: 'Failed to remove from watchlist', + code: 'UPDATE_ERROR', + status: :unprocessable_entity + ) + end + end + end + end + end +end diff --git a/app/modules/scouting/jobs/sync_scouting_target_job.rb b/app/modules/scouting/jobs/sync_scouting_target_job.rb new file mode 100644 index 0000000..2d8703c --- /dev/null +++ b/app/modules/scouting/jobs/sync_scouting_target_job.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Scouting + module Jobs + class SyncScoutingTargetJob < ApplicationJob + include RankComparison + + queue_as :default + + retry_on RiotApiService::RateLimitError, wait: :polynomially_longer, attempts: 5 + retry_on RiotApiService::RiotApiError, wait: 1.minute, attempts: 3 + + def perform(scouting_target_id) + target = ScoutingTarget.find(scouting_target_id) + riot_service = RiotApiService.new + + if target.riot_puuid.blank? + summoner_data = riot_service.get_summoner_by_name( + summoner_name: target.summoner_name, + region: target.region + ) + + target.update!( + riot_puuid: summoner_data[:puuid], + riot_summoner_id: summoner_data[:summoner_id] + ) + end + + if target.riot_summoner_id.present? + league_data = riot_service.get_league_entries( + summoner_id: target.riot_summoner_id, + region: target.region + ) + + update_rank_info(target, league_data) + end + + if target.riot_puuid.present? + mastery_data = riot_service.get_champion_mastery( + puuid: target.riot_puuid, + region: target.region + ) + + update_champion_pool(target, mastery_data) + end + + target.update!(last_sync_at: Time.current) + + Rails.logger.info("Successfully synced scouting target #{target.id}") + rescue RiotApiService::NotFoundError => e + Rails.logger.error("Scouting target not found in Riot API: #{target.summoner_name} - #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to sync scouting target #{target.id}: #{e.message}") + raise + end + + private + + def update_rank_info(target, league_data) + update_attributes = {} + + if league_data[:solo_queue].present? + solo = league_data[:solo_queue] + update_attributes.merge!( + current_tier: solo[:tier], + current_rank: solo[:rank], + current_lp: solo[:lp] + ) + + # Update peak if current is higher + if should_update_peak?(target, solo[:tier], solo[:rank]) + update_attributes.merge!( + peak_tier: solo[:tier], + peak_rank: solo[:rank] + ) + end + end + + target.update!(update_attributes) if update_attributes.present? + end + + def update_champion_pool(target, mastery_data) + champion_id_map = load_champion_id_map + champion_names = mastery_data.take(10).map do |mastery| + champion_id_map[mastery[:champion_id]] + end.compact + + target.update!(champion_pool: champion_names) + end + + def load_champion_id_map + DataDragonService.new.champion_id_map + end + end + end +end diff --git a/app/modules/scrims/controllers/opponent_teams_controller.rb b/app/modules/scrims/controllers/opponent_teams_controller.rb new file mode 100644 index 0000000..86a12ff --- /dev/null +++ b/app/modules/scrims/controllers/opponent_teams_controller.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scrims + # OpponentTeams Controller + # + # Manages opponent team records which are shared across organizations. + # Security note: Update and delete operations are restricted to organizations + # that have used this opponent team in scrims. + class OpponentTeamsController < Api::V1::BaseController + include TierAuthorization + include Paginatable + + before_action :set_opponent_team, only: %i[show update destroy scrim_history] + before_action :verify_team_usage!, only: %i[update destroy] + + # GET /api/v1/scrims/opponent_teams + def index + teams = OpponentTeam.all.order(:name) + + # Filters + teams = teams.by_region(params[:region]) if params[:region].present? + teams = teams.by_tier(params[:tier]) if params[:tier].present? + teams = teams.by_league(params[:league]) if params[:league].present? + teams = teams.with_scrims if params[:with_scrims] == 'true' + + # Search + if params[:search].present? + search_term = ActiveRecord::Base.sanitize_sql_like(params[:search]) + teams = teams.where('name ILIKE ? OR tag ILIKE ?', "%#{search_term}%", "%#{search_term}%") + end + + # Pagination + page = params[:page] || 1 + per_page = params[:per_page] || 20 + + teams = teams.page(page).per(per_page) + + render json: { + data: { + opponent_teams: teams.map { |team| ScrimOpponentTeamSerializer.new(team).as_json }, + meta: pagination_meta(teams) + } + } + end + + # GET /api/v1/scrims/opponent_teams/:id + def show + render json: { data: ScrimOpponentTeamSerializer.new(@opponent_team, detailed: true).as_json } + end + + # GET /api/v1/scrims/opponent_teams/:id/scrim_history + def scrim_history + scrims = current_organization.scrims + .where(opponent_team_id: @opponent_team.id) + .includes(:match) + .order(scheduled_at: :desc) + + service = Scrims::ScrimAnalyticsService.new(current_organization) + opponent_stats = service.opponent_performance(@opponent_team.id) + + render json: { + data: { + opponent_team: ScrimOpponentTeamSerializer.new(@opponent_team).as_json, + scrims: scrims.map { |scrim| ScrimSerializer.new(scrim).as_json }, + stats: opponent_stats + } + } + end + + # POST /api/v1/scrims/opponent_teams + def create + team = OpponentTeam.new(opponent_team_params) + + if team.save + render json: { data: ScrimOpponentTeamSerializer.new(team).as_json }, status: :created + else + render json: { errors: team.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH /api/v1/scrims/opponent_teams/:id + def update + if @opponent_team.update(opponent_team_params) + render json: { data: ScrimOpponentTeamSerializer.new(@opponent_team).as_json } + else + render json: { errors: @opponent_team.errors.full_messages }, status: :unprocessable_entity + end + end + + # DELETE /api/v1/scrims/opponent_teams/:id + def destroy + # Check if team has scrims from other organizations before deleting + other_org_scrims = @opponent_team.scrims.where.not(organization_id: current_organization.id).exists? + + if other_org_scrims + return render json: { + error: 'Cannot delete opponent team that is used by other organizations' + }, status: :unprocessable_entity + end + + @opponent_team.destroy + head :no_content + end + + private + + # Finds opponent team by ID + # Security Note: OpponentTeam is a shared resource across organizations. + # Access control is enforced via verify_team_usage! before_action for + # sensitive operations (update/destroy). This ensures organizations can + # only modify teams they have scrims with. + # Read operations (index/show) are allowed for all teams to enable discovery. + # + def set_opponent_team + id = Integer(params[:id], exception: false) + return render json: { error: 'Opponent team not found' }, status: :not_found unless id + + @opponent_team = OpponentTeam.find_by(id: id) + return render json: { error: 'Opponent team not found' }, status: :not_found unless @opponent_team + end + + # Verifies that current organization has used this opponent team + # Prevents organizations from modifying/deleting teams they haven't interacted with + def verify_team_usage! + has_scrims = current_organization.scrims.exists?(opponent_team_id: @opponent_team.id) + + return if has_scrims + + render json: { + error: 'You cannot modify this opponent team. Your organization has not played against them.' + }, status: :forbidden + end + + def opponent_team_params + params.require(:opponent_team).permit( + :name, + :tag, + :region, + :tier, + :league, + :logo_url, + :playstyle_notes, + :contact_email, + :discord_server, + known_players: [], + strengths: [], + weaknesses: [], + recent_performance: {}, + preferred_champions: {} + ) + end + end + end + end +end diff --git a/app/modules/scrims/controllers/scrims_controller.rb b/app/modules/scrims/controllers/scrims_controller.rb new file mode 100644 index 0000000..8d7c7c3 --- /dev/null +++ b/app/modules/scrims/controllers/scrims_controller.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module Api + module V1 + module Scrims + class ScrimsController < Api::V1::BaseController + include TierAuthorization + include Paginatable + + before_action :set_scrim, only: %i[show update destroy add_game] + + # GET /api/v1/scrims + def index + scrims = current_organization.scrims + .includes(:opponent_team, :match) + .order(scheduled_at: :desc) + + # Filters + scrims = scrims.by_type(params[:scrim_type]) if params[:scrim_type].present? + scrims = scrims.by_focus_area(params[:focus_area]) if params[:focus_area].present? + scrims = scrims.where(opponent_team_id: params[:opponent_team_id]) if params[:opponent_team_id].present? + + # Status filter + case params[:status] + when 'upcoming' + scrims = scrims.upcoming + when 'past' + scrims = scrims.past + when 'completed' + scrims = scrims.completed + when 'in_progress' + scrims = scrims.in_progress + end + + # Pagination + page = params[:page] || 1 + per_page = params[:per_page] || 20 + + scrims = scrims.page(page).per(per_page) + + render json: { + data: { + scrims: scrims.map { |scrim| Scrims::Serializers::ScrimSerializer.new(scrim).as_json }, + meta: pagination_meta(scrims) + } + } + end + + # GET /api/v1/scrims/calendar + def calendar + start_date = params[:start_date]&.to_date || Date.current.beginning_of_month + end_date = params[:end_date]&.to_date || Date.current.end_of_month + + scrims = current_organization.scrims + .includes(:opponent_team) + .where(scheduled_at: start_date..end_date) + .order(scheduled_at: :asc) + + render json: { + scrims: scrims.map { |scrim| ScrimSerializer.new(scrim, calendar_view: true).as_json }, + start_date: start_date, + end_date: end_date + } + end + + # GET /api/v1/scrims/analytics + def analytics + service = Scrims::ScrimAnalyticsService.new(current_organization) + date_range = (params[:days]&.to_i || 30).days + + render json: { + overall_stats: service.overall_stats(date_range: date_range), + by_opponent: service.stats_by_opponent, + by_focus_area: service.stats_by_focus_area, + success_patterns: service.success_patterns, + improvement_trends: service.improvement_trends + } + end + + # GET /api/v1/scrims/:id + def show + render json: ScrimSerializer.new(@scrim, detailed: true).as_json + end + + # POST /api/v1/scrims + def create + # Check scrim creation limit + unless current_organization.can_create_scrim? + return render json: { + error: 'Scrim Limit Reached', + message: 'You have reached your monthly scrim limit. Upgrade to create more scrims.' + }, status: :forbidden + end + + scrim = current_organization.scrims.new(scrim_params) + + if scrim.save + render json: ScrimSerializer.new(scrim).as_json, status: :created + else + render json: { errors: scrim.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH /api/v1/scrims/:id + def update + if @scrim.update(scrim_params) + render json: ScrimSerializer.new(@scrim).as_json + else + render json: { errors: @scrim.errors.full_messages }, status: :unprocessable_entity + end + end + + # DELETE /api/v1/scrims/:id + def destroy + @scrim.destroy + head :no_content + end + + # POST /api/v1/scrims/:id/add_game + def add_game + victory = params[:victory] + duration = params[:duration] + notes = params[:notes] + + if @scrim.add_game_result(victory: victory, duration: duration, notes: notes) + # Update opponent team stats if present + @scrim.opponent_team.update_scrim_stats!(victory: victory) if @scrim.opponent_team.present? + + render json: ScrimSerializer.new(@scrim.reload).as_json + else + render json: { errors: @scrim.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def set_scrim + @scrim = current_organization.scrims.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Scrim not found' }, status: :not_found + end + + def scrim_params + params.require(:scrim).permit( + :opponent_team_id, + :match_id, + :scheduled_at, + :scrim_type, + :focus_area, + :pre_game_notes, + :post_game_notes, + :is_confidential, + :visibility, + :games_planned, + :games_completed, + game_results: [], + objectives: {}, + outcomes: {} + ) + end + end + end + end +end diff --git a/app/modules/scrims/serializers/competitive_match_serializer.rb b/app/modules/scrims/serializers/competitive_match_serializer.rb new file mode 100644 index 0000000..d055ac5 --- /dev/null +++ b/app/modules/scrims/serializers/competitive_match_serializer.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Scrims + class CompetitiveMatchSerializer + def initialize(competitive_match, options = {}) + @competitive_match = competitive_match + @options = options + end + + def as_json + base_attributes.tap do |hash| + hash.merge!(detailed_attributes) if @options[:detailed] + end + end + + private + + def base_attributes + { + id: @competitive_match.id, + organization_id: @competitive_match.organization_id, + tournament_name: @competitive_match.tournament_name, + tournament_display: @competitive_match.tournament_display, + tournament_stage: @competitive_match.tournament_stage, + tournament_region: @competitive_match.tournament_region, + match_date: @competitive_match.match_date, + match_format: @competitive_match.match_format, + game_number: @competitive_match.game_number, + game_label: @competitive_match.game_label, + our_team_name: @competitive_match.our_team_name, + opponent_team_name: @competitive_match.opponent_team_name, + opponent_team: opponent_team_summary, + victory: @competitive_match.victory, + result_text: @competitive_match.result_text, + series_score: @competitive_match.series_score, + side: @competitive_match.side, + patch_version: @competitive_match.patch_version, + meta_relevant: @competitive_match.meta_relevant?, + created_at: @competitive_match.created_at, + updated_at: @competitive_match.updated_at + } + end + + def detailed_attributes + { + external_match_id: @competitive_match.external_match_id, + match_id: @competitive_match.match_id, + draft_summary: @competitive_match.draft_summary, + our_composition: @competitive_match.our_composition, + opponent_composition: @competitive_match.opponent_composition, + our_banned_champions: @competitive_match.our_banned_champions, + opponent_banned_champions: @competitive_match.opponent_banned_champions, + our_picked_champions: @competitive_match.our_picked_champions, + opponent_picked_champions: @competitive_match.opponent_picked_champions, + has_complete_draft: @competitive_match.has_complete_draft?, + meta_champions: @competitive_match.meta_champions, + game_stats: @competitive_match.game_stats, + vod_url: @competitive_match.vod_url, + external_stats_url: @competitive_match.external_stats_url, + draft_phase_sequence: @competitive_match.draft_phase_sequence + } + end + + def opponent_team_summary + return nil unless @competitive_match.opponent_team + + { + id: @competitive_match.opponent_team.id, + name: @competitive_match.opponent_team.name, + tag: @competitive_match.opponent_team.tag, + tier: @competitive_match.opponent_team.tier, + logo_url: @competitive_match.opponent_team.logo_url + } + end + end +end diff --git a/app/modules/scrims/services/scrim_analytics_service.rb b/app/modules/scrims/services/scrim_analytics_service.rb new file mode 100644 index 0000000..0207915 --- /dev/null +++ b/app/modules/scrims/services/scrim_analytics_service.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Scrims + module Services + # Service for calculating scrim analytics + # Delegates pure calculations to Scrims::Utilities::AnalyticsCalculator + class ScrimAnalyticsService + def initialize(organization) + @organization = organization + end + + # Overall scrim statistics + def overall_stats(date_range: 30.days) + scrims = @organization.scrims.where('created_at > ?', date_range.ago) + + { + total_scrims: scrims.count, + total_games: scrims.sum(:games_completed), + win_rate: calculator.calculate_win_rate(scrims), + most_practiced_opponent: calculator.most_frequent_opponent(scrims), + focus_areas: focus_area_breakdown(scrims), + improvement_metrics: track_improvement(scrims), + completion_rate: calculator.completion_rate(scrims) + } + end + + # Stats grouped by opponent + def stats_by_opponent + scrims = @organization.scrims.includes(:opponent_team).to_a + + scrims.group_by(&:opponent_team_id).map do |opponent_id, opponent_scrims| + next unless opponent_id + + opponent_team = OpponentTeam.find(opponent_id) + { + opponent_team: { + id: opponent_team.id, + name: opponent_team.name, + tag: opponent_team.tag + }, + total_scrims: opponent_scrims.size, + total_games: opponent_scrims.sum(&:games_completed).to_i, + win_rate: calculator.calculate_win_rate(opponent_scrims) + } + end.compact + end + + # Stats grouped by focus area + def stats_by_focus_area + scrims = @organization.scrims.where.not(focus_area: nil) + + scrims.group_by(&:focus_area).transform_values do |area_scrims| + { + total_scrims: area_scrims.size, + total_games: area_scrims.sum(&:games_completed).to_i, + win_rate: calculator.calculate_win_rate(area_scrims), + avg_completion: calculator.average_completion_percentage(area_scrims) + } + end + end + + # Performance against specific opponent + def opponent_performance(opponent_team_id) + scrims = @organization.scrims + .where(opponent_team_id: opponent_team_id) + .includes(:match) + + { + head_to_head_record: calculator.calculate_record(scrims), + total_games: scrims.sum(:games_completed), + win_rate: calculator.calculate_win_rate(scrims), + avg_game_duration: calculator.avg_duration(scrims), + most_successful_comps: successful_compositions(scrims), + improvement_over_time: calculator.performance_trend(scrims), + last_5_results: calculator.last_n_results(scrims, 5) + } + end + + # Identify patterns in successful scrims + def success_patterns + winning_scrims = @organization.scrims.select { |s| s.win_rate > 50 } + + { + best_focus_areas: calculator.best_performing_focus_areas(winning_scrims), + best_time_of_day: calculator.best_performance_time_of_day(winning_scrims), + optimal_games_count: calculator.optimal_games_per_scrim(winning_scrims), + common_objectives: calculator.common_objectives_in_wins(winning_scrims) + } + end + + # Track improvement trends over time + def improvement_trends + all_scrims = @organization.scrims.order(created_at: :asc) + + return {} if all_scrims.count < 10 + + # Split into time periods + first_quarter = all_scrims.limit(all_scrims.count / 4) + last_quarter = all_scrims.last(all_scrims.count / 4) + + { + initial_win_rate: calculator.calculate_win_rate(first_quarter), + recent_win_rate: calculator.calculate_win_rate(last_quarter), + improvement_delta: calculator.calculate_win_rate(last_quarter) - calculator.calculate_win_rate(first_quarter), + games_played_trend: calculator.games_played_trend(all_scrims), + consistency_score: calculator.consistency_score(all_scrims) + } + end + + private + + # Returns the calculator utility module + def calculator + @calculator ||= Scrims::Utilities::AnalyticsCalculator + end + + # Breaks down scrims by focus area + def focus_area_breakdown(scrims) + scrims.where.not(focus_area: nil) + .group(:focus_area) + .count + end + + # Tracks improvement metrics between early and recent scrims + def track_improvement(scrims) + ordered_scrims = scrims.order(created_at: :asc) + return {} if ordered_scrims.count < 10 + + first_10 = ordered_scrims.limit(10) + last_10 = ordered_scrims.last(10) + + { + initial_win_rate: calculator.calculate_win_rate(first_10), + recent_win_rate: calculator.calculate_win_rate(last_10), + improvement: calculator.calculate_win_rate(last_10) - calculator.calculate_win_rate(first_10) + } + end + + # Placeholder for composition analysis (requires match data) + def successful_compositions(_scrims) + [] + end + end + end +end diff --git a/app/modules/scrims/utilities/analytics_calculator.rb b/app/modules/scrims/utilities/analytics_calculator.rb new file mode 100644 index 0000000..3d88aa3 --- /dev/null +++ b/app/modules/scrims/utilities/analytics_calculator.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +module Scrims + module Utilities + # Pure calculation utilities for scrim analytics + # All methods are stateless and can be called as module functions + # + # @example + # Scrims::Utilities::AnalyticsCalculator.calculate_win_rate(scrims) + module AnalyticsCalculator + extend self + + # Calculates win rate from scrim game results + # + # @param scrims [Array] Collection of scrims + # @return [Float] Win rate percentage + def calculate_win_rate(scrims) + all_results = scrims.flat_map(&:game_results) + return 0 if all_results.empty? + + wins = all_results.count { |result| result['victory'] == true } + ((wins.to_f / all_results.size) * 100).round(2) + end + + # Formats record as "XW - YL" string + # + # @param scrims [Array] Collection of scrims + # @return [String] Formatted record + def calculate_record(scrims) + all_results = scrims.flat_map(&:game_results) + wins = all_results.count { |result| result['victory'] == true } + losses = all_results.size - wins + + "#{wins}W - #{losses}L" + end + + # Finds most frequent opponent from scrims + # + # @param scrims [Array] Collection of scrims + # @return [String, nil] Opponent team name or nil + def most_frequent_opponent(scrims) + opponent_counts = scrims.group_by(&:opponent_team_id).transform_values(&:count) + most_frequent_id = opponent_counts.max_by { |_, count| count }&.first + + return nil unless most_frequent_id + + opponent = OpponentTeam.find_by(id: most_frequent_id) + opponent&.name + end + + # Calculates completion rate of scrims + # + # @param scrims [Array] Collection of scrims + # @return [Float] Completion rate percentage + def completion_rate(scrims) + completed = scrims.count { |s| s.status == 'completed' } + return 0 if scrims.none? + + ((completed.to_f / scrims.count) * 100).round(2) + end + + # Calculates average game duration + # + # @param scrims [Array] Collection of scrims + # @return [String] Formatted duration (MM:SS) + def avg_duration(scrims) + results_with_duration = scrims.flat_map(&:game_results) + .select { |r| r['duration'].present? } + + return '00:00' if results_with_duration.empty? + + avg_seconds = results_with_duration.sum { |r| r['duration'].to_i } / results_with_duration.size + format_duration(avg_seconds) + end + + # Returns last N scrim results + # + # @param scrims [ActiveRecord::Relation] Scrim relation + # @param limit [Integer] Number of results to return + # @return [Array] Last N results + def last_n_results(scrims, limit) + scrims.order(scheduled_at: :desc).limit(limit).map do |scrim| + { + date: scrim.scheduled_at, + win_rate: scrim.win_rate, + games_played: scrim.games_completed, + focus_area: scrim.focus_area + } + end + end + + # Finds best performing focus areas + # + # @param scrims [Array] Collection of scrims + # @return [Hash] Top 3 focus areas with win rates + def best_performing_focus_areas(scrims) + scrims.group_by(&:focus_area) + .transform_values { |s| calculate_win_rate(s) } + .sort_by { |_, wr| -wr } + .first(3) + .to_h + end + + # Finds best time of day for performance + # + # @param scrims [Array] Collection of scrims + # @return [Integer, nil] Hour with best win rate + def best_performance_time_of_day(scrims) + by_hour = scrims.group_by { |s| s.scheduled_at&.hour } + + by_hour.transform_values { |s| calculate_win_rate(s) } + .max_by { |_, wr| wr } + &.first + end + + # Finds optimal number of games per scrim + # + # @param scrims [Array] Collection of scrims + # @return [Integer, nil] Optimal games count + def optimal_games_per_scrim(scrims) + by_games = scrims.group_by(&:games_planned) + + by_games.transform_values { |s| calculate_win_rate(s) } + .max_by { |_, wr| wr } + &.first + end + + # Finds common objectives in winning scrims + # + # @param scrims [Array] Collection of scrims + # @return [Hash] Top 5 objectives with counts + def common_objectives_in_wins(scrims) + objectives = scrims.flat_map { |s| s.objectives.keys } + objectives.tally.sort_by { |_, count| -count }.first(5).to_h + end + + # Calculates games played trend by week + # + # @param scrims [Array] Collection of scrims + # @return [Hash] Games played by week + def games_played_trend(scrims) + scrims.group_by { |s| s.created_at.beginning_of_week } + .transform_values { |s| s.sum(&:games_completed) } + end + + # Calculates consistency score (0-100, higher = more consistent) + # + # @param scrims [Array] Collection of scrims + # @return [Float] Consistency score + def consistency_score(scrims) + win_rates = scrims.map(&:win_rate) + return 0 if win_rates.empty? + + mean = win_rates.sum / win_rates.size + variance = win_rates.sum { |wr| (wr - mean)**2 } / win_rates.size + std_dev = Math.sqrt(variance) + + # Lower std_dev = more consistent (convert to 0-100 scale) + [100 - std_dev, 0].max.round(2) + end + + # Calculates average completion percentage + # + # @param scrims [Array] Collection of scrims + # @return [Float] Average completion percentage + def average_completion_percentage(scrims) + percentages = scrims.map(&:completion_percentage) + return 0 if percentages.empty? + + (percentages.sum / percentages.size).round(2) + end + + # Calculates performance trend over groups of scrims + # + # @param scrims [ActiveRecord::Relation] Ordered scrim relation + # @return [Array] Trend data + def performance_trend(scrims) + ordered = scrims.order(scheduled_at: :asc) + return [] if ordered.count < 3 + + ordered.each_cons(3).map do |group| + { + date_range: "#{group.first.scheduled_at.to_date} - #{group.last.scheduled_at.to_date}", + win_rate: calculate_win_rate(group) + } + end + end + + private + + # Formats duration in seconds to MM:SS + # + # @param seconds [Integer] Duration in seconds + # @return [String] Formatted duration + def format_duration(seconds) + minutes = seconds / 60 + secs = seconds % 60 + "#{minutes}:#{secs.to_s.rjust(2, '0')}" + end + end + end +end diff --git a/app/modules/team_goals/controllers/team_goals_controller.rb b/app/modules/team_goals/controllers/team_goals_controller.rb new file mode 100644 index 0000000..6a9867a --- /dev/null +++ b/app/modules/team_goals/controllers/team_goals_controller.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Api + module V1 + class TeamGoalsController < Api::V1::BaseController + before_action :set_team_goal, only: %i[show update destroy] + + def index + goals = organization_scoped(TeamGoal).includes(:player, :assigned_to, :created_by) + + goals = goals.by_status(params[:status]) if params[:status].present? + goals = goals.by_category(params[:category]) if params[:category].present? + goals = goals.for_player(params[:player_id]) if params[:player_id].present? + + goals = goals.team_goals if params[:type] == 'team' + goals = goals.player_goals if params[:type] == 'player' + goals = goals.active if params[:active] == 'true' + goals = goals.overdue if params[:overdue] == 'true' + goals = goals.expiring_soon(params[:expiring_days]&.to_i || 7) if params[:expiring_soon] == 'true' + + goals = goals.where(assigned_to_id: params[:assigned_to_id]) if params[:assigned_to_id].present? + + # Whitelist for sort parameters to prevent SQL injection + allowed_sort_fields = %w[created_at updated_at title status category start_date end_date progress] + allowed_sort_orders = %w[asc desc] + + sort_by = allowed_sort_fields.include?(params[:sort_by]) ? params[:sort_by] : 'created_at' + sort_order = allowed_sort_orders.include?(params[:sort_order]&.downcase) ? params[:sort_order].downcase : 'desc' + goals = goals.order(sort_by => sort_order) + + result = paginate(goals) + + render_success({ + goals: TeamGoalSerializer.render_as_hash(result[:data]), + pagination: result[:pagination], + summary: calculate_goals_summary(goals) + }) + end + + def show + render_success({ + goal: TeamGoalSerializer.render_as_hash(@goal) + }) + end + + def create + goal = organization_scoped(TeamGoal).new(team_goal_params) + goal.organization = current_organization + goal.created_by = current_user + + if goal.save + log_user_action( + action: 'create', + entity_type: 'TeamGoal', + entity_id: goal.id, + new_values: goal.attributes + ) + + render_created({ + goal: TeamGoalSerializer.render_as_hash(goal) + }, message: 'Goal created successfully') + else + render_error( + message: 'Failed to create goal', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: goal.errors.as_json + ) + end + end + + def update + old_values = @goal.attributes.dup + + if @goal.update(team_goal_params) + log_user_action( + action: 'update', + entity_type: 'TeamGoal', + entity_id: @goal.id, + old_values: old_values, + new_values: @goal.attributes + ) + + render_updated({ + goal: TeamGoalSerializer.render_as_hash(@goal) + }) + else + render_error( + message: 'Failed to update goal', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @goal.errors.as_json + ) + end + end + + def destroy + if @goal.destroy + log_user_action( + action: 'delete', + entity_type: 'TeamGoal', + entity_id: @goal.id, + old_values: @goal.attributes + ) + + render_deleted(message: 'Goal deleted successfully') + else + render_error( + message: 'Failed to delete goal', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_team_goal + @goal = organization_scoped(TeamGoal).find(params[:id]) + end + + def team_goal_params + params.require(:team_goal).permit( + :title, :description, :category, :metric_type, + :target_value, :current_value, :start_date, :end_date, + :status, :progress, :notes, + :player_id, :assigned_to_id + ) + end + + def calculate_goals_summary(goals) + { + total: goals.count, + by_status: goals.group(:status).count, + by_category: goals.group(:category).count, + active_count: goals.active.count, + completed_count: goals.where(status: 'completed').count, + overdue_count: goals.overdue.count, + avg_progress: goals.active.average(:progress)&.round(1) || 0 + } + end + end + end +end diff --git a/app/modules/vod_reviews/controllers/vod_reviews_controller.rb b/app/modules/vod_reviews/controllers/vod_reviews_controller.rb new file mode 100644 index 0000000..9eb6826 --- /dev/null +++ b/app/modules/vod_reviews/controllers/vod_reviews_controller.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module VodReviews + module Controllers + class VodReviewsController < Api::V1::BaseController + before_action :set_vod_review, only: %i[show update destroy] + + def index + authorize VodReview + vod_reviews = organization_scoped(VodReview).includes(:match, :reviewer) + + vod_reviews = vod_reviews.where(status: params[:status]) if params[:status].present? + + vod_reviews = vod_reviews.where(match_id: params[:match_id]) if params[:match_id].present? + + vod_reviews = vod_reviews.where(reviewer_id: params[:reviewer_id]) if params[:reviewer_id].present? + + if params[:search].present? + search_term = "%#{params[:search]}%" + vod_reviews = vod_reviews.where('title ILIKE ?', search_term) + end + + # Whitelist for sort parameters to prevent SQL injection + allowed_sort_fields = %w[created_at updated_at title status reviewed_at] + allowed_sort_orders = %w[asc desc] + + sort_by = allowed_sort_fields.include?(params[:sort_by]) ? params[:sort_by] : 'created_at' + sort_order = allowed_sort_orders.include?(params[:sort_order]&.downcase) ? params[:sort_order].downcase : 'desc' + vod_reviews = vod_reviews.order(sort_by => sort_order) + + result = paginate(vod_reviews) + + render_success({ + vod_reviews: VodReviewSerializer.render_as_hash(result[:data], include_timestamps_count: true), + pagination: result[:pagination] + }) + end + + def show + authorize @vod_review + vod_review_data = VodReviewSerializer.render_as_hash(@vod_review) + timestamps = VodTimestampSerializer.render_as_hash( + @vod_review.vod_timestamps.includes(:target_player, :created_by).order(:timestamp_seconds) + ) + + render_success({ + vod_review: vod_review_data, + timestamps: timestamps + }) + end + + def create + authorize VodReview + vod_review = organization_scoped(VodReview).new(vod_review_params) + vod_review.organization = current_organization + vod_review.reviewer = current_user + + if vod_review.save + log_user_action( + action: 'create', + entity_type: 'VodReview', + entity_id: vod_review.id, + new_values: vod_review.attributes + ) + + render_created({ + vod_review: VodReviewSerializer.render_as_hash(vod_review) + }, message: 'VOD review created successfully') + else + render_error( + message: 'Failed to create VOD review', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: vod_review.errors.as_json + ) + end + end + + def update + authorize @vod_review + old_values = @vod_review.attributes.dup + + if @vod_review.update(vod_review_params) + log_user_action( + action: 'update', + entity_type: 'VodReview', + entity_id: @vod_review.id, + old_values: old_values, + new_values: @vod_review.attributes + ) + + render_updated({ + vod_review: VodReviewSerializer.render_as_hash(@vod_review) + }) + else + render_error( + message: 'Failed to update VOD review', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @vod_review.errors.as_json + ) + end + end + + def destroy + authorize @vod_review + if @vod_review.destroy + log_user_action( + action: 'delete', + entity_type: 'VodReview', + entity_id: @vod_review.id, + old_values: @vod_review.attributes + ) + + render_deleted(message: 'VOD review deleted successfully') + else + render_error( + message: 'Failed to delete VOD review', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_vod_review + @vod_review = organization_scoped(VodReview).find(params[:id]) + end + + def vod_review_params + params.require(:vod_review).permit( + :title, :description, :review_type, :review_date, + :video_url, :thumbnail_url, :duration, + :status, :is_public, :match_id, + tags: [], shared_with_players: [] + ) + end + end + end +end diff --git a/app/modules/vod_reviews/controllers/vod_timestamps_controller.rb b/app/modules/vod_reviews/controllers/vod_timestamps_controller.rb new file mode 100644 index 0000000..b23e695 --- /dev/null +++ b/app/modules/vod_reviews/controllers/vod_timestamps_controller.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Api + module V1 + class VodTimestampsController < Api::V1::BaseController + before_action :set_vod_review, only: %i[index create] + before_action :set_vod_timestamp, only: %i[update destroy] + + def index + authorize @vod_review, :show? + timestamps = @vod_review.vod_timestamps + .includes(:target_player, :created_by) + .order(:timestamp_seconds) + + timestamps = timestamps.where(category: params[:category]) if params[:category].present? + timestamps = timestamps.where(importance: params[:importance]) if params[:importance].present? + timestamps = timestamps.where(target_player_id: params[:player_id]) if params[:player_id].present? + + render_success({ + timestamps: VodTimestampSerializer.render_as_hash(timestamps) + }) + end + + def create + authorize @vod_review, :update? + timestamp = @vod_review.vod_timestamps.new(vod_timestamp_params) + timestamp.created_by = current_user + + if timestamp.save + log_user_action( + action: 'create', + entity_type: 'VodTimestamp', + entity_id: timestamp.id, + new_values: timestamp.attributes + ) + + render_created({ + timestamp: VodTimestampSerializer.render_as_hash(timestamp) + }, message: 'Timestamp added successfully') + else + render_error( + message: 'Failed to create timestamp', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: timestamp.errors.as_json + ) + end + end + + def update + authorize @timestamp.vod_review, :update? + old_values = @timestamp.attributes.dup + + if @timestamp.update(vod_timestamp_params) + log_user_action( + action: 'update', + entity_type: 'VodTimestamp', + entity_id: @timestamp.id, + old_values: old_values, + new_values: @timestamp.attributes + ) + + render_updated({ + timestamp: VodTimestampSerializer.render_as_hash(@timestamp) + }) + else + render_error( + message: 'Failed to update timestamp', + code: 'VALIDATION_ERROR', + status: :unprocessable_entity, + details: @timestamp.errors.as_json + ) + end + end + + def destroy + authorize @timestamp.vod_review, :update? + if @timestamp.destroy + log_user_action( + action: 'delete', + entity_type: 'VodTimestamp', + entity_id: @timestamp.id, + old_values: @timestamp.attributes + ) + + render_deleted(message: 'Timestamp deleted successfully') + else + render_error( + message: 'Failed to delete timestamp', + code: 'DELETE_ERROR', + status: :unprocessable_entity + ) + end + end + + private + + def set_vod_review + @vod_review = organization_scoped(VodReview).find(params[:vod_review_id]) + end + + def set_vod_timestamp + # Scope to organization through vod_review to prevent cross-org access + @timestamp = VodTimestamp + .joins(:vod_review) + .where(vod_reviews: { organization_id: current_organization.id }) + .find_by!(id: params[:id]) + rescue ActiveRecord::RecordNotFound + render_error( + message: 'Timestamp not found', + code: 'NOT_FOUND', + status: :not_found + ) + end + + def vod_timestamp_params + params.require(:vod_timestamp).permit( + :timestamp_seconds, :category, :importance, + :title, :description, :target_type, :target_player_id + ) + end + end + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 10dc909..3b7ba7d 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -1,73 +1,75 @@ -class ApplicationPolicy - attr_reader :user, :record - - def initialize(user, record) - @user = user - @record = record - end - - def index? - false - end - - def show? - false - end - - def create? - false - end - - def new? - create? - end - - def update? - false - end - - def edit? - update? - end - - def destroy? - false - end - - class Scope - def initialize(user, scope) - @user = user - @scope = scope - end - - def resolve - raise NotImplementedError, "You must define #resolve in #{self.class}" - end - - private - - attr_reader :user, :scope - end - - private - - def owner? - user.role == 'owner' - end - - def admin? - user.role == 'admin' || user.role == 'owner' - end - - def coach? - %w[coach admin owner].include?(user.role) - end - - def analyst? - %w[analyst coach admin owner].include?(user.role) - end - - def same_organization? - record.respond_to?(:organization_id) && record.organization_id == user.organization_id - end -end +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NotImplementedError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end + + private + + def owner? + user.role == 'owner' + end + + def admin? + %w[admin owner].include?(user.role) + end + + def coach? + %w[coach admin owner].include?(user.role) + end + + def analyst? + %w[analyst coach admin owner].include?(user.role) + end + + def same_organization? + record.respond_to?(:organization_id) && record.organization_id == user.organization_id + end +end diff --git a/app/policies/match_policy.rb b/app/policies/match_policy.rb index 2032cac..d0e924d 100644 --- a/app/policies/match_policy.rb +++ b/app/policies/match_policy.rb @@ -1,35 +1,37 @@ -class MatchPolicy < ApplicationPolicy - def index? - true - end - - def show? - same_organization? - end - - def create? - coach? - end - - def update? - coach? && same_organization? - end - - def destroy? - admin? && same_organization? - end - - def stats? - same_organization? - end - - def import? - coach? && same_organization? - end - - class Scope < Scope - def resolve - scope.where(organization: user.organization) - end - end -end +# frozen_string_literal: true + +class MatchPolicy < ApplicationPolicy + def index? + true + end + + def show? + same_organization? + end + + def create? + coach? + end + + def update? + coach? && same_organization? + end + + def destroy? + admin? && same_organization? + end + + def stats? + same_organization? + end + + def import? + coach? && same_organization? + end + + class Scope < Scope + def resolve + scope.where(organization: user.organization) + end + end +end diff --git a/app/policies/player_policy.rb b/app/policies/player_policy.rb index a578aa4..ef99e8e 100644 --- a/app/policies/player_policy.rb +++ b/app/policies/player_policy.rb @@ -1,39 +1,41 @@ -class PlayerPolicy < ApplicationPolicy - def index? - true # All authenticated users can view players - end - - def show? - same_organization? - end - - def create? - admin? - end - - def update? - admin? && same_organization? - end - - def destroy? - owner? && same_organization? - end - - def stats? - same_organization? - end - - def matches? - same_organization? - end - - def import? - admin? && same_organization? - end - - class Scope < Scope - def resolve - scope.where(organization: user.organization) - end - end -end +# frozen_string_literal: true + +class PlayerPolicy < ApplicationPolicy + def index? + true # All authenticated users can view players + end + + def show? + same_organization? + end + + def create? + admin? + end + + def update? + admin? && same_organization? + end + + def destroy? + owner? && same_organization? + end + + def stats? + same_organization? + end + + def matches? + same_organization? + end + + def import? + admin? && same_organization? + end + + class Scope < Scope + def resolve + scope.where(organization: user.organization) + end + end +end diff --git a/app/policies/pro_match_policy.rb b/app/policies/pro_match_policy.rb new file mode 100644 index 0000000..411cf15 --- /dev/null +++ b/app/policies/pro_match_policy.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ProMatchPolicy < ApplicationPolicy + def index? + true # All authenticated users can view pro matches + end + + def show? + true + end + + def upcoming? + true + end + + def past? + true + end + + def refresh? + # Only organization owners can refresh cache + user.owner? + end + + def import? + # Only coaches and owners can import matches + user.owner? || user.coach? + end + + class Scope < Scope + def resolve + scope.all + end + end +end diff --git a/app/policies/riot_data_policy.rb b/app/policies/riot_data_policy.rb new file mode 100644 index 0000000..8f93dfe --- /dev/null +++ b/app/policies/riot_data_policy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class RiotDataPolicy < ApplicationPolicy + def manage? + user.admin_or_owner? + end + + class Scope < Scope + def resolve + scope.all + end + end +end diff --git a/app/policies/schedule_policy.rb b/app/policies/schedule_policy.rb index 2113245..f172d26 100644 --- a/app/policies/schedule_policy.rb +++ b/app/policies/schedule_policy.rb @@ -1,27 +1,29 @@ -class SchedulePolicy < ApplicationPolicy - def index? - true - end - - def show? - same_organization? - end - - def create? - coach? - end - - def update? - coach? && same_organization? - end - - def destroy? - admin? && same_organization? - end - - class Scope < Scope - def resolve - scope.where(organization: user.organization) - end - end -end +# frozen_string_literal: true + +class SchedulePolicy < ApplicationPolicy + def index? + true + end + + def show? + same_organization? + end + + def create? + coach? + end + + def update? + coach? && same_organization? + end + + def destroy? + admin? && same_organization? + end + + class Scope < Scope + def resolve + scope.where(organization: user.organization) + end + end +end diff --git a/app/policies/scouting_target_policy.rb b/app/policies/scouting_target_policy.rb index 926b39b..4e930d6 100644 --- a/app/policies/scouting_target_policy.rb +++ b/app/policies/scouting_target_policy.rb @@ -1,35 +1,37 @@ -class ScoutingTargetPolicy < ApplicationPolicy - def index? - coach? - end - - def show? - coach? && same_organization? - end - - def create? - coach? - end - - def update? - coach? && same_organization? - end - - def destroy? - admin? && same_organization? - end - - def sync? - coach? && same_organization? - end - - class Scope < Scope - def resolve - if %w[coach admin owner].include?(user.role) - scope.where(organization: user.organization) - else - scope.none - end - end - end -end +# frozen_string_literal: true + +class ScoutingTargetPolicy < ApplicationPolicy + def index? + coach? + end + + def show? + coach? && same_organization? + end + + def create? + coach? + end + + def update? + coach? && same_organization? + end + + def destroy? + admin? && same_organization? + end + + def sync? + coach? && same_organization? + end + + class Scope < Scope + def resolve + if %w[coach admin owner].include?(user.role) + scope.where(organization: user.organization) + else + scope.none + end + end + end +end diff --git a/app/policies/team_goal_policy.rb b/app/policies/team_goal_policy.rb index 71c0201..b35785d 100644 --- a/app/policies/team_goal_policy.rb +++ b/app/policies/team_goal_policy.rb @@ -1,42 +1,44 @@ -class TeamGoalPolicy < ApplicationPolicy - def index? - true - end - - def show? - same_organization? - end - - def create? - coach? - end - - def update? - can_update? - end - - def destroy? - admin? && same_organization? - end - - private - - def can_update? - return false unless same_organization? - - # Owners/admins can update any goal - return true if admin? - - # Coaches can update goals - return true if coach? - - # Users can update goals assigned to them - record.assigned_to_id == user.id - end - - class Scope < Scope - def resolve - scope.where(organization: user.organization) - end - end -end +# frozen_string_literal: true + +class TeamGoalPolicy < ApplicationPolicy + def index? + true + end + + def show? + same_organization? + end + + def create? + coach? + end + + def update? + can_update? + end + + def destroy? + admin? && same_organization? + end + + private + + def can_update? + return false unless same_organization? + + # Owners/admins can update any goal + return true if admin? + + # Coaches can update goals + return true if coach? + + # Users can update goals assigned to them + record.assigned_to_id == user.id + end + + class Scope < Scope + def resolve + scope.where(organization: user.organization) + end + end +end diff --git a/app/policies/vod_review_policy.rb b/app/policies/vod_review_policy.rb index 0cc0a8d..068bcef 100644 --- a/app/policies/vod_review_policy.rb +++ b/app/policies/vod_review_policy.rb @@ -1,31 +1,33 @@ -class VodReviewPolicy < ApplicationPolicy - def index? - analyst? - end - - def show? - analyst? && same_organization? - end - - def create? - analyst? - end - - def update? - analyst? && same_organization? - end - - def destroy? - admin? && same_organization? - end - - class Scope < Scope - def resolve - if %w[analyst coach admin owner].include?(user.role) - scope.where(organization: user.organization) - else - scope.none - end - end - end -end +# frozen_string_literal: true + +class VodReviewPolicy < ApplicationPolicy + def index? + analyst? + end + + def show? + analyst? && same_organization? + end + + def create? + analyst? + end + + def update? + analyst? && same_organization? + end + + def destroy? + admin? && same_organization? + end + + class Scope < Scope + def resolve + if %w[analyst coach admin owner].include?(user.role) + scope.where(organization: user.organization) + else + scope.none + end + end + end +end diff --git a/app/policies/vod_timestamp_policy.rb b/app/policies/vod_timestamp_policy.rb index 9990913..5e3378f 100644 --- a/app/policies/vod_timestamp_policy.rb +++ b/app/policies/vod_timestamp_policy.rb @@ -1,25 +1,27 @@ -class VodTimestampPolicy < ApplicationPolicy - def index? - analyst? - end - - def create? - analyst? - end - - def update? - analyst? && can_access_vod_review? - end - - def destroy? - analyst? && can_access_vod_review? - end - - private - - def can_access_vod_review? - return false unless record.vod_review - - record.vod_review.organization_id == user.organization_id - end -end +# frozen_string_literal: true + +class VodTimestampPolicy < ApplicationPolicy + def index? + analyst? + end + + def create? + analyst? + end + + def update? + analyst? && can_access_vod_review? + end + + def destroy? + analyst? && can_access_vod_review? + end + + private + + def can_access_vod_review? + return false unless record.vod_review + + record.vod_review.organization_id == user.organization_id + end +end diff --git a/app/serializers/champion_pool_serializer.rb b/app/serializers/champion_pool_serializer.rb index 87f27be..334ab97 100644 --- a/app/serializers/champion_pool_serializer.rb +++ b/app/serializers/champion_pool_serializer.rb @@ -1,14 +1,17 @@ -class ChampionPoolSerializer < Blueprinter::Base - identifier :id - - fields :champion, :games_played, :wins, :losses, - :average_kda, :average_cs, :mastery_level, :mastery_points, - :last_played_at, :created_at, :updated_at - - field :win_rate do |pool| - return 0 if pool.games_played.to_i.zero? - ((pool.wins.to_f / pool.games_played) * 100).round(1) - end - - association :player, blueprint: PlayerSerializer -end +# frozen_string_literal: true + +class ChampionPoolSerializer < Blueprinter::Base + identifier :id + + fields :champion, :games_played, :wins, :losses, + :average_kda, :average_cs, :mastery_level, :mastery_points, + :last_played_at, :created_at, :updated_at + + field :win_rate do |pool| + return 0 if pool.games_played.to_i.zero? + + ((pool.wins.to_f / pool.games_played) * 100).round(1) + end + + association :player, blueprint: PlayerSerializer +end diff --git a/app/serializers/match_serializer.rb b/app/serializers/match_serializer.rb index f4c3a08..5aa5f3e 100644 --- a/app/serializers/match_serializer.rb +++ b/app/serializers/match_serializer.rb @@ -1,38 +1,40 @@ -class MatchSerializer < Blueprinter::Base - identifier :id - - fields :match_type, :game_start, :game_end, :game_duration, - :riot_match_id, :game_version, - :opponent_name, :opponent_tag, :victory, - :our_side, :our_score, :opponent_score, - :our_towers, :opponent_towers, :our_dragons, :opponent_dragons, - :our_barons, :opponent_barons, :our_inhibitors, :opponent_inhibitors, - :vod_url, :replay_file_url, :notes, - :created_at, :updated_at - - field :result do |match| - match.result_text - end - - field :duration_formatted do |match| - match.duration_formatted - end - - field :score_display do |match| - match.score_display - end - - field :kda_summary do |match| - match.kda_summary - end - - field :has_replay do |match| - match.has_replay? - end - - field :has_vod do |match| - match.has_vod? - end - - association :organization, blueprint: OrganizationSerializer -end +# frozen_string_literal: true + +class MatchSerializer < Blueprinter::Base + identifier :id + + fields :match_type, :game_start, :game_end, :game_duration, + :riot_match_id, :game_version, + :opponent_name, :opponent_tag, :victory, + :our_side, :our_score, :opponent_score, + :our_towers, :opponent_towers, :our_dragons, :opponent_dragons, + :our_barons, :opponent_barons, :our_inhibitors, :opponent_inhibitors, + :vod_url, :replay_file_url, :notes, + :created_at, :updated_at + + field :result do |obj| + obj.result_text + end + + field :duration_formatted do |obj| + obj.duration_formatted + end + + field :score_display do |obj| + obj.score_display + end + + field :kda_summary do |obj| + obj.kda_summary + end + + field :has_replay do |obj| + obj.has_replay? + end + + field :has_vod do |obj| + obj.has_vod? + end + + association :organization, blueprint: OrganizationSerializer +end diff --git a/app/serializers/organization_serializer.rb b/app/serializers/organization_serializer.rb index 1bcf09d..e7a07a8 100644 --- a/app/serializers/organization_serializer.rb +++ b/app/serializers/organization_serializer.rb @@ -1,53 +1,77 @@ -class OrganizationSerializer < Blueprinter::Base - - identifier :id - - fields :name, :slug, :region, :tier, :subscription_plan, :subscription_status, - :logo_url, :settings, :created_at, :updated_at - - field :region_display do |org| - region_names = { - 'BR' => 'Brazil', - 'NA' => 'North America', - 'EUW' => 'Europe West', - 'EUNE' => 'Europe Nordic & East', - 'KR' => 'Korea', - 'LAN' => 'Latin America North', - 'LAS' => 'Latin America South', - 'OCE' => 'Oceania', - 'RU' => 'Russia', - 'TR' => 'Turkey', - 'JP' => 'Japan' - } - - region_names[org.region] || org.region - end - - field :tier_display do |org| - if org.tier.blank? - 'Not set' - else - org.tier.humanize - end - end - - field :subscription_display do |org| - if org.subscription_plan.blank? - 'Free' - else - plan = org.subscription_plan.humanize - status = org.subscription_status&.humanize || 'Active' - "#{plan} (#{status})" - end - end - - field :statistics do |org| - { - total_players: org.players.count, - active_players: org.players.active.count, - total_matches: org.matches.count, - recent_matches: org.matches.recent(30).count, - total_users: org.users.count - } - end -end \ No newline at end of file +# frozen_string_literal: true + +class OrganizationSerializer < Blueprinter::Base + identifier :id + + fields :name, :slug, :region, :tier, :subscription_plan, :subscription_status, + :logo_url, :settings, :created_at, :updated_at + + field :region_display do |org| + region_names = { + 'BR' => 'Brazil', + 'NA' => 'North America', + 'EUW' => 'Europe West', + 'EUNE' => 'Europe Nordic & East', + 'KR' => 'Korea', + 'LAN' => 'Latin America North', + 'LAS' => 'Latin America South', + 'OCE' => 'Oceania', + 'RU' => 'Russia', + 'TR' => 'Turkey', + 'JP' => 'Japan' + } + + region_names[org.region] || org.region + end + + field :tier_display do |org| + if org.tier.blank? + 'Not set' + else + org.tier.humanize + end + end + + field :subscription_display do |org| + if org.subscription_plan.blank? + 'Free' + else + plan = org.subscription_plan.humanize + status = org.subscription_status&.humanize || 'Active' + "#{plan} (#{status})" + end + end + + field :statistics do |org| + { + total_players: org.players.count, + active_players: org.players.active.count, + total_matches: org.matches.count, + recent_matches: org.matches.recent(30).count, + total_users: org.users.count + } + end + + # Tier features and capabilities + field :features do |org| + { + can_access_scrims: org.can_access_scrims?, + can_access_competitive_data: org.can_access_competitive_data?, + can_access_predictive_analytics: org.can_access_predictive_analytics?, + available_features: org.available_features, + available_data_sources: org.available_data_sources, + available_analytics: org.available_analytics + } + end + + field :limits do |org| + { + max_players: org.tier_limits[:max_players], + max_matches_per_month: org.tier_limits[:max_matches_per_month], + current_players: org.tier_limits[:current_players], + current_monthly_matches: org.tier_limits[:current_monthly_matches], + players_remaining: org.tier_limits[:players_remaining], + matches_remaining: org.tier_limits[:matches_remaining] + } + end +end diff --git a/app/serializers/player_match_stat_serializer.rb b/app/serializers/player_match_stat_serializer.rb index d9a0d38..41ee306 100644 --- a/app/serializers/player_match_stat_serializer.rb +++ b/app/serializers/player_match_stat_serializer.rb @@ -1,23 +1,25 @@ -class PlayerMatchStatSerializer < Blueprinter::Base - identifier :id - - fields :role, :champion, :kills, :deaths, :assists, - :gold_earned, :total_damage_dealt, :total_damage_taken, - :minions_killed, :jungle_minions_killed, - :vision_score, :wards_placed, :wards_killed, - :champion_level, :first_blood_kill, :double_kills, - :triple_kills, :quadra_kills, :penta_kills, - :performance_score, :created_at, :updated_at - - field :kda do |stat| - deaths = stat.deaths.zero? ? 1 : stat.deaths - ((stat.kills + stat.assists).to_f / deaths).round(2) - end - - field :cs_total do |stat| - (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) - end - - association :player, blueprint: PlayerSerializer - association :match, blueprint: MatchSerializer -end +# frozen_string_literal: true + +class PlayerMatchStatSerializer < Blueprinter::Base + identifier :id + + fields :role, :champion, :kills, :deaths, :assists, + :gold_earned, :total_damage_dealt, :total_damage_taken, + :minions_killed, :jungle_minions_killed, + :vision_score, :wards_placed, :wards_killed, + :champion_level, :first_blood_kill, :double_kills, + :triple_kills, :quadra_kills, :penta_kills, + :performance_score, :created_at, :updated_at + + field :kda do |stat| + deaths = stat.deaths.zero? ? 1 : stat.deaths + ((stat.kills + stat.assists).to_f / deaths).round(2) + end + + field :cs_total do |stat| + (stat.minions_killed || 0) + (stat.jungle_minions_killed || 0) + end + + association :player, blueprint: PlayerSerializer + association :match, blueprint: MatchSerializer +end diff --git a/app/serializers/player_serializer.rb b/app/serializers/player_serializer.rb index 43a1081..be0f5a5 100644 --- a/app/serializers/player_serializer.rb +++ b/app/serializers/player_serializer.rb @@ -1,48 +1,57 @@ -class PlayerSerializer < Blueprinter::Base - identifier :id - - fields :summoner_name, :real_name, :role, :status, - :jersey_number, :birth_date, :country, - :contract_start_date, :contract_end_date, - :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, - :solo_queue_wins, :solo_queue_losses, - :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, - :peak_tier, :peak_rank, :peak_season, - :riot_puuid, :riot_summoner_id, - :twitter_handle, :twitch_channel, :instagram_handle, - :notes, :sync_status, :last_sync_at, :created_at, :updated_at - - field :age do |player| - player.age - end - - field :win_rate do |player| - player.win_rate - end - - field :current_rank do |player| - player.current_rank_display - end - - field :peak_rank do |player| - player.peak_rank_display - end - - field :contract_status do |player| - player.contract_status - end - - field :main_champions do |player| - player.main_champions - end - - field :social_links do |player| - player.social_links - end - - field :needs_sync do |player| - player.needs_sync? - end - - association :organization, blueprint: OrganizationSerializer -end +# frozen_string_literal: true + +class PlayerSerializer < Blueprinter::Base + identifier :id + + fields :summoner_name, :real_name, :role, :status, + :jersey_number, :birth_date, :country, + :contract_start_date, :contract_end_date, + :solo_queue_tier, :solo_queue_rank, :solo_queue_lp, + :solo_queue_wins, :solo_queue_losses, + :flex_queue_tier, :flex_queue_rank, :flex_queue_lp, + :peak_tier, :peak_rank, :peak_season, + :riot_puuid, :riot_summoner_id, :profile_icon_id, + :twitter_handle, :twitch_channel, :instagram_handle, + :notes, :sync_status, :last_sync_at, :created_at, :updated_at + + field :age do |obj| + obj.age + end + + field :avatar_url do |player| + if player.profile_icon_id.present? + # Use latest patch version from Data Dragon + "https://ddragon.leagueoflegends.com/cdn/14.1.1/img/profileicon/#{player.profile_icon_id}.png" + end + end + + field :win_rate do |obj| + obj.win_rate + end + + field :current_rank do |obj| + obj.current_rank_display + end + + field :peak_rank do |obj| + obj.peak_rank_display + end + + field :contract_status do |obj| + obj.contract_status + end + + field :main_champions do |obj| + obj.main_champions + end + + field :social_links do |obj| + obj.social_links + end + + field :needs_sync do |obj| + obj.needs_sync? + end + + association :organization, blueprint: OrganizationSerializer +end diff --git a/app/serializers/schedule_serializer.rb b/app/serializers/schedule_serializer.rb index 3f932b1..60df113 100644 --- a/app/serializers/schedule_serializer.rb +++ b/app/serializers/schedule_serializer.rb @@ -1,19 +1,22 @@ -class ScheduleSerializer < Blueprinter::Base - identifier :id - - fields :event_type, :title, :description, :start_time, :end_time, - :location, :opponent_name, :status, - :meeting_url, :timezone, :all_day, - :tags, :color, :is_recurring, :recurrence_rule, - :recurrence_end_date, :reminder_minutes, - :required_players, :optional_players, :metadata, - :created_at, :updated_at - - field :duration_hours do |schedule| - return nil unless schedule.start_time && schedule.end_time - ((schedule.end_time - schedule.start_time) / 3600).round(1) - end - - association :organization, blueprint: OrganizationSerializer - association :match, blueprint: MatchSerializer -end +# frozen_string_literal: true + +class ScheduleSerializer < Blueprinter::Base + identifier :id + + fields :event_type, :title, :description, :start_time, :end_time, + :location, :opponent_name, :status, + :meeting_url, :timezone, :all_day, + :tags, :color, :is_recurring, :recurrence_rule, + :recurrence_end_date, :reminder_minutes, + :required_players, :optional_players, :metadata, + :created_at, :updated_at + + field :duration_hours do |schedule| + return nil unless schedule.start_time && schedule.end_time + + ((schedule.end_time - schedule.start_time) / 3600).round(1) + end + + association :organization, blueprint: OrganizationSerializer + association :match, blueprint: MatchSerializer +end diff --git a/app/serializers/scouting_target_serializer.rb b/app/serializers/scouting_target_serializer.rb index 2ce9171..cf4da9d 100644 --- a/app/serializers/scouting_target_serializer.rb +++ b/app/serializers/scouting_target_serializer.rb @@ -1,35 +1,37 @@ -class ScoutingTargetSerializer < Blueprinter::Base - identifier :id - - fields :summoner_name, :role, :region, :status, :priority, :age - - fields :riot_puuid - - fields :current_tier, :current_rank, :current_lp - - fields :champion_pool, :playstyle, :strengths, :weaknesses - - fields :recent_performance, :performance_trend - - fields :email, :phone, :discord_username, :twitter_handle - - fields :notes, :metadata - - fields :last_reviewed, :created_at, :updated_at - - field :priority_text do |target| - target.priority&.titleize || 'Not Set' - end - - field :status_text do |target| - target.status&.titleize || 'Watching' - end - - field :current_rank_display do |target| - target.current_rank_display - end - - association :organization, blueprint: OrganizationSerializer - association :added_by, blueprint: UserSerializer - association :assigned_to, blueprint: UserSerializer -end +# frozen_string_literal: true + +class ScoutingTargetSerializer < Blueprinter::Base + identifier :id + + fields :summoner_name, :role, :region, :status, :priority, :age + + fields :riot_puuid + + fields :current_tier, :current_rank, :current_lp + + fields :champion_pool, :playstyle, :strengths, :weaknesses + + fields :recent_performance, :performance_trend + + fields :email, :phone, :discord_username, :twitter_handle + + fields :notes, :metadata + + fields :last_reviewed, :created_at, :updated_at + + field :priority_text do |target| + target.priority&.titleize || 'Not Set' + end + + field :status_text do |target| + target.status&.titleize || 'Watching' + end + + field :current_rank_display do |obj| + obj.current_rank_display + end + + association :organization, blueprint: OrganizationSerializer + association :added_by, blueprint: UserSerializer + association :assigned_to, blueprint: UserSerializer +end diff --git a/app/serializers/scrim_opponent_team_serializer.rb b/app/serializers/scrim_opponent_team_serializer.rb new file mode 100644 index 0000000..4c253b0 --- /dev/null +++ b/app/serializers/scrim_opponent_team_serializer.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class ScrimOpponentTeamSerializer + def initialize(opponent_team, options = {}) + @opponent_team = opponent_team + @options = options + end + + def as_json + base_attributes.tap do |hash| + hash.merge!(detailed_attributes) if @options[:detailed] + end + end + + private + + def base_attributes + { + id: @opponent_team.id, + name: @opponent_team.name, + tag: @opponent_team.tag, + full_name: @opponent_team.full_name, + region: @opponent_team.region, + tier: @opponent_team.tier, + tier_display: @opponent_team.tier_display, + league: @opponent_team.league, + logo_url: @opponent_team.logo_url, + total_scrims: @opponent_team.total_scrims, + scrim_record: @opponent_team.scrim_record, + scrim_win_rate: @opponent_team.scrim_win_rate, + created_at: @opponent_team.created_at, + updated_at: @opponent_team.updated_at + } + end + + def detailed_attributes + { + known_players: @opponent_team.known_players, + recent_performance: @opponent_team.recent_performance, + playstyle_notes: @opponent_team.playstyle_notes, + strengths: @opponent_team.all_strengths_tags, + weaknesses: @opponent_team.all_weaknesses_tags, + preferred_champions: @opponent_team.preferred_champions, + contact_email: @opponent_team.contact_email, + discord_server: @opponent_team.discord_server, + contact_available: @opponent_team.contact_available? + } + end +end diff --git a/app/serializers/scrim_serializer.rb b/app/serializers/scrim_serializer.rb new file mode 100644 index 0000000..51c9ee7 --- /dev/null +++ b/app/serializers/scrim_serializer.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class ScrimSerializer + def initialize(scrim, options = {}) + @scrim = scrim + @options = options + end + + def as_json + base_attributes.tap do |hash| + hash.merge!(detailed_attributes) if @options[:detailed] + hash.merge!(calendar_attributes) if @options[:calendar_view] + end + end + + private + + def base_attributes + { + id: @scrim.id, + organization_id: @scrim.organization_id, + opponent_team: opponent_team_summary, + scheduled_at: @scrim.scheduled_at, + scrim_type: @scrim.scrim_type, + focus_area: @scrim.focus_area, + games_planned: @scrim.games_planned, + games_completed: @scrim.games_completed, + completion_percentage: @scrim.completion_percentage, + status: @scrim.status, + win_rate: @scrim.win_rate, + is_confidential: @scrim.is_confidential, + visibility: @scrim.visibility, + created_at: @scrim.created_at, + updated_at: @scrim.updated_at + } + end + + def detailed_attributes + { + match_id: @scrim.match_id, + pre_game_notes: @scrim.pre_game_notes, + post_game_notes: @scrim.post_game_notes, + game_results: @scrim.game_results, + objectives: @scrim.objectives, + outcomes: @scrim.outcomes, + objectives_met: @scrim.objectives_met? + } + end + + def calendar_attributes + { + title: calendar_title, + start: @scrim.scheduled_at, + end: @scrim.scheduled_at + (@scrim.games_planned || 3).hours, + color: status_color + } + end + + def opponent_team_summary + return nil unless @scrim.opponent_team + + { + id: @scrim.opponent_team.id, + name: @scrim.opponent_team.name, + tag: @scrim.opponent_team.tag, + tier: @scrim.opponent_team.tier, + logo_url: @scrim.opponent_team.logo_url + } + end + + def calendar_title + opponent = @scrim.opponent_team&.name || 'TBD' + "Scrim vs #{opponent}" + end + + def status_color + case @scrim.status + when 'completed' + '#4CAF50' # Green + when 'in_progress' + '#FF9800' # Orange + when 'upcoming' + '#2196F3' # Blue + else + '#9E9E9E' # Gray + end + end +end diff --git a/app/serializers/team_goal_serializer.rb b/app/serializers/team_goal_serializer.rb index 2635d11..0c4483c 100644 --- a/app/serializers/team_goal_serializer.rb +++ b/app/serializers/team_goal_serializer.rb @@ -1,44 +1,46 @@ -class TeamGoalSerializer < Blueprinter::Base - identifier :id - - fields :title, :description, :category, :metric_type, - :target_value, :current_value, :start_date, :end_date, - :status, :progress, :created_at, :updated_at - - field :is_team_goal do |goal| - goal.is_team_goal? - end - - field :days_remaining do |goal| - goal.days_remaining - end - - field :days_total do |goal| - goal.days_total - end - - field :time_progress_percentage do |goal| - goal.time_progress_percentage - end - - field :is_overdue do |goal| - goal.is_overdue? - end - - field :target_display do |goal| - goal.target_display - end - - field :current_display do |goal| - goal.current_display - end - - field :completion_percentage do |goal| - goal.completion_percentage - end - - association :organization, blueprint: OrganizationSerializer - association :player, blueprint: PlayerSerializer - association :assigned_to, blueprint: UserSerializer - association :created_by, blueprint: UserSerializer -end +# frozen_string_literal: true + +class TeamGoalSerializer < Blueprinter::Base + identifier :id + + fields :title, :description, :category, :metric_type, + :target_value, :current_value, :start_date, :end_date, + :status, :progress, :created_at, :updated_at + + field :is_team_goal do |obj| + obj.is_team_goal? + end + + field :days_remaining do |obj| + obj.days_remaining + end + + field :days_total do |obj| + obj.days_total + end + + field :time_progress_percentage do |obj| + obj.time_progress_percentage + end + + field :is_overdue do |obj| + obj.is_overdue? + end + + field :target_display do |obj| + obj.target_display + end + + field :current_display do |obj| + obj.current_display + end + + field :completion_percentage do |obj| + obj.completion_percentage + end + + association :organization, blueprint: OrganizationSerializer + association :player, blueprint: PlayerSerializer + association :assigned_to, blueprint: UserSerializer + association :created_by, blueprint: UserSerializer +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 478408f..da3b911 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,45 +1,46 @@ -class UserSerializer < Blueprinter::Base - - identifier :id - - fields :email, :full_name, :role, :avatar_url, :timezone, :language, - :notifications_enabled, :notification_preferences, :last_login_at, - :created_at, :updated_at - - field :role_display do |user| - user.full_role_name - end - - field :permissions do |user| - { - can_manage_users: user.can_manage_users?, - can_manage_players: user.can_manage_players?, - can_view_analytics: user.can_view_analytics?, - is_admin_or_owner: user.admin_or_owner? - } - end - - field :last_login_display do |user| - user.last_login_at ? time_ago_in_words(user.last_login_at) : 'Never' - end - - def self.time_ago_in_words(time) - if time.nil? - 'Never' - else - diff = Time.current - time - case diff - when 0...60 - "#{diff.to_i} seconds ago" - when 60...3600 - "#{(diff / 60).to_i} minutes ago" - when 3600...86400 - "#{(diff / 3600).to_i} hours ago" - when 86400...2592000 - "#{(diff / 86400).to_i} days ago" - else - time.strftime('%B %d, %Y') - end - end - end -end \ No newline at end of file +# frozen_string_literal: true + +class UserSerializer < Blueprinter::Base + identifier :id + + fields :email, :full_name, :role, :avatar_url, :timezone, :language, + :notifications_enabled, :notification_preferences, :last_login_at, + :created_at, :updated_at + + field :role_display do |user| + user.full_role_name + end + + field :permissions do |user| + { + can_manage_users: user.can_manage_users?, + can_manage_players: user.can_manage_players?, + can_view_analytics: user.can_view_analytics?, + is_admin_or_owner: user.admin_or_owner? + } + end + + field :last_login_display do |user| + user.last_login_at ? time_ago_in_words(user.last_login_at) : 'Never' + end + + def self.time_ago_in_words(time) + if time.nil? + 'Never' + else + diff = Time.current - time + case diff + when 0...60 + "#{diff.to_i} seconds ago" + when 60...3600 + "#{(diff / 60).to_i} minutes ago" + when 3600...86_400 + "#{(diff / 3600).to_i} hours ago" + when 86_400...2_592_000 + "#{(diff / 86_400).to_i} days ago" + else + time.strftime('%B %d, %Y') + end + end + end +end diff --git a/app/serializers/vod_review_serializer.rb b/app/serializers/vod_review_serializer.rb index 5a3a012..dafd781 100644 --- a/app/serializers/vod_review_serializer.rb +++ b/app/serializers/vod_review_serializer.rb @@ -1,14 +1,19 @@ -class VodReviewSerializer < Blueprinter::Base - identifier :id - - fields :title, :vod_url, :vod_platform, :game_start_timestamp, - :status, :notes, :created_at, :updated_at - - field :timestamps_count do |vod_review, options| - options[:include_timestamps_count] ? vod_review.vod_timestamps.count : nil - end - - association :organization, blueprint: OrganizationSerializer - association :match, blueprint: MatchSerializer - association :reviewed_by, blueprint: UserSerializer -end +# frozen_string_literal: true + +class VodReviewSerializer < Blueprinter::Base + identifier :id + + fields :title, :description, :review_type, :review_date, + :video_url, :thumbnail_url, :duration, + :is_public, :share_link, :shared_with_players, + :status, :tags, :metadata, + :created_at, :updated_at + + field :timestamps_count do |vod_review, options| + options[:include_timestamps_count] ? vod_review.vod_timestamps.count : nil + end + + association :organization, blueprint: OrganizationSerializer + association :match, blueprint: MatchSerializer + association :reviewer, blueprint: UserSerializer +end diff --git a/app/serializers/vod_timestamp_serializer.rb b/app/serializers/vod_timestamp_serializer.rb index 388fe6b..57391ff 100644 --- a/app/serializers/vod_timestamp_serializer.rb +++ b/app/serializers/vod_timestamp_serializer.rb @@ -1,23 +1,25 @@ -class VodTimestampSerializer < Blueprinter::Base - identifier :id - - fields :timestamp_seconds, :category, :importance, :title, - :description, :created_at, :updated_at - - field :formatted_timestamp do |timestamp| - seconds = timestamp.timestamp_seconds - hours = seconds / 3600 - minutes = (seconds % 3600) / 60 - secs = seconds % 60 - - if hours > 0 - format('%02d:%02d:%02d', hours, minutes, secs) - else - format('%02d:%02d', minutes, secs) - end - end - - association :vod_review, blueprint: VodReviewSerializer - association :target_player, blueprint: PlayerSerializer - association :created_by, blueprint: UserSerializer -end +# frozen_string_literal: true + +class VodTimestampSerializer < Blueprinter::Base + identifier :id + + fields :timestamp_seconds, :category, :importance, :title, + :description, :created_at, :updated_at + + field :formatted_timestamp do |timestamp| + seconds = timestamp.timestamp_seconds + hours = seconds / 3600 + minutes = (seconds % 3600) / 60 + secs = seconds % 60 + + if hours.positive? + format('%02d:%02d:%02d', hours, minutes, secs) + else + format('%02d:%02d', minutes, secs) + end + end + + association :vod_review, blueprint: VodReviewSerializer + association :target_player, blueprint: PlayerSerializer + association :created_by, blueprint: UserSerializer +end diff --git a/app/services/data_dragon_service.rb b/app/services/data_dragon_service.rb new file mode 100644 index 0000000..70f4546 --- /dev/null +++ b/app/services/data_dragon_service.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +class DataDragonService + BASE_URL = 'https://ddragon.leagueoflegends.com' + + class DataDragonError < StandardError; end + + def initialize + @latest_version = nil + end + + # Get the latest game version + def latest_version + @latest_version ||= fetch_latest_version + end + + # Get champion ID to name mapping + def champion_id_map + Rails.cache.fetch('riot:champion_id_map', expires_in: 1.week) do + fetch_champion_data + end + end + + # Get champion name to ID mapping (reverse) + def champion_name_map + Rails.cache.fetch('riot:champion_name_map', expires_in: 1.week) do + champion_id_map.invert + end + end + + # Get all champions data (full details) + def all_champions + Rails.cache.fetch('riot:all_champions', expires_in: 1.week) do + fetch_all_champions_data + end + end + + # Get specific champion data by key + def champion_by_key(champion_key) + all_champions[champion_key] + end + + # Get profile icons data + def profile_icons + Rails.cache.fetch('riot:profile_icons', expires_in: 1.week) do + fetch_profile_icons + end + end + + # Get summoner spells data + def summoner_spells + Rails.cache.fetch('riot:summoner_spells', expires_in: 1.week) do + fetch_summoner_spells + end + end + + # Get items data + def items + Rails.cache.fetch('riot:items', expires_in: 1.week) do + fetch_items + end + end + + # Clear all cached data + def clear_cache! + Rails.cache.delete('riot:champion_id_map') + Rails.cache.delete('riot:champion_name_map') + Rails.cache.delete('riot:all_champions') + Rails.cache.delete('riot:profile_icons') + Rails.cache.delete('riot:summoner_spells') + Rails.cache.delete('riot:items') + Rails.cache.delete('riot:latest_version') + @latest_version = nil + end + + private + + def fetch_latest_version + cached_version = Rails.cache.read('riot:latest_version') + return cached_version if cached_version.present? + + url = "#{BASE_URL}/api/versions.json" + response = make_request(url) + versions = JSON.parse(response.body) + + latest = versions.first + Rails.cache.write('riot:latest_version', latest, expires_in: 1.day) + latest + rescue StandardError => e + Rails.logger.error("Failed to fetch latest version: #{e.message}") + # Fallback to a recent known version + '14.1.1' + end + + def fetch_champion_data + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/champion.json" + + response = make_request(url) + data = JSON.parse(response.body) + + # Create mapping: champion_id (integer) => champion_name (string) + 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 + end + + champion_map + rescue StandardError => e + Rails.logger.error("Failed to fetch champion data: #{e.message}") + {} + end + + def fetch_all_champions_data + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/champion.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch all champions data: #{e.message}") + {} + end + + def fetch_profile_icons + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/profileicon.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch profile icons: #{e.message}") + {} + end + + def fetch_summoner_spells + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/summoner.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch summoner spells: #{e.message}") + {} + end + + def fetch_items + version = latest_version + url = "#{BASE_URL}/cdn/#{version}/data/en_US/item.json" + + response = make_request(url) + data = JSON.parse(response.body) + + data['data'] + rescue StandardError => e + Rails.logger.error("Failed to fetch items: #{e.message}") + {} + end + + def make_request(url) + conn = Faraday.new do |f| + f.request :retry, max: 3, interval: 0.5, backoff_factor: 2 + f.adapter Faraday.default_adapter + end + + response = conn.get(url) do |req| + req.options.timeout = 10 + end + + raise DataDragonError, "Request failed with status #{response.status}" unless response.success? + + response + rescue Faraday::TimeoutError => e + raise DataDragonError, "Request timeout: #{e.message}" + rescue Faraday::Error => e + raise DataDragonError, "Network error: #{e.message}" + end +end diff --git a/app/services/riot_api_service.rb b/app/services/riot_api_service.rb index f06c9b9..4062b0d 100644 --- a/app/services/riot_api_service.rb +++ b/app/services/riot_api_service.rb @@ -1,240 +1,282 @@ -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' } - }.freeze - - class RiotApiError < StandardError; end - 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? - end - - # Summoner endpoints - 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)}" - - response = make_request(url) - parse_summoner_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 - - # League (Rank) endpoints - 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) - parse_league_entries(response) - end - - # Match endpoints - 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) - 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) - parse_match_details(response) - end - - # Champion Mastery endpoints - 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) - 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 - f.adapter Faraday.default_adapter - end - - response = conn.get(url) do |req| - req.headers['X-Riot-Token'] = @api_key - req.options.timeout = 10 - end - - handle_response(response) - rescue Faraday::TimeoutError => e - raise RiotApiError, "Request timeout: #{e.message}" - rescue Faraday::Error => e - raise RiotApiError, "Network error: #{e.message}" - 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 429 - retry_after = response.headers['Retry-After']&.to_i || 120 - 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}" - end - end - - def check_rate_limit! - # Simple rate limiting using Redis - 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 - - if count_two_min > RATE_LIMITS[:per_two_minutes] - raise RateLimitError, 'Rate limit exceeded for 2-minute window' - end - end - - def platform_for_region(region) - REGIONS.dig(region.upcase, :platform) || raise(RiotApiError, "Unknown region: #{region}") - end - - def regional_route_for_region(region) - REGIONS.dig(region.upcase, :region) || raise(RiotApiError, "Unknown region: #{region}") - end - - def parse_summoner_response(response) - data = JSON.parse(response.body) - { - summoner_id: data['id'], - puuid: data['puuid'], - summoner_name: data['name'], - summoner_level: data['summonerLevel'], - profile_icon_id: data['profileIconId'] - } - end - - 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') - } - end - - def find_queue_entry(entries, queue_type) - entry = entries.find { |e| e['queueType'] == queue_type } - return nil unless entry - - { - tier: entry['tier'], - rank: entry['rank'], - lp: entry['leaguePoints'], - wins: entry['wins'], - losses: entry['losses'] - } - end - - def parse_match_details(response) - data = JSON.parse(response.body) - info = data['info'] - metadata = data['metadata'] - - { - match_id: metadata['matchId'], - game_creation: Time.at(info['gameCreation'] / 1000), - game_duration: info['gameDuration'], - game_mode: info['gameMode'], - game_version: info['gameVersion'], - participants: info['participants'].map { |p| parse_participant(p) } - } - end - - def parse_participant(participant) - { - puuid: participant['puuid'], - summoner_name: participant['summonerName'], - champion_name: participant['championName'], - champion_id: participant['championId'], - team_id: participant['teamId'], - role: participant['teamPosition']&.downcase, - kills: participant['kills'], - deaths: participant['deaths'], - assists: participant['assists'], - gold_earned: participant['goldEarned'], - total_damage_dealt: participant['totalDamageDealtToChampions'], - 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'], - first_blood_kill: participant['firstBloodKill'], - double_kills: participant['doubleKills'], - triple_kills: participant['tripleKills'], - quadra_kills: participant['quadraKills'], - penta_kills: participant['pentaKills'], - win: participant['win'] - } - end - - def parse_champion_mastery(response) - masteries = JSON.parse(response.body) - - masteries.map do |mastery| - { - champion_id: mastery['championId'], - champion_level: mastery['championLevel'], - champion_points: mastery['championPoints'], - last_played: Time.at(mastery['lastPlayTime'] / 1000) - } - end - end -end +# frozen_string_literal: true + +# Service for interacting with the Riot Games API +# +# Handles all communication with Riot's League of Legends APIs including: +# - Summoner data retrieval +# - Ranked stats and league information +# - Match history and details +# - Champion mastery data +# +# Features: +# - Automatic rate limiting (20/sec, 100/2min) +# - Regional routing (platform vs regional endpoints) +# - Error handling with custom exceptions +# - Automatic retries for transient failures +# +# @example Initialize and fetch summoner data +# service = RiotApiService.new +# summoner = service.get_summoner_by_name( +# summoner_name: "Faker", +# region: "KR" +# ) +# +# @example Get match history +# matches = service.get_match_history( +# puuid: player.riot_puuid, +# region: "BR", +# count: 20 +# ) +# +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' } + }.freeze + + class RiotApiError < StandardError; end + 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? + end + + # Summoner endpoints + + # Retrieves summoner information by summoner name + # + # @param summoner_name [String] The summoner's in-game name + # @param region [String] The region code (e.g., 'BR', 'NA', 'EUW') + # @return [Hash] Summoner data including puuid, summoner_id, level + # @raise [NotFoundError] If summoner is not found + # @raise [RiotApiError] For other API errors + 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)}" + + response = make_request(url) + parse_summoner_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 + + # League (Rank) endpoints + 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) + parse_league_entries(response) + end + + # Match endpoints + 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) + 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) + parse_match_details(response) + end + + # Champion Mastery endpoints + 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) + 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 + f.adapter Faraday.default_adapter + end + + response = conn.get(url) do |req| + req.headers['X-Riot-Token'] = @api_key + req.options.timeout = 10 + end + + handle_response(response) + rescue Faraday::TimeoutError => e + raise RiotApiError, "Request timeout: #{e.message}" + rescue Faraday::Error => e + raise RiotApiError, "Network error: #{e.message}" + 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 429 + retry_after = response.headers['Retry-After']&.to_i || 120 + 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}" + end + end + + def check_rate_limit! + # Simple rate limiting using Redis + 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) + # Handle both 'BR' and 'br1' formats + clean_region = region.to_s.upcase.gsub(/\d+/, '') + REGIONS.dig(clean_region, :platform) || raise(RiotApiError, "Unknown region: #{region}") + end + + def regional_route_for_region(region) + # Handle both 'BR' and 'br1' formats + clean_region = region.to_s.upcase.gsub(/\d+/, '') + REGIONS.dig(clean_region, :region) || raise(RiotApiError, "Unknown region: #{region}") + end + + def parse_summoner_response(response) + data = JSON.parse(response.body) + { + summoner_id: data['id'], + puuid: data['puuid'], + summoner_name: data['name'], + summoner_level: data['summonerLevel'], + profile_icon_id: data['profileIconId'] + } + end + + 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') + } + end + + def find_queue_entry(entries, queue_type) + entry = entries.find { |e| e['queueType'] == queue_type } + return nil unless entry + + { + tier: entry['tier'], + rank: entry['rank'], + lp: entry['leaguePoints'], + wins: entry['wins'], + losses: entry['losses'] + } + end + + def parse_match_details(response) + data = JSON.parse(response.body) + info = data['info'] + metadata = data['metadata'] + + { + match_id: metadata['matchId'], + game_creation: Time.at(info['gameCreation'] / 1000), + game_duration: info['gameDuration'], + game_mode: info['gameMode'], + game_version: info['gameVersion'], + participants: info['participants'].map { |p| parse_participant(p) } + } + end + + def parse_participant(participant) + { + puuid: participant['puuid'], + summoner_name: participant['summonerName'], + champion_name: participant['championName'], + champion_id: participant['championId'], + team_id: participant['teamId'], + role: participant['teamPosition']&.downcase, + kills: participant['kills'], + deaths: participant['deaths'], + assists: participant['assists'], + gold_earned: participant['goldEarned'], + total_damage_dealt: participant['totalDamageDealtToChampions'], + 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'], + first_blood_kill: participant['firstBloodKill'], + double_kills: participant['doubleKills'], + triple_kills: participant['tripleKills'], + quadra_kills: participant['quadraKills'], + penta_kills: participant['pentaKills'], + win: participant['win'] + } + end + + def parse_champion_mastery(response) + masteries = JSON.parse(response.body) + + masteries.map do |mastery| + { + champion_id: mastery['championId'], + champion_level: mastery['championLevel'], + champion_points: mastery['championPoints'], + last_played: Time.at(mastery['lastPlayTime'] / 1000) + } + end + end +end diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..84c7878 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,69 @@ + + + + + + + +
+
+

ProStaff

+
+
+ <%= yield %> +
+ +
+ + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..6073edf --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1,8 @@ +ProStaff +======================================== + +<%= yield %> + +---------------------------------------- +© <%= Time.current.year %> ProStaff.gg. All rights reserved. +This is an automated message, please do not reply. diff --git a/app/views/user_mailer/password_reset.html.erb b/app/views/user_mailer/password_reset.html.erb new file mode 100644 index 0000000..51e4527 --- /dev/null +++ b/app/views/user_mailer/password_reset.html.erb @@ -0,0 +1,21 @@ +

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 Password +

+ +

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

diff --git a/app/views/user_mailer/password_reset.text.erb b/app/views/user_mailer/password_reset.text.erb new file mode 100644 index 0000000..0eff796 --- /dev/null +++ b/app/views/user_mailer/password_reset.text.erb @@ -0,0 +1,15 @@ +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 diff --git a/app/views/user_mailer/password_reset_confirmation.html.erb b/app/views/user_mailer/password_reset_confirmation.html.erb new file mode 100644 index 0000000..2e15455 --- /dev/null +++ b/app/views/user_mailer/password_reset_confirmation.html.erb @@ -0,0 +1,12 @@ +

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

diff --git a/app/views/user_mailer/password_reset_confirmation.text.erb b/app/views/user_mailer/password_reset_confirmation.text.erb new file mode 100644 index 0000000..b89a3ac --- /dev/null +++ b/app/views/user_mailer/password_reset_confirmation.text.erb @@ -0,0 +1,12 @@ +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 diff --git a/app/views/user_mailer/welcome.html.erb b/app/views/user_mailer/welcome.html.erb new file mode 100644 index 0000000..5e7ba05 --- /dev/null +++ b/app/views/user_mailer/welcome.html.erb @@ -0,0 +1,21 @@ +

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

diff --git a/app/views/user_mailer/welcome.text.erb b/app/views/user_mailer/welcome.text.erb new file mode 100644 index 0000000..f3cc013 --- /dev/null +++ b/app/views/user_mailer/welcome.text.erb @@ -0,0 +1,19 @@ +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 diff --git a/bin/check_db_connection b/bin/check_db_connection new file mode 100644 index 0000000..a79d27d --- /dev/null +++ b/bin/check_db_connection @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Database Connection Safety Checker +# Prevents accidental execution of destructive commands on production + +require_relative '../config/environment' + +config = ActiveRecord::Base.connection_db_config.configuration_hash + +puts "\n🔌 Database Connection Check" +puts "=" * 50 +puts " Environment: #{Rails.env}" +puts " Host: #{config[:host]}" +puts " Database: #{config[:database]}" +puts " Username: #{config[:username]}" +puts " Port: #{config[:port]}" +puts "=" * 50 + +if config[:host].to_s.include?('supabase') || config[:host].to_s.include?('aws') + puts "\n🚨 WARNING: Connected to REMOTE PRODUCTION DATABASE!" + puts " This is DANGEROUS for destructive operations!" + puts " Host: #{config[:host]}" + puts "\n❌ BLOCKED: Use .env.test or local database instead." + exit 1 +elsif config[:host].to_s == 'localhost' || config[:host].to_s == '127.0.0.1' + puts "\n✅ Safe: Connected to localhost" + puts " You can proceed with operations." + exit 0 +else + puts "\n⚠️ Unknown host: #{config[:host]}" + puts " Please verify before proceeding!" + exit 1 +end diff --git a/bin/rails b/bin/rails index 0739660..a31728a 100644 --- a/bin/rails +++ b/bin/rails @@ -1,4 +1,6 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + APP_PATH = File.expand_path('../config/application', __dir__) require_relative '../config/boot' require 'rails/commands' diff --git a/bin/rake b/bin/rake index 1724048..c199955 100644 --- a/bin/rake +++ b/bin/rake @@ -1,4 +1,6 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + require_relative '../config/boot' require 'rake' Rake.application.run diff --git a/bin/setup b/bin/setup index f5daa1e..5ccf6e7 100644 --- a/bin/setup +++ b/bin/setup @@ -1,4 +1,6 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + require 'fileutils' # path to your application root. diff --git a/config.ru b/config.ru index 9ab87a6..6dc8321 100644 --- a/config.ru +++ b/config.ru @@ -1,6 +1,8 @@ -# This file is used by Rack-based servers to start the application. - -require_relative "config/environment" - -run Rails.application -Rails.application.load_server \ No newline at end of file +# frozen_string_literal: true + +# This file is used by Rack-based servers to start the application. + +require_relative 'config/environment' + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb index b31d317..61837c5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,81 +1,83 @@ -require_relative "boot" - -require "rails" -# Pick the frameworks you want: -require "active_model/railtie" -require "active_job/railtie" -require "active_record/railtie" -require "active_storage/engine" -require "action_controller/railtie" -require "action_mailer/railtie" -require "action_mailbox/engine" -require "action_text/engine" -require "action_view/railtie" -require "action_cable/engine" -# require "rails/test_unit/railtie" - -# Require the gems listed in Gemfile, including any gems -# you've limited to :test, :development, or :production. -Bundler.require(*Rails.groups) - -module ProstaffApi - class Application < Rails::Application - # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.1 - - # Please, add to the `ignore` list any other `lib` subdirectories that do - # not contain `.rb` files, or that should not be reloaded or eager loaded. - # Common ones are `templates`, `generators`, or `middleware`. - config.autoload_lib(ignore: %w(assets tasks)) - - # Configuration for the application, engines, and railties goes here. - # - # These settings can be overridden in specific environments using the files - # in config/environments/, which are processed later. - # - # config.time_zone = "Central Time (US & Canada)" - # config.eager_load_paths << Rails.root.join("extras") - - # Only loads a smaller set of middleware suitable for API only apps. - # Middleware like session, flash, cookies can be added back manually. - # Skip views, helpers and assets when generating a new resource. - config.api_only = true - - # Load modules directory - config.autoload_paths += %W(#{config.root}/app/modules) - config.eager_load_paths += %W(#{config.root}/app/modules) - - # CORS configuration - config.middleware.insert_before 0, Rack::Cors do - allow do - origins ENV.fetch('CORS_ORIGINS', 'http://localhost:5173').split(',') - - resource '*', - headers: :any, - methods: [:get, :post, :put, :patch, :delete, :options, :head], - credentials: true, - max_age: 86400 - end - end - - # Rack Attack for rate limiting - config.middleware.use Rack::Attack - - # Time zone - config.time_zone = 'UTC' - - # Generator configuration - config.generators do |g| - g.test_framework :rspec - g.factory_bot_dir 'spec/factories' - g.skip_routes true - g.helper false - g.assets false - g.view_specs false - g.helper_specs false - g.routing_specs false - g.controller_specs false - g.request_specs true - end - end -end \ No newline at end of file +# frozen_string_literal: true + +require_relative 'boot' + +require 'rails' +# Pick the frameworks you want: +require 'active_model/railtie' +require 'active_job/railtie' +require 'active_record/railtie' +require 'active_storage/engine' +require 'action_controller/railtie' +require 'action_mailer/railtie' +require 'action_mailbox/engine' +require 'action_text/engine' +require 'action_view/railtie' +require 'action_cable/engine' +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module ProstaffApi + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 7.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments/, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Only loads a smaller set of middleware suitable for API only apps. + # Middleware like session, flash, cookies can be added back manually. + # Skip views, helpers and assets when generating a new resource. + config.api_only = true + + # Load modules directory + config.autoload_paths += %W[#{config.root}/app/modules] + config.eager_load_paths += %W[#{config.root}/app/modules] + + # CORS configuration + config.middleware.insert_before 0, Rack::Cors do + allow do + origins ENV.fetch('CORS_ORIGINS', 'http://localhost:5173').split(',') + + resource '*', + headers: :any, + methods: %i[get post put patch delete options head], + credentials: true, + max_age: 86_400 + end + end + + # Rack Attack for rate limiting + config.middleware.use Rack::Attack + + # Time zone + config.time_zone = 'UTC' + + # Generator configuration + config.generators do |g| + g.test_framework :rspec + g.factory_bot_dir 'spec/factories' + g.skip_routes true + g.helper false + g.assets false + g.view_specs false + g.helper_specs false + g.routing_specs false + g.controller_specs false + g.request_specs true + end + end +end diff --git a/config/boot.rb b/config/boot.rb index c8c4faf..c04863f 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,4 +1,6 @@ -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -require "bundler/setup" # Set up gems listed in the Gemfile. -require "bootsnap/setup" # Speed up boot time by caching expensive operations. \ No newline at end of file +# frozen_string_literal: true + +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) + +require 'bundler/setup' # Set up gems listed in the Gemfile. +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/config/environment.rb b/config/environment.rb index d27c36d..5cacbed 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,3 +1,5 @@ -require_relative "application" - -Rails.application.initialize! \ No newline at end of file +# frozen_string_literal: true + +require_relative 'application' + +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb index 815b261..cbb9ef9 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,59 +1,61 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # 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.eager_load = false - - config.consider_all_requests_local = true - - config.server_timing = true - - if Rails.root.join("tmp/caching-dev.txt").exist? - config.cache_store = :memory_store - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" - } - else - config.action_controller.perform_caching = false - - config.cache_store = :null_store - end - - config.active_storage.variant_processor = :mini_magick - - config.action_mailer.raise_delivery_errors = false - - config.action_mailer.perform_caching = false - - config.active_support.deprecation = :log - - config.active_support.disallowed_deprecation = :raise - - config.active_support.disallowed_deprecation_warnings = [] - - config.active_record.migration_error = :page_load - - config.active_record.verbose_query_logs = true - - config.assets.quiet = true if defined?(config.assets) - - config.assets.debug = true if defined?(config.assets) - - config.assets.quiet = true if defined?(config.assets) - - # Bullet for N+1 query detection - # Uncomment if using Bullet gem - # config.after_initialize do - # Bullet.enable = true - # Bullet.alert = true - # Bullet.bullet_logger = true - # Bullet.console = true - # Bullet.rails_logger = true - # end -end \ No newline at end of file +# frozen_string_literal: true + +require 'active_support/core_ext/integer/time' + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # 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.eager_load = false + + config.consider_all_requests_local = true + + config.server_timing = true + + if Rails.root.join('tmp/caching-dev.txt').exist? + config.cache_store = :memory_store + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + config.active_storage.variant_processor = :mini_magick + + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + config.active_support.deprecation = :log + + config.active_support.disallowed_deprecation = :raise + + config.active_support.disallowed_deprecation_warnings = [] + + config.active_record.migration_error = :page_load + + config.active_record.verbose_query_logs = true + + config.assets.quiet = true if defined?(config.assets) + + config.assets.debug = true if defined?(config.assets) + + config.assets.quiet = true if defined?(config.assets) + + # Bullet for N+1 query detection + # Uncomment if using Bullet gem + # config.after_initialize do + # Bullet.enable = true + # Bullet.alert = true + # Bullet.bullet_logger = true + # Bullet.console = true + # Bullet.rails_logger = true + # end +end diff --git a/config/environments/production.rb b/config/environments/production.rb index d7d7185..1a6d276 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,44 +1,44 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - - config.cache_classes = true - - - config.eager_load = true - - config.consider_all_requests_local = false - - config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? - - config.active_storage.variant_processor = :mini_magick - - config.force_ssl = true - - config.log_level = :info - - config.log_tags = [ :request_id ] - - config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } - - - config.active_job.queue_adapter = :sidekiq - - config.action_mailer.perform_caching = false - - - config.i18n.fallbacks = true - - config.active_support.report_deprecations = false - - config.log_formatter = ::Logger::Formatter.new - - - if ENV["RAILS_LOG_TO_STDOUT"].present? - logger = ActiveSupport::Logger.new(STDOUT) - logger.formatter = config.log_formatter - config.logger = ActiveSupport::TaggedLogging.new(logger) - end - - config.active_record.dump_schema_after_migration = false -end \ No newline at end of file +# frozen_string_literal: true + +require 'active_support/core_ext/integer/time' + +Rails.application.configure do + config.cache_classes = true + + config.eager_load = true + + config.consider_all_requests_local = false + + # Allow Render hostname + config.hosts << ENV['RENDER_EXTERNAL_HOSTNAME'] if ENV['RENDER_EXTERNAL_HOSTNAME'].present? + + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + + config.active_storage.variant_processor = :mini_magick + + config.force_ssl = true + + config.log_level = :info + + config.log_tags = [:request_id] + + config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } + + config.active_job.queue_adapter = :sidekiq + + config.action_mailer.perform_caching = false + + config.i18n.fallbacks = true + + config.active_support.report_deprecations = false + + config.log_formatter = Logger::Formatter.new + + if ENV['RAILS_LOG_TO_STDOUT'].present? + logger = ActiveSupport::Logger.new($stdout) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + + config.active_record.dump_schema_after_migration = false +end diff --git a/config/environments/test.rb b/config/environments/test.rb index af76603..6d3368e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,60 +1,62 @@ -require "active_support/core_ext/integer/time" - -# The test environment is used exclusively to run your application's -# test suite. You never need to work with it otherwise. Remember that -# your test database is "scratch space" for the test suite and is wiped -# and recreated between test runs. Don't rely on the data there! - -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 - - # 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 - # system, or in some way before deploying your code. - config.eager_load = false - - # Configure public file server for tests with Cache-Control for performance. - config.public_file_server.enabled = true - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{1.hour.to_i}" - } - - # Show full error reports and disable caching. - config.consider_all_requests_local = true - config.action_controller.perform_caching = false - config.cache_store = :null_store - - # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false - - # Disable request forgery protection in test environment. - config.action_controller.allow_forgery_protection = false - - # Store uploaded files on the local file system in a temporary directory. - config.active_storage.variant_processor = :mini_magick - - config.action_mailer.perform_caching = false - - # Tell Action Mailer not to deliver emails to the real world. - # The :test delivery method accumulates sent emails in the - # ActionMailer::Base.deliveries array. - config.action_mailer.delivery_method = :test - - # Print deprecation notices to the stderr. - config.active_support.deprecation = :stderr - - # Raise exceptions for disallowed deprecations. - config.active_support.disallowed_deprecation = :raise - - # Tell Active Support which deprecation messages to disallow. - config.active_support.disallowed_deprecation_warnings = [] - - # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true - - # Annotate rendered view with file names. - # config.action_view.annotate_rendered_view_with_filenames = true -end \ No newline at end of file +# frozen_string_literal: true + +require 'active_support/core_ext/integer/time' + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +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 + + # 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 + # system, or in some way before deploying your code. + config.eager_load = false + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.variant_processor = :mini_magick + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true +end diff --git a/config/initializers/action_mailer.rb b/config/initializers/action_mailer.rb new file mode 100644 index 0000000..d9b793b --- /dev/null +++ b/config/initializers/action_mailer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.action_mailer.delivery_method = ENV.fetch('MAILER_DELIVERY_METHOD', 'smtp').to_sym + + config.action_mailer.smtp_settings = { + address: ENV.fetch('SMTP_ADDRESS', 'smtp.gmail.com'), + port: ENV.fetch('SMTP_PORT', 587).to_i, + domain: ENV.fetch('SMTP_DOMAIN', 'gmail.com'), + user_name: ENV['SMTP_USERNAME'], + password: ENV['SMTP_PASSWORD'], + authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym, + enable_starttls_auto: ENV.fetch('SMTP_ENABLE_STARTTLS_AUTO', 'true') == 'true' + } + + config.action_mailer.default_url_options = { + host: ENV.fetch('FRONTEND_URL', 'http://localhost:8888').gsub(%r{https?://}, ''), + protocol: ENV.fetch('FRONTEND_URL', 'http://localhost:8888').start_with?('https') ? 'https' : 'http' + } + + config.action_mailer.raise_delivery_errors = true + config.action_mailer.perform_deliveries = true + + config.action_mailer.deliver_later_queue_name = 'mailers' +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 9d8252d..8eaea2b 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -1,18 +1,20 @@ -# Be sure to restart your server when you modify this file. - -# Avoid CORS issues when API is called from the frontend app. -# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. - -# Read more: https://github.com/cyu/rack-cors - -Rails.application.config.middleware.insert_before 0, Rack::Cors do - allow do - origins ENV.fetch('CORS_ORIGINS', 'http://localhost:5173').split(',') - - resource '*', - headers: :any, - methods: [:get, :post, :put, :patch, :delete, :options, :head], - credentials: true, - max_age: 86400 - end -end \ No newline at end of file +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Avoid CORS issues when API is called from the frontend app. +# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. + +# Read more: https://github.com/cyu/rack-cors + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins ENV.fetch('CORS_ORIGINS', 'http://localhost:5173,http://localhost:8888').split(',') + + resource '*', + headers: :any, + methods: %i[get post put patch delete options head], + credentials: true, + max_age: 86_400 + end +end diff --git a/config/initializers/database_safety.rb b/config/initializers/database_safety.rb new file mode 100644 index 0000000..8573c24 --- /dev/null +++ b/config/initializers/database_safety.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# Database Safety Initializer +# +# CRITICAL PROTECTION: Prevents accidental data loss by blocking destructive +# database operations when connected to remote/production databases. +# +# This initializer will ABORT any attempt to: +# - Drop databases +# - Reset databases +# - Load schema (which drops all tables) +# - Purge databases +# +# ...if the connection is pointing to a remote database (Supabase, AWS, etc.) + +# Database safety protection module +module DatabaseSafetyProtection + REMOTE_INDICATORS = %w[supabase aws rds heroku render railway].freeze + + DANGEROUS_TASKS = %w[ + db:drop + db:drop:_unsafe + db:reset + db:schema:load + db:structure:load + db:purge + db:test:purge + db:migrate:reset + ].freeze + + module_function + + def remote_database? + return false unless defined?(ActiveRecord::Base) + + host = database_host + REMOTE_INDICATORS.any? { |indicator| host.include?(indicator) } + rescue StandardError + false + end + + def database_host + ActiveRecord::Base.connection_db_config.configuration_hash[:host].to_s + end + + def database_config + ActiveRecord::Base.connection_db_config.configuration_hash + end + + def block_dangerous_tasks! + DANGEROUS_TASKS.each do |task_name| + block_task(task_name) if task_defined?(task_name) + end + end + + def task_defined?(task_name) + Rake::Task.task_defined?(task_name) + end + + def block_task(task_name) + task = Rake::Task[task_name] + task.clear_actions + task.actions << create_blocking_action(task_name) + end + + def create_blocking_action(task_name) + proc do + display_blocking_message(task_name) + abort '❌ Operation aborted to protect your data!' + end + end + + def display_blocking_message(task_name) + print_blocking_header(task_name) + print_connection_info + print_fix_instructions + end + + def print_blocking_header(task_name) + puts "\n#{'=' * 70}" + puts '🚨 CRITICAL: DESTRUCTIVE DATABASE OPERATION BLOCKED!' + puts('=' * 70) + puts "\n❌ Task '#{task_name}' blocked on REMOTE DATABASE!" + end + + def print_connection_info + config = database_config + puts "\n📍 Current Connection:" + puts " Host: #{config[:host]}" + puts " Database: #{config[:database]}" + puts " User: #{config[:username]}" + puts "\n🛡️ This operation has been BLOCKED to prevent data loss." + end + + def print_fix_instructions + puts "\n💡 To fix this:" + puts ' 1. Verify your .env file is NOT pointing to production' + puts ' 2. Use local database for development/testing' + puts ' 3. Check config/database.yml configuration' + puts "\n Run: ./bin/check_db_connection" + puts "\n#{'=' * 70}\n" + end + + def log_remote_connection_warning + config = database_config + Rails.logger.warn "\n#{'!' * 70}" + Rails.logger.warn '⚠️ WARNING: Connected to REMOTE database!' + Rails.logger.warn " Host: #{config[:host]}" + Rails.logger.warn ' Destructive operations are BLOCKED' + Rails.logger.warn "#{'!' * 70}\n" + end +end + +# Only run this protection in non-production environments +unless Rails.env.production? + Rails.application.config.after_initialize do + next unless defined?(Rake::Task) && DatabaseSafetyProtection.remote_database? + + DatabaseSafetyProtection.block_dangerous_tasks! + DatabaseSafetyProtection.log_remote_connection_warning + end +end + +# Additional safety: Prevent schema loading in console if remote +if defined?(Rails::Console) && !Rails.env.production? + Rails.application.config.after_initialize do + next unless defined?(ActiveRecord::Base) + next unless DatabaseSafetyProtection.remote_database? + + host = DatabaseSafetyProtection.database_host + puts "\n#{'⚠️ ' * 35}" + puts '⚠️ CAUTION: Rails console connected to REMOTE database!' + puts "⚠️ Host: #{host}" + puts '⚠️ Be VERY careful with destructive operations!' + puts "#{'⚠️ ' * 35}\n" + end +end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index f395398..dafd3e4 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -1,48 +1,43 @@ -class Rack::Attack - # Enable caching for Rack::Attack using Rails cache store - Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - - # Allow localhost in development - safelist('allow from localhost') do |req| - '127.0.0.1' == req.ip || '::1' == req.ip if Rails.env.development? - end - - # Throttle all requests by IP - throttle('req/ip', limit: ENV.fetch('RACK_ATTACK_LIMIT', 300).to_i, period: ENV.fetch('RACK_ATTACK_PERIOD', 300).to_i) do |req| - req.ip - end - - # Throttle login attempts - throttle('logins/ip', limit: 5, period: 20.seconds) do |req| - if req.path == '/api/v1/auth/login' && req.post? - req.ip - end - end - - # Throttle registration - throttle('register/ip', limit: 3, period: 1.hour) do |req| - if req.path == '/api/v1/auth/register' && req.post? - req.ip - end - end - - # Throttle password reset requests - throttle('password_reset/ip', limit: 5, period: 1.hour) do |req| - if req.path == '/api/v1/auth/forgot-password' && req.post? - req.ip - end - end - - # Throttle API requests per authenticated user - throttle('req/authenticated_user', limit: 1000, period: 1.hour) do |req| - if req.env['rack.jwt.payload'] - req.env['rack.jwt.payload']['user_id'] - end - end - - # Log blocked requests - ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, payload| - req = payload[:request] - Rails.logger.warn "[Rack::Attack] Blocked #{req.env['REQUEST_METHOD']} #{req.url} from #{req.ip} at #{Time.current}" - end -end \ No newline at end of file +# frozen_string_literal: true + +module Rack + class Attack + # Enable caching for Rack::Attack using Rails cache store + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + # Allow localhost in development + safelist('allow from localhost') do |req| + ['127.0.0.1', '::1'].include?(req.ip) if Rails.env.development? + end + + # Throttle all requests by IP + throttle('req/ip', limit: ENV.fetch('RACK_ATTACK_LIMIT', 300).to_i, + period: ENV.fetch('RACK_ATTACK_PERIOD', 300).to_i, &:ip) + + # Throttle login attempts + throttle('logins/ip', limit: 5, period: 20.seconds) do |req| + req.ip if req.path == '/api/v1/auth/login' && req.post? + end + + # Throttle registration + throttle('register/ip', limit: 3, period: 1.hour) do |req| + req.ip if req.path == '/api/v1/auth/register' && req.post? + end + + # Throttle password reset requests + throttle('password_reset/ip', limit: 5, period: 1.hour) do |req| + req.ip if req.path == '/api/v1/auth/forgot-password' && req.post? + end + + # Throttle API requests per authenticated user + throttle('req/authenticated_user', limit: 1000, period: 1.hour) do |req| + req.env['rack.jwt.payload']['user_id'] if req.env['rack.jwt.payload'] + end + + # Log blocked requests + ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload| + req = payload[:request] + Rails.logger.warn "[Rack::Attack] Blocked #{req.env['REQUEST_METHOD']} #{req.url} from #{req.ip} at #{Time.current}" + end + end +end diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb index f701c68..22768ea 100644 --- a/config/initializers/rswag_api.rb +++ b/config/initializers/rswag_api.rb @@ -1,7 +1,9 @@ -Rswag::Api.configure do |c| - # Specify a root folder where Swagger JSON files are located - c.swagger_root = Rails.root.join('swagger').to_s - - # Inject a lambda function to alter the returned Swagger prior to serialization - # c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } -end +# frozen_string_literal: true + +Rswag::Api.configure do |c| + # Specify a root folder where OpenAPI JSON files are located + c.openapi_root = Rails.root.join('swagger').to_s + + # Inject a lambda function to alter the returned Swagger prior to serialization + # c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb index da170d0..99b6752 100644 --- a/config/initializers/rswag_ui.rb +++ b/config/initializers/rswag_ui.rb @@ -1,11 +1,13 @@ -Rswag::Ui.configure do |c| - # List the Swagger endpoints that you want to be documented through the - # swagger-ui. The first parameter is the path (absolute or relative to the UI - # host) to the corresponding endpoint and the second is a title that will be - # displayed in the document selector. - c.swagger_endpoint '/api-docs/v1/swagger.yaml', 'ProStaff API V1 Docs' - - # Add Basic Auth in case your API is private - # c.basic_auth_enabled = true - # c.basic_auth_credentials 'username', 'password' -end +# frozen_string_literal: true + +Rswag::Ui.configure do |c| + # List the OpenAPI endpoints that you want to be documented through the + # swagger-ui. The first parameter is the path (absolute or relative to the UI + # host) to the corresponding endpoint and the second is a title that will be + # displayed in the document selector. + c.openapi_endpoint '/api-docs/v1/swagger.yaml', 'ProStaff API V1 Docs' + + # Add Basic Auth in case your API is private + # c.basic_auth_enabled = true + # c.basic_auth_credentials 'username', 'password' +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 5c8a44b..c39f9f4 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,7 +1,23 @@ -Sidekiq.configure_server do |config| - config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } -end - -Sidekiq.configure_client do |config| - config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } -end \ No newline at end of file +# frozen_string_literal: true + +require 'sidekiq' +require 'sidekiq-scheduler' + +Sidekiq.configure_server do |config| + config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } + + config.on(:startup) do + schedule_file = Rails.root.join('config', 'sidekiq.yml') + if File.exist?(schedule_file) + schedule = YAML.load_file(schedule_file) + if schedule && schedule[:schedule] + Sidekiq.schedule = schedule[:schedule] + SidekiqScheduler::Scheduler.instance.reload_schedule! + end + end + end +end + +Sidekiq.configure_client do |config| + config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } +end diff --git a/config/puma.rb b/config/puma.rb index a5c30e2..b9a88c3 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,122 +1,124 @@ -# Puma configuration file for ProStaff API - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT") { 3000 } - -# Specifies the `environment` that Puma will run in. -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } - -# Specifies the number of `workers` to boot in clustered mode. -# Workers are forked web server processes. If using threads and workers together -# the concurrency of the application would be max `threads` * `workers`. -# Workers do not work on JRuby or Windows (both of which do not support -# processes). -workers ENV.fetch("WEB_CONCURRENCY") { 2 } - -# Use the `preload_app!` method when specifying a `workers` number. -# This directive tells Puma to first boot the application and load code -# before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. -preload_app! - -# Allow puma to be restarted by `rails restart` command. -plugin :tmp_restart - -# Specifies the number of `threads` to use per worker. -# This controls how many threads Puma will use to process requests. -# The default is set to 5 threads as a decent default for most Ruby/Rails apps. -max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } -threads min_threads_count, max_threads_count - -# === Production Optimizations === -if ENV["RAILS_ENV"] == "production" || ENV["RAILS_ENV"] == "staging" - # Increase workers in production - workers ENV.fetch("WEB_CONCURRENCY") { 4 } - - # Bind to socket for better nginx integration (optional) - # bind "unix://#{ENV.fetch('APP_ROOT', Dir.pwd)}/tmp/sockets/puma.sock" - - # Logging - stdout_redirect( - ENV.fetch("PUMA_STDOUT_LOG") { "#{Dir.pwd}/log/puma_access.log" }, - ENV.fetch("PUMA_STDERR_LOG") { "#{Dir.pwd}/log/puma_error.log" }, - true - ) - - # Worker timeout (seconds) - # Kill workers if they hang for more than this time - worker_timeout ENV.fetch("PUMA_WORKER_TIMEOUT") { 60 }.to_i - - # Worker boot timeout - worker_boot_timeout ENV.fetch("PUMA_WORKER_BOOT_TIMEOUT") { 60 }.to_i - - # Worker shutdown timeout - worker_shutdown_timeout ENV.fetch("PUMA_WORKER_SHUTDOWN_TIMEOUT") { 30 }.to_i - - # === Phased Restart (Zero Downtime Deploys) === - # This allows Puma to restart workers one at a time - # instead of all at once during a restart - # Use: pumactl phased-restart - on_worker_boot do - # Worker specific setup for Rails - # This is needed for preload_app to work with ActiveRecord - ActiveRecord::Base.establish_connection if defined?(ActiveRecord) - end - - before_fork do - # Disconnect from database before forking - ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) - end - - # === Nakayoshi Fork (Memory Optimization) === - # This reduces memory usage by running GC before forking - nakayoshi_fork if ENV.fetch("PUMA_NAKAYOSHI_FORK") { "true" } == "true" - - # === Low Level Configuration === - # Configure the queue for accepting connections - # When backlog is full, new connections are rejected - backlog ENV.fetch("PUMA_BACKLOG") { 1024 }.to_i - - # Set the TCP_CORK and TCP_NODELAY options on the connection socket - tcp_nopush true if ENV.fetch("PUMA_TCP_NOPUSH") { "true" } == "true" - - # === Monitoring === - # Activate control/status app - # Allows you to query Puma for stats and control it - # Access via: pumactl stats -C unix://#{Dir.pwd}/tmp/sockets/pumactl.sock - activate_control_app "unix://#{Dir.pwd}/tmp/sockets/pumactl.sock", { no_token: true } -end - -# === Development Optimizations === -if ENV["RAILS_ENV"] == "development" - # Use fewer workers in development - workers 0 - - # Verbose logging in development - debug true if ENV.fetch("PUMA_DEBUG") { "false" } == "true" -end - -# === Callbacks === -on_booted do - puts "🚀 Puma booted (PID: #{Process.pid})" - puts " Environment: #{ENV['RAILS_ENV']}" - puts " Workers: #{ENV.fetch('WEB_CONCURRENCY', 2)}" - puts " Threads: #{min_threads_count}-#{max_threads_count}" - puts " Port: #{ENV.fetch('PORT', 3000)}" -end - -on_worker_boot do |worker_index| - puts "👷 Worker #{worker_index} booted (PID: #{Process.pid})" -end - -on_worker_shutdown do |worker_index| - puts "👷 Worker #{worker_index} shutting down (PID: #{Process.pid})" -end - -# === Health Check Endpoint === -# This is automatically handled by Rails /up endpoint -# No additional configuration needed here +# frozen_string_literal: true + +# Puma configuration file for ProStaff API + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch('PORT', 3000) + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch('RAILS_ENV', 'development') + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid') + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked web server processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +workers ENV.fetch('WEB_CONCURRENCY', 2) + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +preload_app! + +# Allow puma to be restarted by `rails restart` command. +plugin :tmp_restart + +# Specifies the number of `threads` to use per worker. +# This controls how many threads Puma will use to process requests. +# The default is set to 5 threads as a decent default for most Ruby/Rails apps. +max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) +min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } +threads min_threads_count, max_threads_count + +# === Production Optimizations === +if %w[production staging].include?(ENV['RAILS_ENV']) + # Increase workers in production + workers ENV.fetch('WEB_CONCURRENCY', 4) + + # Bind to socket for better nginx integration (optional) + # bind "unix://#{ENV.fetch('APP_ROOT', Dir.pwd)}/tmp/sockets/puma.sock" + + # Logging + stdout_redirect( + ENV.fetch('PUMA_STDOUT_LOG') { "#{Dir.pwd}/log/puma_access.log" }, + ENV.fetch('PUMA_STDERR_LOG') { "#{Dir.pwd}/log/puma_error.log" }, + true + ) + + # Worker timeout (seconds) + # Kill workers if they hang for more than this time + worker_timeout ENV.fetch('PUMA_WORKER_TIMEOUT', 60).to_i + + # Worker boot timeout + worker_boot_timeout ENV.fetch('PUMA_WORKER_BOOT_TIMEOUT', 60).to_i + + # Worker shutdown timeout + worker_shutdown_timeout ENV.fetch('PUMA_WORKER_SHUTDOWN_TIMEOUT', 30).to_i + + # === Phased Restart (Zero Downtime Deploys) === + # This allows Puma to restart workers one at a time + # instead of all at once during a restart + # Use: pumactl phased-restart + on_worker_boot do + # Worker specific setup for Rails + # This is needed for preload_app to work with ActiveRecord + ActiveRecord::Base.establish_connection if defined?(ActiveRecord) + end + + before_fork do + # Disconnect from database before forking + ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) + end + + # === Nakayoshi Fork (Memory Optimization) === + # This reduces memory usage by running GC before forking + nakayoshi_fork if ENV.fetch('PUMA_NAKAYOSHI_FORK', 'true') == 'true' + + # === Low Level Configuration === + # Configure the queue for accepting connections + # When backlog is full, new connections are rejected + backlog ENV.fetch('PUMA_BACKLOG', 1024).to_i + + # Set the TCP_CORK and TCP_NODELAY options on the connection socket + tcp_nopush true if ENV.fetch('PUMA_TCP_NOPUSH', 'true') == 'true' + + # === Monitoring === + # Activate control/status app + # Allows you to query Puma for stats and control it + # Access via: pumactl stats -C unix://#{Dir.pwd}/tmp/sockets/pumactl.sock + activate_control_app "unix://#{Dir.pwd}/tmp/sockets/pumactl.sock", { no_token: true } +end + +# === Development Optimizations === +if ENV['RAILS_ENV'] == 'development' + # Use fewer workers in development + workers 0 + + # Verbose logging in development + debug true if ENV.fetch('PUMA_DEBUG', 'false') == 'true' +end + +# === Callbacks === +on_booted do + puts "🚀 Puma booted (PID: #{Process.pid})" + puts " Environment: #{ENV['RAILS_ENV']}" + puts " Workers: #{ENV.fetch('WEB_CONCURRENCY', 2)}" + puts " Threads: #{min_threads_count}-#{max_threads_count}" + puts " Port: #{ENV.fetch('PORT', 3000)}" +end + +on_worker_boot do |worker_index| + puts "👷 Worker #{worker_index} booted (PID: #{Process.pid})" +end + +on_worker_shutdown do |worker_index| + puts "👷 Worker #{worker_index} shutting down (PID: #{Process.pid})" +end + +# === Health Check Endpoint === +# This is automatically handled by Rails /up endpoint +# No additional configuration needed here diff --git a/config/routes.rb b/config/routes.rb index 0805058..5dde895 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,102 +1,161 @@ -Rails.application.routes.draw do - # Mount Rswag API documentation - mount Rswag::Ui::Engine => '/api-docs' - mount Rswag::Api::Engine => '/api-docs' - - # Health check endpoint - get "up" => "rails/health#show", as: :rails_health_check - - # API routes - namespace :api do - namespace :v1 do - # Auth - scope :auth do - post 'register', to: 'auth#register' - post 'login', to: 'auth#login' - post 'refresh', to: 'auth#refresh' - post 'logout', to: 'auth#logout' - post 'forgot-password', to: 'auth#forgot_password' - post 'reset-password', to: 'auth#reset_password' - get 'me', to: 'auth#me' - end - - # Dashboard - resources :dashboard, only: [:index] do - collection do - get :stats - get :activities - get :schedule - end - end - - # Players - resources :players do - collection do - get :stats - post :import - post :bulk_sync - end - member do - get :stats - get :matches - post :sync_from_riot - end - end - - # Riot Integration - scope :riot_integration, controller: 'riot_integration' do - get :sync_status - end - - # Scouting - namespace :scouting do - resources :players do - member do - post :sync - end - end - get 'regions', to: 'regions#index' - resources :watchlist, only: [:index, :create, :destroy] - end - - # Analytics - namespace :analytics do - get 'performance', to: 'performance#index' - get 'champions/:player_id', to: 'champions#show' - get 'kda-trend/:player_id', to: 'kda_trend#show' - get 'laning/:player_id', to: 'laning#show' - get 'teamfights/:player_id', to: 'teamfights#show' - get 'vision/:player_id', to: 'vision#show' - get 'team-comparison', to: 'team_comparison#index' - end - - # Matches - resources :matches do - collection do - post :import - end - member do - get :stats - end - end - - # Schedules - resources :schedules - - # VOD Reviews - resources :vod_reviews, path: 'vod-reviews' do - resources :timestamps, controller: 'vod_timestamps', only: [:index, :create] - end - resources :vod_timestamps, path: 'vod-timestamps', only: [:update, :destroy] - - # Team Goals - resources :team_goals, path: 'team-goals' - end - end - - # Mount Sidekiq web UI in development - if Rails.env.development? - require 'sidekiq/web' - mount Sidekiq::Web => '/sidekiq' - end -end \ No newline at end of file +# frozen_string_literal: true + +Rails.application.routes.draw do + # Mount Rswag API documentation + mount Rswag::Ui::Engine => '/api-docs' + mount Rswag::Api::Engine => '/api-docs' + + # Health check endpoint + get 'up' => 'rails/health#show', as: :rails_health_check + + # API routes + namespace :api do + namespace :v1 do + # Constants (public) + get 'constants', to: 'constants#index' + + # Auth + scope :auth do + post 'register', to: 'auth#register' + post 'login', to: 'auth#login' + post 'refresh', to: 'auth#refresh' + post 'logout', to: 'auth#logout' + post 'forgot-password', to: 'auth#forgot_password' + post 'reset-password', to: 'auth#reset_password' + get 'me', to: 'auth#me' + end + + # Dashboard + resources :dashboard, only: [:index] do + collection do + get :stats + get :activities + get :schedule + end + end + + # Players + resources :players do + collection do + get :stats + post :import + post :bulk_sync + get :search_riot_id + end + member do + get :stats + get :matches + post :sync_from_riot + end + end + + # Riot Integration + scope :riot_integration, controller: 'riot_integration' do + get :sync_status + end + + # Riot Data (Data Dragon) + scope 'riot-data', controller: 'riot_data' do + get 'champions', to: 'riot_data#champions' + get 'champions/:champion_key', to: 'riot_data#champion_details' + get 'all-champions', to: 'riot_data#all_champions' + get 'items', to: 'riot_data#items' + get 'summoner-spells', to: 'riot_data#summoner_spells' + get 'version', to: 'riot_data#version' + post 'clear-cache', to: 'riot_data#clear_cache' + post 'update-cache', to: 'riot_data#update_cache' + end + + # Scouting + namespace :scouting do + resources :players do + member do + post :sync + end + end + get 'regions', to: 'regions#index' + resources :watchlist, only: %i[index create destroy] + end + + # Analytics + namespace :analytics do + get 'performance', to: 'performance#index' + get 'champions/:player_id', to: 'champions#show' + get 'kda-trend/:player_id', to: 'kda_trend#show' + get 'laning/:player_id', to: 'laning#show' + get 'teamfights/:player_id', to: 'teamfights#show' + get 'vision/:player_id', to: 'vision#show' + get 'team-comparison', to: 'team_comparison#index' + end + + # Matches + resources :matches do + collection do + post :import + end + member do + get :stats + end + end + + # Schedules + resources :schedules + + # VOD Reviews + resources :vod_reviews, path: 'vod-reviews' do + resources :timestamps, controller: 'vod_timestamps', only: %i[index create] + end + resources :vod_timestamps, path: 'vod-timestamps', only: %i[update destroy] + + # Team Goals + resources :team_goals, path: 'team-goals' + + # Scrims Module (Tier 2+) + namespace :scrims do + resources :scrims do + member do + post :add_game + end + collection do + get :calendar + get :analytics + end + end + + resources :opponent_teams, path: 'opponent-teams' do + member do + get :scrim_history, path: 'scrim-history' + end + end + end + + # Competitive Matches (Tier 1) + resources :competitive_matches, path: 'competitive-matches', only: %i[index show] + + # Competitive Module - PandaScore Integration + namespace :competitive do + # Pro Matches from PandaScore + resources :pro_matches, path: 'pro-matches', only: %i[index show] do + collection do + get :upcoming + get :past + post :refresh + post :import + end + end + + # Draft Comparison & Meta Analysis + post 'draft-comparison', to: 'draft_comparison#compare' + get 'meta/:role', to: 'draft_comparison#meta_by_role' + get 'composition-winrate', to: 'draft_comparison#composition_winrate' + get 'counters', to: 'draft_comparison#suggest_counters' + end + end + end + + # Mount Sidekiq web UI in development + if Rails.env.development? + require 'sidekiq/web' + mount Sidekiq::Web => '/sidekiq' + end +end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..c4f36b1 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,27 @@ +--- +# Sidekiq Configuration File + +:concurrency: 5 +:timeout: 25 +:verbose: true +:queues: + - default + - mailers + - critical + - high + - low + +# Sidekiq Scheduler Configuration +:schedule: + # Cleanup expired tokens daily at 2 AM + cleanup_expired_tokens: + cron: '0 2 * * *' + class: CleanupExpiredTokensJob + description: 'Clean up expired password reset tokens and blacklisted JWT tokens' + + # Additional scheduled jobs can be added here + # Example: + # sync_riot_data: + # every: '30m' + # class: SyncRiotDataJob + # description: 'Sync player data from Riot API' diff --git a/db/migrate/20241001000001_enable_uuid_extension.rb b/db/migrate/20241001000001_enable_uuid_extension.rb index 644f1b3..bd4a2c2 100644 --- a/db/migrate/20241001000001_enable_uuid_extension.rb +++ b/db/migrate/20241001000001_enable_uuid_extension.rb @@ -1,5 +1,7 @@ -class EnableUuidExtension < ActiveRecord::Migration[7.1] - def change - enable_extension 'pgcrypto' - end -end \ No newline at end of file +# frozen_string_literal: true + +class EnableUuidExtension < ActiveRecord::Migration[7.1] + def change + enable_extension 'pgcrypto' + end +end diff --git a/db/migrate/20241001000002_create_organizations.rb b/db/migrate/20241001000002_create_organizations.rb index 32a5c9d..59d2fd7 100644 --- a/db/migrate/20241001000002_create_organizations.rb +++ b/db/migrate/20241001000002_create_organizations.rb @@ -1,20 +1,22 @@ -class CreateOrganizations < ActiveRecord::Migration[7.1] - def change - create_table :organizations, id: :uuid do |t| - t.string :name, null: false - t.string :slug, null: false - t.string :region, null: false - t.string :tier - t.string :subscription_plan - t.string :subscription_status - t.string :logo_url - t.jsonb :settings, default: {} - - t.timestamps - end - - add_index :organizations, :slug, unique: true - add_index :organizations, :region - add_index :organizations, :subscription_plan - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateOrganizations < ActiveRecord::Migration[7.1] + def change + create_table :organizations, id: :uuid do |t| + t.string :name, null: false + t.string :slug, null: false + t.string :region, null: false + t.string :tier + t.string :subscription_plan + t.string :subscription_status + t.string :logo_url + t.jsonb :settings, default: {} + + t.timestamps + end + + add_index :organizations, :slug, unique: true + add_index :organizations, :region + add_index :organizations, :subscription_plan + end +end diff --git a/db/migrate/20241001000003_create_users.rb b/db/migrate/20241001000003_create_users.rb index e9ac593..67f62e8 100644 --- a/db/migrate/20241001000003_create_users.rb +++ b/db/migrate/20241001000003_create_users.rb @@ -1,22 +1,24 @@ -class CreateUsers < ActiveRecord::Migration[7.1] - def change - create_table :users, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - t.string :email, null: false - t.string :password_digest, null: false - t.string :full_name - t.string :role, null: false - t.string :avatar_url - t.string :timezone - t.string :language - t.boolean :notifications_enabled, default: true - t.jsonb :notification_preferences, default: {} - t.timestamp :last_login_at - - t.timestamps - end - - add_index :users, :email, unique: true - add_index :users, :role - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[7.1] + def change + create_table :users, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.string :email, null: false + t.string :password_digest, null: false + t.string :full_name + t.string :role, null: false + t.string :avatar_url + t.string :timezone + t.string :language + t.boolean :notifications_enabled, default: true + t.jsonb :notification_preferences, default: {} + t.timestamp :last_login_at + + t.timestamps + end + + add_index :users, :email, unique: true + add_index :users, :role + end +end diff --git a/db/migrate/20241001000004_create_players.rb b/db/migrate/20241001000004_create_players.rb index 276ad2e..75499b5 100644 --- a/db/migrate/20241001000004_create_players.rb +++ b/db/migrate/20241001000004_create_players.rb @@ -1,59 +1,61 @@ -class CreatePlayers < ActiveRecord::Migration[7.1] - def change - create_table :players, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - t.string :summoner_name, null: false - t.string :real_name - t.string :role, null: false - t.string :country - t.date :birth_date - t.string :status, default: 'active' - - # Riot Games Data - t.string :riot_puuid - t.string :riot_summoner_id - t.string :riot_account_id - t.integer :profile_icon_id - t.integer :summoner_level - - # Ranked Data - t.string :solo_queue_tier - t.string :solo_queue_rank - t.integer :solo_queue_lp - t.integer :solo_queue_wins - t.integer :solo_queue_losses - t.string :flex_queue_tier - t.string :flex_queue_rank - t.integer :flex_queue_lp - t.string :peak_tier - t.string :peak_rank - t.string :peak_season - - # Contract Info - t.date :contract_start_date - t.date :contract_end_date - t.decimal :salary, precision: 10, scale: 2 - t.integer :jersey_number - - # Additional Info - t.text :champion_pool, array: true, default: [] - t.string :preferred_role_secondary - t.text :playstyle_tags, array: true, default: [] - t.string :twitter_handle - t.string :twitch_channel - t.string :instagram_handle - t.text :notes - - # Metadata - t.jsonb :metadata, default: {} - t.timestamp :last_sync_at - - t.timestamps - end - - add_index :players, :riot_puuid, unique: true - add_index :players, :summoner_name - add_index :players, :status - add_index :players, :role - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreatePlayers < ActiveRecord::Migration[7.1] + def change + create_table :players, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.string :summoner_name, null: false + t.string :real_name + t.string :role, null: false + t.string :country + t.date :birth_date + t.string :status, default: 'active' + + # Riot Games Data + t.string :riot_puuid + t.string :riot_summoner_id + t.string :riot_account_id + t.integer :profile_icon_id + t.integer :summoner_level + + # Ranked Data + t.string :solo_queue_tier + t.string :solo_queue_rank + t.integer :solo_queue_lp + t.integer :solo_queue_wins + t.integer :solo_queue_losses + t.string :flex_queue_tier + t.string :flex_queue_rank + t.integer :flex_queue_lp + t.string :peak_tier + t.string :peak_rank + t.string :peak_season + + # Contract Info + t.date :contract_start_date + t.date :contract_end_date + t.decimal :salary, precision: 10, scale: 2 + t.integer :jersey_number + + # Additional Info + t.text :champion_pool, array: true, default: [] + t.string :preferred_role_secondary + t.text :playstyle_tags, array: true, default: [] + t.string :twitter_handle + t.string :twitch_channel + t.string :instagram_handle + t.text :notes + + # Metadata + t.jsonb :metadata, default: {} + t.timestamp :last_sync_at + + t.timestamps + end + + add_index :players, :riot_puuid, unique: true + add_index :players, :summoner_name + add_index :players, :status + add_index :players, :role + end +end diff --git a/db/migrate/20241001000005_create_matches.rb b/db/migrate/20241001000005_create_matches.rb index b7ab94c..01d01ab 100644 --- a/db/migrate/20241001000005_create_matches.rb +++ b/db/migrate/20241001000005_create_matches.rb @@ -1,53 +1,55 @@ -class CreateMatches < ActiveRecord::Migration[7.1] - def change - create_table :matches, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - t.string :match_type, null: false - t.string :riot_match_id - - # Game Info - t.string :game_version - t.timestamp :game_start - t.timestamp :game_end - t.integer :game_duration - - # Teams - t.string :our_side - t.string :opponent_name - t.string :opponent_tag - t.boolean :victory - - # Scores - t.integer :our_score - t.integer :opponent_score - t.integer :our_towers - t.integer :opponent_towers - t.integer :our_dragons - t.integer :opponent_dragons - t.integer :our_barons - t.integer :opponent_barons - t.integer :our_inhibitors - t.integer :opponent_inhibitors - - # Bans - t.text :our_bans, array: true, default: [] - t.text :opponent_bans, array: true, default: [] - - # Files - t.string :vod_url - t.string :replay_file_url - - # Organization - t.text :tags, array: true, default: [] - t.text :notes - t.jsonb :metadata, default: {} - - t.timestamps - end - - add_index :matches, :riot_match_id, unique: true - add_index :matches, :match_type - add_index :matches, :game_start - add_index :matches, :victory - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateMatches < ActiveRecord::Migration[7.1] + def change + create_table :matches, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.string :match_type, null: false + t.string :riot_match_id + + # Game Info + t.string :game_version + t.timestamp :game_start + t.timestamp :game_end + t.integer :game_duration + + # Teams + t.string :our_side + t.string :opponent_name + t.string :opponent_tag + t.boolean :victory + + # Scores + t.integer :our_score + t.integer :opponent_score + t.integer :our_towers + t.integer :opponent_towers + t.integer :our_dragons + t.integer :opponent_dragons + t.integer :our_barons + t.integer :opponent_barons + t.integer :our_inhibitors + t.integer :opponent_inhibitors + + # Bans + t.text :our_bans, array: true, default: [] + t.text :opponent_bans, array: true, default: [] + + # Files + t.string :vod_url + t.string :replay_file_url + + # Organization + t.text :tags, array: true, default: [] + t.text :notes + t.jsonb :metadata, default: {} + + t.timestamps + end + + add_index :matches, :riot_match_id, unique: true + add_index :matches, :match_type + add_index :matches, :game_start + add_index :matches, :victory + end +end diff --git a/db/migrate/20241001000006_create_player_match_stats.rb b/db/migrate/20241001000006_create_player_match_stats.rb index 0a2420a..1505463 100644 --- a/db/migrate/20241001000006_create_player_match_stats.rb +++ b/db/migrate/20241001000006_create_player_match_stats.rb @@ -1,72 +1,74 @@ -class CreatePlayerMatchStats < ActiveRecord::Migration[7.1] - def change - create_table :player_match_stats, id: :uuid do |t| - t.references :match, null: false, foreign_key: true, type: :uuid - t.references :player, null: false, foreign_key: true, type: :uuid - - # Champion & Position - t.string :champion, null: false - t.string :role - t.string :lane - - # KDA - t.integer :kills, default: 0 - t.integer :deaths, default: 0 - t.integer :assists, default: 0 - t.integer :double_kills, default: 0 - t.integer :triple_kills, default: 0 - t.integer :quadra_kills, default: 0 - t.integer :penta_kills, default: 0 - t.integer :largest_killing_spree - t.integer :largest_multi_kill - - # Farm - t.integer :cs, default: 0 - t.decimal :cs_per_min, precision: 5, scale: 2 - - # Gold - t.integer :gold_earned - t.decimal :gold_per_min, precision: 8, scale: 2 - t.decimal :gold_share, precision: 5, scale: 2 - - # Damage - t.integer :damage_dealt_champions - t.integer :damage_dealt_total - t.integer :damage_dealt_objectives - t.integer :damage_taken - t.integer :damage_mitigated - t.decimal :damage_share, precision: 5, scale: 2 - - # Vision - t.integer :vision_score - t.integer :wards_placed - t.integer :wards_destroyed - t.integer :control_wards_purchased - - # Combat - t.decimal :kill_participation, precision: 5, scale: 2 - t.boolean :first_blood, default: false - t.boolean :first_tower, default: false - - # Build - t.integer :items, array: true, default: [] - t.integer :item_build_order, array: true, default: [] - t.integer :trinket - t.string :summoner_spell_1 - t.string :summoner_spell_2 - t.string :primary_rune_tree - t.string :secondary_rune_tree - t.integer :runes, array: true, default: [] - - # Other - t.integer :healing_done - t.decimal :performance_score, precision: 5, scale: 2 - - t.jsonb :metadata, default: {} - t.timestamps - end - - add_index :player_match_stats, [:player_id, :match_id], unique: true - add_index :player_match_stats, :champion - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreatePlayerMatchStats < ActiveRecord::Migration[7.1] + def change + create_table :player_match_stats, id: :uuid do |t| + t.references :match, null: false, foreign_key: true, type: :uuid + t.references :player, null: false, foreign_key: true, type: :uuid + + # Champion & Position + t.string :champion, null: false + t.string :role + t.string :lane + + # KDA + t.integer :kills, default: 0 + t.integer :deaths, default: 0 + t.integer :assists, default: 0 + t.integer :double_kills, default: 0 + t.integer :triple_kills, default: 0 + t.integer :quadra_kills, default: 0 + t.integer :penta_kills, default: 0 + t.integer :largest_killing_spree + t.integer :largest_multi_kill + + # Farm + t.integer :cs, default: 0 + t.decimal :cs_per_min, precision: 5, scale: 2 + + # Gold + t.integer :gold_earned + t.decimal :gold_per_min, precision: 8, scale: 2 + t.decimal :gold_share, precision: 5, scale: 2 + + # Damage + t.integer :damage_dealt_champions + t.integer :damage_dealt_total + t.integer :damage_dealt_objectives + t.integer :damage_taken + t.integer :damage_mitigated + t.decimal :damage_share, precision: 5, scale: 2 + + # Vision + t.integer :vision_score + t.integer :wards_placed + t.integer :wards_destroyed + t.integer :control_wards_purchased + + # Combat + t.decimal :kill_participation, precision: 5, scale: 2 + t.boolean :first_blood, default: false + t.boolean :first_tower, default: false + + # Build + t.integer :items, array: true, default: [] + t.integer :item_build_order, array: true, default: [] + t.integer :trinket + t.string :summoner_spell_1 + t.string :summoner_spell_2 + t.string :primary_rune_tree + t.string :secondary_rune_tree + t.integer :runes, array: true, default: [] + + # Other + t.integer :healing_done + t.decimal :performance_score, precision: 5, scale: 2 + + t.jsonb :metadata, default: {} + t.timestamps + end + + add_index :player_match_stats, %i[player_id match_id], unique: true + add_index :player_match_stats, :champion + end +end diff --git a/db/migrate/20241001000007_create_champion_pools.rb b/db/migrate/20241001000007_create_champion_pools.rb index 815dd8e..0ee1ae2 100644 --- a/db/migrate/20241001000007_create_champion_pools.rb +++ b/db/migrate/20241001000007_create_champion_pools.rb @@ -1,32 +1,34 @@ -class CreateChampionPools < ActiveRecord::Migration[7.1] - def change - create_table :champion_pools, id: :uuid do |t| - t.references :player, null: false, foreign_key: true, type: :uuid - t.string :champion, null: false - - # Performance - t.integer :games_played, default: 0 - t.integer :games_won, default: 0 - t.integer :mastery_level, default: 1 - t.decimal :average_kda, precision: 5, scale: 2 - t.decimal :average_cs_per_min, precision: 5, scale: 2 - t.decimal :average_damage_share, precision: 5, scale: 2 - - # Status - t.boolean :is_comfort_pick, default: false - t.boolean :is_pocket_pick, default: false - t.boolean :is_learning, default: false - t.integer :priority, default: 5 - - # Metadata - t.timestamp :last_played - t.text :notes - - t.timestamps - end - - add_index :champion_pools, [:player_id, :champion], unique: true - add_index :champion_pools, :champion - add_index :champion_pools, :priority - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateChampionPools < ActiveRecord::Migration[7.1] + def change + create_table :champion_pools, id: :uuid do |t| + t.references :player, null: false, foreign_key: true, type: :uuid + t.string :champion, null: false + + # Performance + t.integer :games_played, default: 0 + t.integer :games_won, default: 0 + t.integer :mastery_level, default: 1 + t.decimal :average_kda, precision: 5, scale: 2 + t.decimal :average_cs_per_min, precision: 5, scale: 2 + t.decimal :average_damage_share, precision: 5, scale: 2 + + # Status + t.boolean :is_comfort_pick, default: false + t.boolean :is_pocket_pick, default: false + t.boolean :is_learning, default: false + t.integer :priority, default: 5 + + # Metadata + t.timestamp :last_played + t.text :notes + + t.timestamps + end + + add_index :champion_pools, %i[player_id champion], unique: true + add_index :champion_pools, :champion + add_index :champion_pools, :priority + end +end diff --git a/db/migrate/20241001000008_create_scouting_targets.rb b/db/migrate/20241001000008_create_scouting_targets.rb index 202aa2f..c032417 100644 --- a/db/migrate/20241001000008_create_scouting_targets.rb +++ b/db/migrate/20241001000008_create_scouting_targets.rb @@ -1,48 +1,50 @@ -class CreateScoutingTargets < ActiveRecord::Migration[7.1] - def change - create_table :scouting_targets, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - - # Player Info - t.string :summoner_name, null: false - t.string :region, null: false - t.string :riot_puuid - t.string :role, null: false - - # Current Rank - t.string :current_tier - t.string :current_rank - t.integer :current_lp - - # Performance - t.text :champion_pool, array: true, default: [] - t.string :playstyle - t.text :strengths, array: true, default: [] - t.text :weaknesses, array: true, default: [] - t.jsonb :recent_performance, default: {} - t.string :performance_trend - - # Contact - t.string :email - t.string :phone - t.string :discord_username - t.string :twitter_handle - - # Scouting - t.string :status, default: 'watching' - t.string :priority, default: 'medium' - t.references :added_by, foreign_key: { to_table: :users }, type: :uuid - t.references :assigned_to, foreign_key: { to_table: :users }, type: :uuid - t.timestamp :last_reviewed - t.text :notes - - t.jsonb :metadata, default: {} - t.timestamps - end - - add_index :scouting_targets, :riot_puuid - add_index :scouting_targets, :status - add_index :scouting_targets, :priority - add_index :scouting_targets, :role - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateScoutingTargets < ActiveRecord::Migration[7.1] + def change + create_table :scouting_targets, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + + # Player Info + t.string :summoner_name, null: false + t.string :region, null: false + t.string :riot_puuid + t.string :role, null: false + + # Current Rank + t.string :current_tier + t.string :current_rank + t.integer :current_lp + + # Performance + t.text :champion_pool, array: true, default: [] + t.string :playstyle + t.text :strengths, array: true, default: [] + t.text :weaknesses, array: true, default: [] + t.jsonb :recent_performance, default: {} + t.string :performance_trend + + # Contact + t.string :email + t.string :phone + t.string :discord_username + t.string :twitter_handle + + # Scouting + t.string :status, default: 'watching' + t.string :priority, default: 'medium' + t.references :added_by, foreign_key: { to_table: :users }, type: :uuid + t.references :assigned_to, foreign_key: { to_table: :users }, type: :uuid + t.timestamp :last_reviewed + t.text :notes + + t.jsonb :metadata, default: {} + t.timestamps + end + + add_index :scouting_targets, :riot_puuid + add_index :scouting_targets, :status + add_index :scouting_targets, :priority + add_index :scouting_targets, :role + end +end diff --git a/db/migrate/20241001000009_create_schedules.rb b/db/migrate/20241001000009_create_schedules.rb index 923363c..e1be57e 100644 --- a/db/migrate/20241001000009_create_schedules.rb +++ b/db/migrate/20241001000009_create_schedules.rb @@ -1,51 +1,53 @@ -class CreateSchedules < ActiveRecord::Migration[7.1] - def change - create_table :schedules, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - - # Event Info - t.string :title, null: false - t.text :description - t.string :event_type, null: false - - # Time - t.timestamp :start_time, null: false - t.timestamp :end_time, null: false - t.string :timezone - t.boolean :all_day, default: false - - # Match Info - t.references :match, foreign_key: true, type: :uuid - t.string :opponent_name - t.string :location - t.string :meeting_url - - # Participants - t.uuid :required_players, array: true, default: [] - t.uuid :optional_players, array: true, default: [] - - # Organization - t.string :status, default: 'scheduled' - t.text :tags, array: true, default: [] - t.string :color - - # Recurrence - t.boolean :is_recurring, default: false - t.string :recurrence_rule - t.date :recurrence_end_date - - # Reminders - t.integer :reminder_minutes, array: true, default: [] - - # Metadata - t.references :created_by, foreign_key: { to_table: :users }, type: :uuid - t.jsonb :metadata, default: {} - - t.timestamps - end - - add_index :schedules, :start_time - add_index :schedules, :event_type - add_index :schedules, :status - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateSchedules < ActiveRecord::Migration[7.1] + def change + create_table :schedules, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + + # Event Info + t.string :title, null: false + t.text :description + t.string :event_type, null: false + + # Time + t.timestamp :start_time, null: false + t.timestamp :end_time, null: false + t.string :timezone + t.boolean :all_day, default: false + + # Match Info + t.references :match, foreign_key: true, type: :uuid + t.string :opponent_name + t.string :location + t.string :meeting_url + + # Participants + t.uuid :required_players, array: true, default: [] + t.uuid :optional_players, array: true, default: [] + + # Organization + t.string :status, default: 'scheduled' + t.text :tags, array: true, default: [] + t.string :color + + # Recurrence + t.boolean :is_recurring, default: false + t.string :recurrence_rule + t.date :recurrence_end_date + + # Reminders + t.integer :reminder_minutes, array: true, default: [] + + # Metadata + t.references :created_by, foreign_key: { to_table: :users }, type: :uuid + t.jsonb :metadata, default: {} + + t.timestamps + end + + add_index :schedules, :start_time + add_index :schedules, :event_type + add_index :schedules, :status + end +end diff --git a/db/migrate/20241001000010_create_vod_reviews.rb b/db/migrate/20241001000010_create_vod_reviews.rb index 3ad4928..4957c7b 100644 --- a/db/migrate/20241001000010_create_vod_reviews.rb +++ b/db/migrate/20241001000010_create_vod_reviews.rb @@ -1,35 +1,37 @@ -class CreateVodReviews < ActiveRecord::Migration[7.1] - def change - create_table :vod_reviews, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - t.references :match, foreign_key: true, type: :uuid - - # Review Info - t.string :title, null: false - t.text :description - t.string :review_type - t.timestamp :review_date - - # Video - t.string :video_url, null: false - t.string :thumbnail_url - t.integer :duration - - # Sharing - t.boolean :is_public, default: false - t.string :share_link - t.uuid :shared_with_players, array: true, default: [] - - # Organization - t.references :reviewer, foreign_key: { to_table: :users }, type: :uuid - t.string :status, default: 'draft' - t.text :tags, array: true, default: [] - - t.jsonb :metadata, default: {} - t.timestamps - end - - add_index :vod_reviews, :status - add_index :vod_reviews, :share_link, unique: true - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateVodReviews < ActiveRecord::Migration[7.1] + def change + create_table :vod_reviews, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :match, foreign_key: true, type: :uuid + + # Review Info + t.string :title, null: false + t.text :description + t.string :review_type + t.timestamp :review_date + + # Video + t.string :video_url, null: false + t.string :thumbnail_url + t.integer :duration + + # Sharing + t.boolean :is_public, default: false + t.string :share_link + t.uuid :shared_with_players, array: true, default: [] + + # Organization + t.references :reviewer, foreign_key: { to_table: :users }, type: :uuid + t.string :status, default: 'draft' + t.text :tags, array: true, default: [] + + t.jsonb :metadata, default: {} + t.timestamps + end + + add_index :vod_reviews, :status + add_index :vod_reviews, :share_link, unique: true + end +end diff --git a/db/migrate/20241001000011_create_vod_timestamps.rb b/db/migrate/20241001000011_create_vod_timestamps.rb index 42947c9..91dbb50 100644 --- a/db/migrate/20241001000011_create_vod_timestamps.rb +++ b/db/migrate/20241001000011_create_vod_timestamps.rb @@ -1,28 +1,30 @@ -class CreateVodTimestamps < ActiveRecord::Migration[7.1] - def change - create_table :vod_timestamps, id: :uuid do |t| - t.references :vod_review, null: false, foreign_key: true, type: :uuid - - # Timestamp Info - t.integer :timestamp_seconds, null: false - t.string :title, null: false - t.text :description - t.string :category - t.string :importance, default: 'normal' - - # Target - t.string :target_type - t.references :target_player, foreign_key: { to_table: :players }, type: :uuid - - # Metadata - t.references :created_by, foreign_key: { to_table: :users }, type: :uuid - t.jsonb :metadata, default: {} - - t.timestamps - end - - add_index :vod_timestamps, :timestamp_seconds - add_index :vod_timestamps, :category - add_index :vod_timestamps, :importance - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateVodTimestamps < ActiveRecord::Migration[7.1] + def change + create_table :vod_timestamps, id: :uuid do |t| + t.references :vod_review, null: false, foreign_key: true, type: :uuid + + # Timestamp Info + t.integer :timestamp_seconds, null: false + t.string :title, null: false + t.text :description + t.string :category + t.string :importance, default: 'normal' + + # Target + t.string :target_type + t.references :target_player, foreign_key: { to_table: :players }, type: :uuid + + # Metadata + t.references :created_by, foreign_key: { to_table: :users }, type: :uuid + t.jsonb :metadata, default: {} + + t.timestamps + end + + add_index :vod_timestamps, :timestamp_seconds + add_index :vod_timestamps, :category + add_index :vod_timestamps, :importance + end +end diff --git a/db/migrate/20241001000012_create_team_goals.rb b/db/migrate/20241001000012_create_team_goals.rb index 47eae50..2b6496b 100644 --- a/db/migrate/20241001000012_create_team_goals.rb +++ b/db/migrate/20241001000012_create_team_goals.rb @@ -1,36 +1,38 @@ -class CreateTeamGoals < ActiveRecord::Migration[7.1] - def change - create_table :team_goals, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - t.references :player, foreign_key: true, type: :uuid - - # Goal Info - t.string :title, null: false - t.text :description - t.string :category - t.string :metric_type - - # Targets - t.decimal :target_value, precision: 10, scale: 2 - t.decimal :current_value, precision: 10, scale: 2 - - # Timeline - t.date :start_date, null: false - t.date :end_date, null: false - - # Status - t.string :status, default: 'active' - t.integer :progress, default: 0 - - # Assignment - t.references :assigned_to, foreign_key: { to_table: :users }, type: :uuid - t.references :created_by, foreign_key: { to_table: :users }, type: :uuid - - t.jsonb :metadata, default: {} - t.timestamps - end - - add_index :team_goals, :status - add_index :team_goals, :category - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateTeamGoals < ActiveRecord::Migration[7.1] + def change + create_table :team_goals, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :player, foreign_key: true, type: :uuid + + # Goal Info + t.string :title, null: false + t.text :description + t.string :category + t.string :metric_type + + # Targets + t.decimal :target_value, precision: 10, scale: 2 + t.decimal :current_value, precision: 10, scale: 2 + + # Timeline + t.date :start_date, null: false + t.date :end_date, null: false + + # Status + t.string :status, default: 'active' + t.integer :progress, default: 0 + + # Assignment + t.references :assigned_to, foreign_key: { to_table: :users }, type: :uuid + t.references :created_by, foreign_key: { to_table: :users }, type: :uuid + + t.jsonb :metadata, default: {} + t.timestamps + end + + add_index :team_goals, :status + add_index :team_goals, :category + end +end diff --git a/db/migrate/20241001000013_create_audit_logs.rb b/db/migrate/20241001000013_create_audit_logs.rb index 0d65bf0..b3f66d9 100644 --- a/db/migrate/20241001000013_create_audit_logs.rb +++ b/db/migrate/20241001000013_create_audit_logs.rb @@ -1,28 +1,30 @@ -class CreateAuditLogs < ActiveRecord::Migration[7.1] - def change - create_table :audit_logs, id: :uuid do |t| - t.references :organization, null: false, foreign_key: true, type: :uuid - t.references :user, foreign_key: true, type: :uuid - - # Action Info - t.string :action, null: false - t.string :entity_type, null: false - t.uuid :entity_id - - # Changes - t.jsonb :old_values - t.jsonb :new_values - - # Request Info - t.inet :ip_address - t.text :user_agent - - t.timestamps - end - - add_index :audit_logs, :entity_type - add_index :audit_logs, :entity_id - add_index :audit_logs, :created_at - add_index :audit_logs, [:entity_type, :entity_id] - end -end \ No newline at end of file +# frozen_string_literal: true + +class CreateAuditLogs < ActiveRecord::Migration[7.1] + def change + create_table :audit_logs, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :user, foreign_key: true, type: :uuid + + # Action Info + t.string :action, null: false + t.string :entity_type, null: false + t.uuid :entity_id + + # Changes + t.jsonb :old_values + t.jsonb :new_values + + # Request Info + t.inet :ip_address + t.text :user_agent + + t.timestamps + end + + add_index :audit_logs, :entity_type + add_index :audit_logs, :entity_id + add_index :audit_logs, :created_at + add_index :audit_logs, %i[entity_type entity_id] + end +end diff --git a/db/migrate/20241001000014_create_notifications.rb b/db/migrate/20241001000014_create_notifications.rb index c4af185..ac36881 100644 --- a/db/migrate/20241001000014_create_notifications.rb +++ b/db/migrate/20241001000014_create_notifications.rb @@ -1,40 +1,42 @@ -class CreateNotifications < ActiveRecord::Migration[7.1] - def change - create_table :notifications, id: :uuid do |t| - t.references :user, type: :uuid, null: false, foreign_key: true - - t.string :title, limit: 200, null: false - t.text :message, null: false - t.string :type, null: false - - # Linking - t.text :link_url - t.string :link_type, limit: 20 - t.uuid :link_id - - # Status - t.boolean :is_read, default: false - t.timestamp :read_at - - # Delivery channels - t.text :channels, array: true, default: ['in_app'] - t.boolean :email_sent, default: false - t.boolean :discord_sent, default: false - - t.jsonb :metadata, default: {} - - t.timestamps null: false - end - - add_index :notifications, :user_id - add_index :notifications, :is_read - add_index :notifications, :created_at, order: { created_at: :desc } - - # Add check constraint for notification type - execute <<-SQL - ALTER TABLE notifications - ADD CONSTRAINT notifications_type_check - CHECK (type IN ('info', 'success', 'warning', 'error', 'match', 'schedule', 'system')); - SQL - end -end +# frozen_string_literal: true + +class CreateNotifications < ActiveRecord::Migration[7.1] + def change + create_table :notifications, id: :uuid do |t| + t.references :user, type: :uuid, null: false, foreign_key: true + + t.string :title, limit: 200, null: false + t.text :message, null: false + t.string :type, null: false + + # Linking + t.text :link_url + t.string :link_type, limit: 20 + t.uuid :link_id + + # Status + t.boolean :is_read, default: false + t.timestamp :read_at + + # Delivery channels + t.text :channels, array: true, default: ['in_app'] + t.boolean :email_sent, default: false + t.boolean :discord_sent, default: false + + t.jsonb :metadata, default: {} + + t.timestamps null: false + end + + add_index :notifications, :user_id + add_index :notifications, :is_read + add_index :notifications, :created_at, order: { created_at: :desc } + + # Add check constraint for notification type + execute <<-SQL + ALTER TABLE notifications + ADD CONSTRAINT notifications_type_check + CHECK (type IN ('info', 'success', 'warning', 'error', 'match', 'schedule', 'system')); + SQL + end +end diff --git a/db/migrate/20251008_add_performance_indexes.rb b/db/migrate/20251008_add_performance_indexes.rb deleted file mode 100644 index 7a135c6..0000000 --- a/db/migrate/20251008_add_performance_indexes.rb +++ /dev/null @@ -1,14 +0,0 @@ -class AddPerformanceIndexes < ActiveRecord::Migration[7.1] - def change - add_index :matches, [:organization_id, :victory], name: 'index_matches_on_org_and_victory' - add_index :matches, [:organization_id, :game_start], name: 'index_matches_on_org_and_game_start' - add_index :player_match_stats, :match_id, name: 'index_player_match_stats_on_match' - add_index :schedules, [:organization_id, :start_time, :event_type], name: 'index_schedules_on_org_time_type' - add_index :team_goals, [:organization_id, :status], name: 'index_team_goals_on_org_and_status' - - add_index :players, [:organization_id, :status], name: 'index_players_on_org_and_status' - add_index :players, [:organization_id, :role], name: 'index_players_on_org_and_role' - - add_index :audit_logs, [:organization_id, :created_at], name: 'index_audit_logs_on_org_and_created' - end -end diff --git a/db/migrate/20251011231210_add_sync_status_to_players.rb b/db/migrate/20251011231210_add_sync_status_to_players.rb index 1195970..2cd7d30 100644 --- a/db/migrate/20251011231210_add_sync_status_to_players.rb +++ b/db/migrate/20251011231210_add_sync_status_to_players.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddSyncStatusToPlayers < ActiveRecord::Migration[7.2] def change add_column :players, :sync_status, :string diff --git a/db/migrate/20251012022035_add_age_to_scouting_targets.rb b/db/migrate/20251012022035_add_age_to_scouting_targets.rb index aa6d773..70ef34c 100644 --- a/db/migrate/20251012022035_add_age_to_scouting_targets.rb +++ b/db/migrate/20251012022035_add_age_to_scouting_targets.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddAgeToScoutingTargets < ActiveRecord::Migration[7.2] def change add_column :scouting_targets, :age, :integer diff --git a/db/migrate/20251012033201_add_region_to_players.rb b/db/migrate/20251012033201_add_region_to_players.rb index 0b1bb52..9932258 100644 --- a/db/migrate/20251012033201_add_region_to_players.rb +++ b/db/migrate/20251012033201_add_region_to_players.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddRegionToPlayers < ActiveRecord::Migration[7.2] def change add_column :players, :region, :string diff --git a/db/migrate/20251015204944_create_password_reset_tokens.rb b/db/migrate/20251015204944_create_password_reset_tokens.rb new file mode 100644 index 0000000..a5eaad1 --- /dev/null +++ b/db/migrate/20251015204944_create_password_reset_tokens.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreatePasswordResetTokens < ActiveRecord::Migration[7.2] + def change + create_table :password_reset_tokens, id: :uuid do |t| + t.references :user, type: :uuid, null: false, foreign_key: true + t.string :token, null: false + t.string :ip_address + t.string :user_agent + t.datetime :expires_at, null: false + t.datetime :used_at + t.timestamps + end + + add_index :password_reset_tokens, :token, unique: true + add_index :password_reset_tokens, :expires_at + add_index :password_reset_tokens, %i[user_id used_at] + end +end diff --git a/db/migrate/20251015204948_create_token_blacklists.rb b/db/migrate/20251015204948_create_token_blacklists.rb new file mode 100644 index 0000000..b1ade91 --- /dev/null +++ b/db/migrate/20251015204948_create_token_blacklists.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTokenBlacklists < ActiveRecord::Migration[7.2] + def change + create_table :token_blacklists, id: :uuid do |t| + t.string :jti, null: false + t.datetime :expires_at, null: false + t.timestamps + end + + add_index :token_blacklists, :jti, unique: true + add_index :token_blacklists, :expires_at + end +end diff --git a/db/migrate/20251016000001_add_performance_indexes.rb b/db/migrate/20251016000001_add_performance_indexes.rb new file mode 100644 index 0000000..050a4d7 --- /dev/null +++ b/db/migrate/20251016000001_add_performance_indexes.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddPerformanceIndexes < ActiveRecord::Migration[7.1] + def change + add_index :matches, %i[organization_id victory], name: 'index_matches_on_org_and_victory' + add_index :matches, %i[organization_id game_start], name: 'index_matches_on_org_and_game_start' + add_index :player_match_stats, :match_id, name: 'index_player_match_stats_on_match' + add_index :schedules, %i[organization_id start_time event_type], name: 'index_schedules_on_org_time_type' + add_index :team_goals, %i[organization_id status], name: 'index_team_goals_on_org_and_status' + + add_index :players, %i[organization_id status], name: 'index_players_on_org_and_status' + add_index :players, %i[organization_id role], name: 'index_players_on_org_and_role' + + add_index :audit_logs, %i[organization_id created_at], name: 'index_audit_logs_on_org_and_created' + end +end diff --git a/db/migrate/20251017194235_create_scrims.rb b/db/migrate/20251017194235_create_scrims.rb new file mode 100644 index 0000000..a1154ff --- /dev/null +++ b/db/migrate/20251017194235_create_scrims.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class CreateScrims < ActiveRecord::Migration[7.2] + def change + create_table :scrims, id: :uuid do |t| + t.uuid :organization_id, null: false + t.uuid :match_id + t.uuid :opponent_team_id + + # Scrim metadata + t.datetime :scheduled_at + t.string :scrim_type # practice, vod_review, tournament_prep + t.string :focus_area # draft, macro, teamfight, laning, etc + t.text :pre_game_notes + t.text :post_game_notes + + # Privacy & tracking + t.boolean :is_confidential, default: true + t.string :visibility # internal_only, coaching_staff, full_team + + # Results tracking + t.integer :games_planned + t.integer :games_completed + t.jsonb :game_results, default: [] + + # Performance goals + t.jsonb :objectives, default: {} # What we wanted to practice + t.jsonb :outcomes, default: {} # What we achieved + + t.timestamps + end + + add_index :scrims, :organization_id + add_index :scrims, :opponent_team_id + add_index :scrims, :match_id + add_index :scrims, :scheduled_at + add_index :scrims, %i[organization_id scheduled_at], name: 'idx_scrims_org_scheduled' + add_index :scrims, :scrim_type + + add_foreign_key :scrims, :organizations + add_foreign_key :scrims, :matches + end +end diff --git a/db/migrate/20251017194716_create_opponent_teams.rb b/db/migrate/20251017194716_create_opponent_teams.rb new file mode 100644 index 0000000..d53df68 --- /dev/null +++ b/db/migrate/20251017194716_create_opponent_teams.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class CreateOpponentTeams < ActiveRecord::Migration[7.2] + def change + create_table :opponent_teams, id: :uuid do |t| + t.string :name, null: false + t.string :tag + t.string :region + t.string :tier # tier_1, tier_2, tier_3 + t.string :league # CBLOL, LCS, LCK, etc + + # Team info + t.string :logo_url + t.text :known_players, array: true, default: [] + t.jsonb :recent_performance, default: {} + + # Scrim history + t.integer :total_scrims, default: 0 + t.integer :scrims_won, default: 0 + t.integer :scrims_lost, default: 0 + + # Strategic notes + t.text :playstyle_notes + t.text :strengths, array: true, default: [] + t.text :weaknesses, array: true, default: [] + t.jsonb :preferred_champions, default: {} # By role + + # Contact + t.string :contact_email + t.string :discord_server + + t.timestamps + end + + add_index :opponent_teams, :name + add_index :opponent_teams, :tier + add_index :opponent_teams, :region + add_index :opponent_teams, :league + end +end diff --git a/db/migrate/20251017194738_create_competitive_matches.rb b/db/migrate/20251017194738_create_competitive_matches.rb new file mode 100644 index 0000000..8d4f0be --- /dev/null +++ b/db/migrate/20251017194738_create_competitive_matches.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class CreateCompetitiveMatches < ActiveRecord::Migration[7.2] + def change + create_table :competitive_matches, id: :uuid do |t| + t.uuid :organization_id, null: false + t.string :tournament_name, null: false + t.string :tournament_stage # Groups, Playoffs, Finals, etc + t.string :tournament_region # CBLOL, LCS, Worlds, MSI, etc + + # Match data + t.string :external_match_id # PandaScore/Leaguepedia ID + t.datetime :match_date + t.string :match_format # BO1, BO3, BO5 + t.integer :game_number # Qual game do BO (1, 2, 3) + + # Teams + t.string :our_team_name + t.string :opponent_team_name + t.uuid :opponent_team_id + + # Results + t.boolean :victory + t.string :series_score # "2-1", "3-0", etc + + # Draft data (CRUCIAL para análise) + t.jsonb :our_bans, default: [] # [{champion: "Aatrox", order: 1}, ...] + t.jsonb :opponent_bans, default: [] + t.jsonb :our_picks, default: [] # [{champion: "Lee Sin", role: "jungle", order: 1}, ...] + t.jsonb :opponent_picks, default: [] + t.string :side # blue/red + + # In-game stats + t.uuid :match_id # Link para Match model existente (se tivermos replay) + t.jsonb :game_stats, default: {} + + # Meta context + t.string :patch_version + t.text :meta_champions, array: true, default: [] + + # External links + t.string :vod_url + t.string :external_stats_url + + t.timestamps + end + + add_index :competitive_matches, :organization_id + add_index :competitive_matches, %i[organization_id tournament_name], name: 'idx_comp_matches_org_tournament' + add_index :competitive_matches, %i[tournament_region match_date], name: 'idx_comp_matches_region_date' + add_index :competitive_matches, :external_match_id, unique: true + add_index :competitive_matches, :patch_version + add_index :competitive_matches, :match_date + add_index :competitive_matches, :opponent_team_id + + add_foreign_key :competitive_matches, :organizations + add_foreign_key :competitive_matches, :opponent_teams + add_foreign_key :competitive_matches, :matches + end +end diff --git a/db/migrate/20251017194806_add_opponent_team_foreign_key_to_scrims.rb b/db/migrate/20251017194806_add_opponent_team_foreign_key_to_scrims.rb new file mode 100644 index 0000000..d73b211 --- /dev/null +++ b/db/migrate/20251017194806_add_opponent_team_foreign_key_to_scrims.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOpponentTeamForeignKeyToScrims < ActiveRecord::Migration[7.2] + def change + add_foreign_key :scrims, :opponent_teams + end +end diff --git a/db/migrate/20251025204605_add_avatar_url_to_players.rb b/db/migrate/20251025204605_add_avatar_url_to_players.rb new file mode 100644 index 0000000..7cc1dc9 --- /dev/null +++ b/db/migrate/20251025204605_add_avatar_url_to_players.rb @@ -0,0 +1,5 @@ +class AddAvatarUrlToPlayers < ActiveRecord::Migration[7.2] + def change + add_column :players, :avatar_url, :string + end +end diff --git a/db/migrate/20251025204606_add_kick_url_to_players.rb b/db/migrate/20251025204606_add_kick_url_to_players.rb new file mode 100644 index 0000000..44ee446 --- /dev/null +++ b/db/migrate/20251025204606_add_kick_url_to_players.rb @@ -0,0 +1,5 @@ +class AddKickUrlToPlayers < ActiveRecord::Migration[7.2] + def change + add_column :players, :kick_url, :string + end +end diff --git a/db/migrate/20251026030559_add_professional_name_to_players.rb b/db/migrate/20251026030559_add_professional_name_to_players.rb new file mode 100644 index 0000000..d9e5ff9 --- /dev/null +++ b/db/migrate/20251026030559_add_professional_name_to_players.rb @@ -0,0 +1,6 @@ +class AddProfessionalNameToPlayers < ActiveRecord::Migration[7.2] + def change + add_column :players, :professional_name, :string, comment: 'Professional/competitive IGN used in tournaments (e.g., "Titan" for paiN Gaming)' + add_index :players, :professional_name + end +end diff --git a/db/schema.rb b/db/schema.rb index 3544255..68bfe12 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_10_12_033201) do +ActiveRecord::Schema[7.2].define(version: 2025_10_26_233429) do create_schema "auth" create_schema "extensions" create_schema "graphql" @@ -73,6 +73,42 @@ t.index ["priority"], name: "index_champion_pools_on_priority" end + create_table "competitive_matches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "organization_id", null: false + t.string "tournament_name", null: false + t.string "tournament_stage" + t.string "tournament_region" + t.string "external_match_id" + t.datetime "match_date" + t.string "match_format" + t.integer "game_number" + t.string "our_team_name" + t.string "opponent_team_name" + t.uuid "opponent_team_id" + t.boolean "victory" + t.string "series_score" + t.jsonb "our_bans", default: [] + t.jsonb "opponent_bans", default: [] + t.jsonb "our_picks", default: [] + t.jsonb "opponent_picks", default: [] + t.string "side" + t.uuid "match_id" + t.jsonb "game_stats", default: {} + t.string "patch_version" + t.text "meta_champions", default: [], array: true + t.string "vod_url" + t.string "external_stats_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_match_id"], name: "index_competitive_matches_on_external_match_id", unique: true + t.index ["match_date"], name: "index_competitive_matches_on_match_date" + t.index ["opponent_team_id"], name: "index_competitive_matches_on_opponent_team_id" + t.index ["organization_id", "tournament_name"], name: "idx_comp_matches_org_tournament" + t.index ["organization_id"], name: "index_competitive_matches_on_organization_id" + t.index ["patch_version"], name: "index_competitive_matches_on_patch_version" + t.index ["tournament_region", "match_date"], name: "idx_comp_matches_region_date" + end + create_table "matches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "organization_id", null: false t.string "match_type", null: false @@ -115,6 +151,32 @@ t.index ["victory"], name: "index_matches_on_victory" end + create_table "opponent_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", null: false + t.string "tag" + t.string "region" + t.string "tier" + t.string "league" + t.string "logo_url" + t.text "known_players", default: [], array: true + t.jsonb "recent_performance", default: {} + t.integer "total_scrims", default: 0 + t.integer "scrims_won", default: 0 + t.integer "scrims_lost", default: 0 + t.text "playstyle_notes" + t.text "strengths", default: [], array: true + t.text "weaknesses", default: [], array: true + t.jsonb "preferred_champions", default: {} + t.string "contact_email" + t.string "discord_server" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["league"], name: "index_opponent_teams_on_league" + t.index ["name"], name: "index_opponent_teams_on_name" + t.index ["region"], name: "index_opponent_teams_on_region" + t.index ["tier"], name: "index_opponent_teams_on_tier" + end + create_table "organizations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "slug", null: false @@ -131,6 +193,21 @@ t.index ["subscription_plan"], name: "index_organizations_on_subscription_plan" end + create_table "password_reset_tokens", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "token", null: false + t.string "ip_address" + t.string "user_agent" + t.datetime "expires_at", null: false + t.datetime "used_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_password_reset_tokens_on_expires_at" + t.index ["token"], name: "index_password_reset_tokens_on_token", unique: true + t.index ["user_id", "used_at"], name: "index_password_reset_tokens_on_user_id_and_used_at" + t.index ["user_id"], name: "index_password_reset_tokens_on_user_id" + end + create_table "player_match_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "match_id", null: false t.uuid "player_id", null: false @@ -226,10 +303,14 @@ t.datetime "updated_at", null: false t.string "sync_status" t.string "region" + t.string "avatar_url" + t.string "kick_url" + t.string "professional_name", comment: "Professional/competitive IGN used in tournaments (e.g., \"Titan\" for paiN Gaming)" t.index ["organization_id", "role"], name: "index_players_on_org_and_role" t.index ["organization_id", "status"], name: "idx_players_org_status" t.index ["organization_id", "status"], name: "index_players_on_org_and_status" t.index ["organization_id"], name: "index_players_on_organization_id" + t.index ["professional_name"], name: "index_players_on_professional_name" t.index ["riot_puuid"], name: "index_players_on_riot_puuid", unique: true t.index ["role"], name: "index_players_on_role" t.index ["status"], name: "index_players_on_status" @@ -262,12 +343,14 @@ t.jsonb "metadata", default: {} t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "scrim_id" t.index ["created_by_id"], name: "index_schedules_on_created_by_id" t.index ["event_type"], name: "index_schedules_on_event_type" t.index ["match_id"], name: "index_schedules_on_match_id" t.index ["organization_id", "start_time", "event_type"], name: "index_schedules_on_org_time_type" t.index ["organization_id", "start_time"], name: "idx_schedules_org_time" t.index ["organization_id"], name: "index_schedules_on_organization_id" + t.index ["scrim_id"], name: "index_schedules_on_scrim_id" t.index ["start_time"], name: "index_schedules_on_start_time" t.index ["status"], name: "index_schedules_on_status" end @@ -310,6 +393,32 @@ t.index ["status"], name: "index_scouting_targets_on_status" end + create_table "scrims", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "organization_id", null: false + t.uuid "match_id" + t.uuid "opponent_team_id" + t.datetime "scheduled_at" + t.string "scrim_type" + t.string "focus_area" + t.text "pre_game_notes" + t.text "post_game_notes" + t.boolean "is_confidential", default: true + t.string "visibility" + t.integer "games_planned" + t.integer "games_completed" + t.jsonb "game_results", default: [] + t.jsonb "objectives", default: {} + t.jsonb "outcomes", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["match_id"], name: "index_scrims_on_match_id" + t.index ["opponent_team_id"], name: "index_scrims_on_opponent_team_id" + t.index ["organization_id", "scheduled_at"], name: "idx_scrims_org_scheduled" + t.index ["organization_id"], name: "index_scrims_on_organization_id" + t.index ["scheduled_at"], name: "index_scrims_on_scheduled_at" + t.index ["scrim_type"], name: "index_scrims_on_scrim_type" + end + create_table "team_goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "organization_id", null: false t.uuid "player_id" @@ -338,6 +447,15 @@ t.index ["status"], name: "index_team_goals_on_status" end + create_table "token_blacklists", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "jti", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_token_blacklists_on_expires_at" + t.index ["jti"], name: "index_token_blacklists_on_jti", unique: true + end + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "organization_id", null: false t.string "email", null: false @@ -409,16 +527,24 @@ add_foreign_key "audit_logs", "organizations" add_foreign_key "audit_logs", "users" add_foreign_key "champion_pools", "players" + add_foreign_key "competitive_matches", "matches" + add_foreign_key "competitive_matches", "opponent_teams" + add_foreign_key "competitive_matches", "organizations" add_foreign_key "matches", "organizations" + add_foreign_key "password_reset_tokens", "users" add_foreign_key "player_match_stats", "matches" add_foreign_key "player_match_stats", "players" add_foreign_key "players", "organizations" add_foreign_key "schedules", "matches" add_foreign_key "schedules", "organizations" + add_foreign_key "schedules", "scrims", on_delete: :cascade add_foreign_key "schedules", "users", column: "created_by_id" add_foreign_key "scouting_targets", "organizations" add_foreign_key "scouting_targets", "users", column: "added_by_id" add_foreign_key "scouting_targets", "users", column: "assigned_to_id" + add_foreign_key "scrims", "matches" + add_foreign_key "scrims", "opponent_teams" + add_foreign_key "scrims", "organizations" add_foreign_key "team_goals", "organizations" add_foreign_key "team_goals", "players" add_foreign_key "team_goals", "users", column: "assigned_to_id" diff --git a/db/seeds.rb b/db/seeds.rb index 57398aa..bc48aad 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,354 +1,120 @@ -# Seeds file for ProStaff API -# This file should contain all the record creation needed to seed the database with its default values. - -puts "🌱 Seeding database..." - -# Create sample organization -org = Organization.find_or_create_by!(slug: 'team-alpha') do |organization| - organization.name = 'Team Alpha' - organization.region = 'BR' - # Skip tier and subscription for now - will add via Supabase migrations -end - -puts "✅ Created organization: #{org.name}" - -# Create admin user -admin = User.find_or_create_by!(email: 'admin@teamalpha.gg') do |user| - user.organization = org - user.password = 'password123' - user.full_name = 'Admin User' - user.role = 'owner' - user.timezone = 'America/Sao_Paulo' - user.language = 'pt-BR' -end - -puts "✅ Created admin user: #{admin.email}" - -# Create coach user -coach = User.find_or_create_by!(email: 'coach@teamalpha.gg') do |user| - user.organization = org - user.password = 'password123' - user.full_name = 'Head Coach' - user.role = 'coach' - user.timezone = 'America/Sao_Paulo' - user.language = 'pt-BR' -end - -puts "✅ Created coach user: #{coach.email}" - -# Create analyst user -analyst = User.find_or_create_by!(email: 'analyst@teamalpha.gg') do |user| - user.organization = org - user.password = 'password123' - user.full_name = 'Performance Analyst' - user.role = 'analyst' - user.timezone = 'America/Sao_Paulo' - user.language = 'pt-BR' -end - -puts "✅ Created analyst user: #{analyst.email}" - -# Create sample players -players_data = [ - { - summoner_name: 'AlphaTop', - real_name: 'João Silva', - role: 'top', - country: 'BR', - birth_date: Date.parse('2001-03-15'), - solo_queue_tier: 'MASTER', - solo_queue_rank: 'II', - solo_queue_lp: 234, - solo_queue_wins: 127, - solo_queue_losses: 89, - champion_pool: ['Garen', 'Darius', 'Sett', 'Renekton', 'Camille'], - playstyle_tags: ['Tank', 'Engage', 'Team Fighter'], - jersey_number: 1, - contract_start_date: 1.year.ago, - contract_end_date: 1.year.from_now, - salary: 5000.00 - }, - { - summoner_name: 'JungleKing', - real_name: 'Pedro Santos', - role: 'jungle', - country: 'BR', - birth_date: Date.parse('2000-08-22'), - solo_queue_tier: 'GRANDMASTER', - solo_queue_rank: 'I', - solo_queue_lp: 456, - solo_queue_wins: 189, - solo_queue_losses: 112, - champion_pool: ['Graves', 'Kindred', 'Nidalee', 'Elise', 'Kha\'Zix'], - playstyle_tags: ['Carry', 'Aggressive', 'Counter Jungle'], - jersey_number: 2, - contract_start_date: 8.months.ago, - contract_end_date: 16.months.from_now, - salary: 7500.00 - }, - { - summoner_name: 'MidLaner', - real_name: 'Carlos Rodrigues', - role: 'mid', - country: 'BR', - birth_date: Date.parse('1999-12-05'), - solo_queue_tier: 'CHALLENGER', - solo_queue_rank: nil, - solo_queue_lp: 1247, - solo_queue_wins: 234, - solo_queue_losses: 145, - champion_pool: ['Azir', 'Syndra', 'Orianna', 'Yasuo', 'LeBlanc'], - playstyle_tags: ['Control Mage', 'Playmaker', 'Late Game'], - jersey_number: 3, - contract_start_date: 6.months.ago, - contract_end_date: 18.months.from_now, - salary: 10000.00 - }, - { - summoner_name: 'ADCMain', - real_name: 'Rafael Costa', - role: 'adc', - country: 'BR', - birth_date: Date.parse('2002-01-18'), - solo_queue_tier: 'MASTER', - solo_queue_rank: 'I', - solo_queue_lp: 567, - solo_queue_wins: 156, - solo_queue_losses: 98, - champion_pool: ['Jinx', 'Caitlyn', 'Ezreal', 'Kai\'Sa', 'Aphelios'], - playstyle_tags: ['Scaling', 'Positioning', 'Team Fight'], - jersey_number: 4, - contract_start_date: 10.months.ago, - contract_end_date: 14.months.from_now, - salary: 6000.00 - }, - { - summoner_name: 'SupportGod', - real_name: 'Lucas Oliveira', - role: 'support', - country: 'BR', - birth_date: Date.parse('2001-07-30'), - solo_queue_tier: 'MASTER', - solo_queue_rank: 'III', - solo_queue_lp: 345, - solo_queue_wins: 143, - solo_queue_losses: 107, - champion_pool: ['Thresh', 'Nautilus', 'Leona', 'Braum', 'Alistar'], - playstyle_tags: ['Engage', 'Vision Control', 'Shotcaller'], - jersey_number: 5, - contract_start_date: 4.months.ago, - contract_end_date: 20.months.from_now, - salary: 4500.00 - } -] - -players_data.each do |player_data| - player = Player.find_or_create_by!( - organization: org, - summoner_name: player_data[:summoner_name] - ) do |p| - player_data.each { |key, value| p.send("#{key}=", value) } - end - - puts "✅ Created player: #{player.summoner_name} (#{player.role})" - - # Create champion pool entries for each player - player.champion_pool.each_with_index do |champion, index| - ChampionPool.find_or_create_by!( - player: player, - champion: champion - ) do |cp| - cp.games_played = rand(10..50) - cp.games_won = (cp.games_played * (0.4 + rand * 0.4)).round - cp.mastery_level = [5, 6, 7].sample - cp.average_kda = 1.5 + rand * 2.0 - cp.average_cs_per_min = 6.0 + rand * 2.0 - cp.average_damage_share = 0.15 + rand * 0.15 - cp.is_comfort_pick = index < 2 - cp.is_pocket_pick = index == 2 - cp.priority = 10 - index - cp.last_played = rand(30).days.ago - end - end -end - -# Create sample matches -3.times do |i| - match = Match.find_or_create_by!( - organization: org, - riot_match_id: "BR_MATCH_#{1000 + i}" - ) do |m| - m.match_type = ['official', 'scrim'].sample - m.game_version = '14.19' - m.game_start = (i + 1).days.ago - m.game_duration = 1800 + rand(1200) # 30-50 minutes - m.our_side = ['blue', 'red'].sample - m.opponent_name = "Team #{['Beta', 'Gamma', 'Delta'][i]}" - m.victory = [true, false].sample - m.our_score = rand(5..25) - m.opponent_score = rand(5..25) - m.our_towers = rand(3..11) - m.opponent_towers = rand(3..11) - m.our_dragons = rand(0..4) - m.opponent_dragons = rand(0..4) - m.our_barons = rand(0..2) - m.opponent_barons = rand(0..2) - end - - puts "✅ Created match: #{match.opponent_name} (#{match.victory? ? 'Victory' : 'Defeat'})" - - # Create player stats for each match - org.players.each do |player| - PlayerMatchStat.find_or_create_by!( - match: match, - player: player - ) do |stat| - stat.champion = player.champion_pool.sample - stat.role = player.role - stat.kills = rand(0..15) - stat.deaths = rand(0..10) - stat.assists = rand(0..20) - stat.cs = rand(150..300) - stat.gold_earned = rand(10000..20000) - stat.damage_dealt_champions = rand(15000..35000) - stat.vision_score = rand(20..80) - stat.items = Array.new(6) { rand(1000..4000) } - stat.summoner_spell_1 = 'Flash' - stat.summoner_spell_2 = ['Teleport', 'Ignite', 'Heal', 'Barrier'].sample - end - end -end - -# Create sample scouting targets -scouting_targets_data = [ - { - summoner_name: 'ProspectTop', - region: 'BR', - role: 'top', - current_tier: 'GRANDMASTER', - current_rank: 'II', - current_lp: 678, - champion_pool: ['Fiora', 'Jax', 'Irelia'], - playstyle: 'aggressive', - strengths: ['Mechanical skill', 'Lane dominance'], - weaknesses: ['Team fighting', 'Communication'], - status: 'watching', - priority: 'high', - added_by: admin - }, - { - summoner_name: 'YoungSupport', - region: 'BR', - role: 'support', - current_tier: 'MASTER', - current_rank: 'I', - current_lp: 423, - champion_pool: ['Pyke', 'Bard', 'Rakan'], - playstyle: 'calculated', - strengths: ['Vision control', 'Roaming'], - weaknesses: ['Consistency', 'Champion pool'], - status: 'contacted', - priority: 'medium', - added_by: coach - } -] - -scouting_targets_data.each do |target_data| - target = ScoutingTarget.find_or_create_by!( - organization: org, - summoner_name: target_data[:summoner_name], - region: target_data[:region] - ) do |t| - target_data.each { |key, value| t.send("#{key}=", value) } - end - - puts "✅ Created scouting target: #{target.summoner_name} (#{target.role})" -end - -# Create sample team goals -[ - { - title: 'Reach Diamond Average Rank', - description: 'Team average rank should be Diamond or higher', - category: 'rank', - metric_type: 'rank_climb', - target_value: 6, # Diamond = 6 - current_value: 5, # Platinum = 5 - start_date: 1.month.ago, - end_date: 2.months.from_now, - assigned_to: coach, - created_by: admin - }, - { - title: 'Improve Team KDA', - description: 'Team should maintain above 2.0 KDA average', - category: 'performance', - metric_type: 'kda', - target_value: 2.0, - current_value: 1.7, - start_date: 2.weeks.ago, - end_date: 6.weeks.from_now, - assigned_to: analyst, - created_by: admin - } -].each do |goal_data| - goal = TeamGoal.find_or_create_by!( - organization: org, - title: goal_data[:title] - ) do |g| - goal_data.each { |key, value| g.send("#{key}=", value) } - end - - puts "✅ Created team goal: #{goal.title}" -end - -# Create individual player goals -org.players.limit(2).each_with_index do |player, index| - goal_data = [ - { - title: "Improve #{player.summoner_name} CS/min", - description: "Target 8.0+ CS per minute average", - category: 'skill', - metric_type: 'cs_per_min', - target_value: 8.0, - current_value: 6.5, - player: player - }, - { - title: "Increase #{player.summoner_name} Vision Score", - description: "Target 2.5+ vision score per minute", - category: 'performance', - metric_type: 'vision_score', - target_value: 2.5, - current_value: 1.8, - player: player - } - ][index] - - goal = TeamGoal.find_or_create_by!( - organization: org, - player: player, - title: goal_data[:title] - ) do |g| - goal_data.each { |key, value| g.send("#{key}=", value) } - g.start_date = 1.week.ago - g.end_date = 8.weeks.from_now - g.assigned_to = coach - g.created_by = admin - end - - puts "✅ Created player goal: #{goal.title}" -end - -puts "\n🎉 Database seeded successfully!" -puts "\n📋 Summary:" -puts " • Organization: #{org.name}" -puts " • Users: #{org.users.count}" -puts " • Players: #{org.players.count}" -puts " • Matches: #{org.matches.count}" -puts " • Scouting Targets: #{org.scouting_targets.count}" -puts " • Team Goals: #{org.team_goals.count}" -puts "\n🔐 Login credentials:" -puts " • Admin: admin@teamalpha.gg / password123" -puts " • Coach: coach@teamalpha.gg / password123" -puts " • Analyst: analyst@teamalpha.gg / password123" \ No newline at end of file +# frozen_string_literal: true + +# Seeds file for ProStaff API - Development/Testing Data +# +# SECURITY NOTE: This file creates development-only accounts +# Never use these credentials in production! + +# Get password from ENV or use default for development +DEFAULT_DEV_PASSWORD = ENV.fetch('DEV_SEED_PASSWORD', 'password123') + +puts '🌱 Seeding database with organizations...' + +# ============================================================================ +# TIME 1: Java E-sports (Tier 1 Professional) +# ============================================================================ +puts "\n📍 Creating Java E-sports..." + +org1 = Organization.find_or_create_by!(id: '043824c0-906f-4aa2-9bc7-11d668b508dc') do |organization| + organization.name = 'Java E-sports' + organization.slug = 'java-esports' + organization.region = 'BR' + organization.tier = 'tier_1_professional' + organization.subscription_plan = 'professional' + organization.subscription_status = 'active' +end + +puts " ✅ Organization: #{org1.name} (#{org1.tier})" + +user1 = User.find_or_create_by!(email: 'coach@teamalpha.gg') do |user| + user.organization = org1 + user.password = DEFAULT_DEV_PASSWORD + user.password_confirmation = DEFAULT_DEV_PASSWORD + user.full_name = 'Java E-sports Coach' + user.role = 'coach' + user.timezone = 'America/Sao_Paulo' + user.language = 'pt-BR' +end + +puts " ✅ User: #{user1.email} (role: #{user1.role})" + +# ============================================================================ +# TIME 2: BotaPagodão.net (Tier 2 Semi-Pro) +# ============================================================================ +puts "\n📍 Creating BotaPagodão.net..." + +org2 = Organization.find_or_create_by!(id: 'd2e76113-eeda-4e5c-9a5e-bd3e944fc290') do |organization| + organization.name = 'BotaPagodão.net' + organization.slug = 'botapagodao-net' + organization.region = 'BR' + organization.tier = 'tier_2_semi_pro' + organization.subscription_plan = 'semi_pro' + organization.subscription_status = 'active' +end + +puts " ✅ Organization: #{org2.name} (#{org2.tier})" + +user2 = User.find_or_create_by!(email: 'coach@botapagodao.net') do |user| + user.organization = org2 + user.password = DEFAULT_DEV_PASSWORD + user.password_confirmation = DEFAULT_DEV_PASSWORD + user.full_name = 'BotaPagodão Coach' + user.role = 'coach' + user.timezone = 'America/Sao_Paulo' + user.language = 'pt-BR' +end + +puts " ✅ User: #{user2.email} (role: #{user2.role})" + +# ============================================================================ +# TIME 3: Discordia (Tier 2 Semi-Pro) +# ============================================================================ +puts "\n📍 Creating Discordia..." + +org3 = Organization.find_or_create_by!(slug: 'discordia') do |organization| + organization.name = 'Discordia' + organization.region = 'BR' + organization.tier = 'tier_2_semi_pro' + organization.subscription_plan = 'semi_pro' + organization.subscription_status = 'active' +end + +puts " ✅ Organization: #{org3.name} (#{org3.tier}) - ID: #{org3.id}" + +user3 = User.find_or_create_by!(email: 'coach@discordia.gg') do |user| + user.organization = org3 + user.password = DEFAULT_DEV_PASSWORD + user.password_confirmation = DEFAULT_DEV_PASSWORD + user.full_name = 'Discordia Coach' + user.role = 'coach' + user.timezone = 'America/Sao_Paulo' + user.language = 'pt-BR' +end + +puts " ✅ User: #{user3.email} (role: #{user3.role})" + +# ============================================================================ +# SUMMARY +# ============================================================================ +puts "\n#{'=' * 70}" +puts '🎉 Database seeded successfully!' +puts('=' * 70) +puts "\n📋 Organizations Created:" +puts ' 1. Java E-sports (Tier 1 Professional)' +puts " • ID: #{org1.id}" +puts " • Login: coach@teamalpha.gg / #{DEFAULT_DEV_PASSWORD}" +puts '' +puts ' 2. BotaPagodão.net (Tier 2 Semi-Pro)' +puts " • ID: #{org2.id}" +puts " • Login: coach@botapagodao.net / #{DEFAULT_DEV_PASSWORD}" +puts '' +puts ' 3. Discordia (Tier 2 Semi-Pro)' +puts " • ID: #{org3.id}" +puts " • Login: coach@discordia.gg / #{DEFAULT_DEV_PASSWORD}" +puts "\n#{'=' * 70}" +puts '📝 Next Steps:' +puts ' • Import players manually for each organization' +puts ' • Verify login works with the credentials above' +puts "\n⚠️ IMPORTANT: These are DEVELOPMENT-ONLY credentials!" +puts ' Never use these passwords in production environments.' +puts "#{'=' * 70}\n" diff --git a/deploy/nginx/conf.d/prostaff.conf b/deploy/nginx/conf.d/prostaff.conf index 672fc74..4b72787 100644 --- a/deploy/nginx/conf.d/prostaff.conf +++ b/deploy/nginx/conf.d/prostaff.conf @@ -4,7 +4,7 @@ server { listen 80; listen [::]:80; - server_name api.prostaff.gg staging-api.prostaff.gg; + server_name api.prostaff.gg staging-api.prostaff.gg prostaff.gg www.prostaff.gg; # Health check endpoint (HTTP OK) location /health { @@ -13,8 +13,11 @@ server { add_header Content-Type text/plain; } - # Redirect all other traffic to HTTPS + # Redirect all other traffic to HTTPS (canonicalize root domain for www) location / { + if ($host = 'www.prostaff.gg') { + return 301 https://prostaff.gg$request_uri; + } return 301 https://$server_name$request_uri; } } @@ -105,6 +108,107 @@ server { } } +## HTTPS - Production (root domain) +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name prostaff.gg; + + # SSL Configuration + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # HSTS + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Logs + access_log /var/log/nginx/prostaff-root-access.log main; + error_log /var/log/nginx/prostaff-root-error.log warn; + + # Root directory + root /app/public; + + # Rate limiting + limit_req zone=api burst=50 nodelay; + + # Serve static files directly + location ~ ^/(assets|packs|images|javascripts|stylesheets|system)/ { + gzip_static on; + expires max; + add_header Cache-Control public; + access_log off; + try_files $uri @app; + } + + # Health check + location /up { + proxy_pass http://prostaff_api; + proxy_set_header Host $host; + access_log off; + } + + # API Documentation (Swagger) + location /api-docs { + proxy_pass http://prostaff_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy to Rails app + location / { + proxy_pass http://prostaff_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Port $server_port; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Error pages + error_page 500 502 503 504 /500.html; + location = /500.html { + root /app/public; + internal; + } +} + +## HTTPS - Redirect www to root domain +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name www.prostaff.gg; + + # SSL Configuration (certificate should include both root and www SANs) + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + return 301 https://prostaff.gg$request_uri; +} + # HTTPS - Staging server { listen 443 ssl http2; diff --git a/deploy/scripts/docker-entrypoint.sh b/deploy/scripts/docker-entrypoint.sh index da2a269..0811a7e 100644 --- a/deploy/scripts/docker-entrypoint.sh +++ b/deploy/scripts/docker-entrypoint.sh @@ -8,10 +8,17 @@ rm -f /app/tmp/pids/server.pid # Wait for database to be ready echo "⏳ Waiting for database..." -until PGPASSWORD=$POSTGRES_PASSWORD psql -h postgres -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do - echo " Database is unavailable - sleeping" - sleep 2 -done +if [ -n "$DATABASE_URL" ]; then + until pg_isready -d "$DATABASE_URL"; do + echo " Database is unavailable - sleeping" + sleep 2 + done +else + until PGPASSWORD=$POSTGRES_PASSWORD psql -h "${POSTGRES_HOST:-postgres}" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\q' 2>/dev/null; do + echo " Database is unavailable - sleeping" + sleep 2 + done +fi echo "✅ Database is ready" # Run database migrations diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 988b80e..c97d083 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -1,6 +1,40 @@ -version: '3.8' - services: + # Elasticsearch for analytics search + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 + container_name: prostaff-elasticsearch + restart: unless-stopped + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ES_JAVA_OPTS=-Xms1g -Xmx1g + volumes: + - es_data:/usr/share/elasticsearch/data + ports: + - "9200:9200" + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200 >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - prostaff-net + + # Kibana for exploring ES indices + kibana: + image: docker.elastic.co/kibana/kibana:8.13.4 + container_name: prostaff-kibana + restart: unless-stopped + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + depends_on: + elasticsearch: + condition: service_healthy + ports: + - "5601:5601" + networks: + - prostaff-net + # Nginx Reverse Proxy nginx: image: nginx:alpine @@ -17,6 +51,7 @@ services: - nginx_logs:/var/log/nginx depends_on: - api + - kibana networks: - prostaff-net healthcheck: @@ -156,6 +191,24 @@ services: memory: 256M cpus: '0.1' + # Scraper service (optional runner) + scraper: + build: + context: ./League-Data-Scraping-And-Analytics-master/ProStaff-Scraper + container_name: prostaff-scraper + env_file: + - ./League-Data-Scraping-And-Analytics-master/ProStaff-Scraper/.env + volumes: + - ./League-Data-Scraping-And-Analytics-master/ProStaff-Scraper:/app + depends_on: + elasticsearch: + condition: service_healthy + command: ["tail", "-f", "/dev/null"] + networks: + - prostaff-net + profiles: + - scraper + # Backup Service (runs daily) backup: image: postgres:15-alpine @@ -182,6 +235,8 @@ volumes: driver: local nginx_logs: driver: local + es_data: + driver: local networks: prostaff-net: diff --git a/docker-compose.yml b/docker-compose.yml index eb134e4..b5683b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,87 +1,79 @@ -services: - # PostgreSQL Database - postgres: - image: postgres:15-alpine - environment: - POSTGRES_DB: ${POSTGRES_DB:-prostaff_api_development} - POSTGRES_USER: ${POSTGRES_USER:-postgres} - 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}"] - interval: 10s - timeout: 5s - retries: 5 - - # Redis for caching and Sidekiq - redis: - image: redis:7-alpine - volumes: - - redis_data:/data - ports: - - "${REDIS_PORT:-6379}:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - # Rails API - api: - build: . - container_name: prostaff-api - environment: - DATABASE_URL: ${DATABASE_URL} - REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} - RAILS_ENV: ${RAILS_ENV:-development} - JWT_SECRET_KEY: ${JWT_SECRET_KEY} - CORS_ORIGINS: ${CORS_ORIGINS} - RIOT_API_KEY: ${RIOT_API_KEY} - volumes: - - .:/app - - bundle_cache:/usr/local/bundle - ports: - - "${API_PORT:-3333}:3000" - depends_on: - redis: - condition: service_healthy - networks: - - default - - security_tests_security-net - command: > - sh -c " - bundle check || bundle install && - rm -f tmp/pids/server.pid && - bundle exec rails db:migrate && - bundle exec rails server -b 0.0.0.0 -p 3000 - " - - # Sidekiq for background jobs - sidekiq: - build: . - environment: - DATABASE_URL: ${DATABASE_URL} - REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} - RAILS_ENV: ${RAILS_ENV:-development} - JWT_SECRET_KEY: ${JWT_SECRET_KEY} - volumes: - - .:/app - - bundle_cache:/usr/local/bundle - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - command: bundle exec sidekiq - -volumes: - postgres_data: - redis_data: - bundle_cache: - -networks: - security_tests_security-net: - external: true \ No newline at end of file +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-prostaff_api_development} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + 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}"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis for caching and Sidekiq + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # Rails API + api: + build: . + container_name: prostaff-api + env_file: + - .env + volumes: + - .:/app + - bundle_cache:/usr/local/bundle + ports: + - "${API_PORT:-3333}:3000" + depends_on: + redis: + condition: service_healthy + networks: + - default + - security-net + command: > + sh -c " + bundle check || bundle install && + rm -f tmp/pids/server.pid && + bundle exec rails db:migrate && + bundle exec rails server -b 0.0.0.0 -p 3000 + " + + # Sidekiq for background jobs + sidekiq: + build: . + env_file: + - .env + volumes: + - .:/app + - bundle_cache:/usr/local/bundle + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: bundle exec sidekiq -C config/sidekiq.yml + +volumes: + postgres_data: + redis_data: + bundle_cache: + +networks: + security-net: + driver: bridge diff --git a/lib/tasks/db_fix.rake b/lib/tasks/db_fix.rake index 7eea72b..3e54db3 100644 --- a/lib/tasks/db_fix.rake +++ b/lib/tasks/db_fix.rake @@ -1,50 +1,52 @@ -namespace :db do - desc "Mark all migrations as applied" - task mark_migrations_up: :environment do - versions = %w[ - 20241001000002 - 20241001000003 - 20241001000004 - 20241001000005 - 20241001000006 - 20241001000007 - 20241001000008 - 20241001000009 - 20241001000010 - 20241001000011 - 20241001000012 - 20241001000013 - 20241001000014 - ] - - versions.each do |version| - ActiveRecord::Base.connection.execute( - "INSERT INTO schema_migrations (version) VALUES ('#{version}') ON CONFLICT DO NOTHING" - ) - end - - puts "✅ All migrations marked as up!" - end - - desc "Reset public schema tables" - task reset_public_schema: :environment do - puts "🗑️ Dropping all tables in public schema..." - - tables = ActiveRecord::Base.connection.execute(<<-SQL - SELECT tablename FROM pg_tables WHERE schemaname = 'public' - SQL - ).map { |row| row['tablename'] } - - tables.each do |table| - next if table == 'schema_migrations' || table == 'ar_internal_metadata' - - puts " Dropping #{table}..." - ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{table} CASCADE") - end - - # Clear schema_migrations - ActiveRecord::Base.connection.execute("DELETE FROM schema_migrations") - - puts "✅ Public schema reset complete!" - end -end +# frozen_string_literal: true + +namespace :db do + desc 'Mark all migrations as applied' + task mark_migrations_up: :environment do + versions = %w[ + 20241001000002 + 20241001000003 + 20241001000004 + 20241001000005 + 20241001000006 + 20241001000007 + 20241001000008 + 20241001000009 + 20241001000010 + 20241001000011 + 20241001000012 + 20241001000013 + 20241001000014 + ] + + versions.each do |version| + ActiveRecord::Base.connection.execute( + "INSERT INTO schema_migrations (version) VALUES ('#{version}') ON CONFLICT DO NOTHING" + ) + end + + puts '✅ All migrations marked as up!' + end + + desc 'Reset public schema tables' + task reset_public_schema: :environment do + puts '🗑️ Dropping all tables in public schema...' + + tables = ActiveRecord::Base.connection.execute(<<-SQL + SELECT tablename FROM pg_tables WHERE schemaname = 'public' + SQL + ).map { |row| row['tablename'] } + + tables.each do |table| + next if %w[schema_migrations ar_internal_metadata].include?(table) + + puts " Dropping #{table}..." + ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{table} CASCADE") + end + + # Clear schema_migrations + ActiveRecord::Base.connection.execute('DELETE FROM schema_migrations') + + puts '✅ Public schema reset complete!' + end +end diff --git a/lib/tasks/riot.rake b/lib/tasks/riot.rake new file mode 100644 index 0000000..b9c035b --- /dev/null +++ b/lib/tasks/riot.rake @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +namespace :riot do + desc 'Update Data Dragon cache (champions, items, etc.)' + task update_data_dragon: :environment do + puts '🔄 Updating Data Dragon cache...' + + service = DataDragonService.new + + begin + # Clear existing cache + puts '🗑️ Clearing old cache...' + service.clear_cache! + + # Fetch latest version + puts '📦 Fetching latest game version...' + version = service.latest_version + puts " ✅ Latest version: #{version}" + + # Fetch champion data + puts '🎮 Fetching champion data...' + champions = service.champion_id_map + puts " ✅ Loaded #{champions.count} champions" + + # Fetch all champions details + puts '📊 Fetching detailed champion data...' + all_champions = service.all_champions + puts " ✅ Loaded details for #{all_champions.count} champions" + + # Fetch items + puts '⚔️ Fetching items data...' + items = service.items + puts " ✅ Loaded #{items.count} items" + + # Fetch summoner spells + puts '✨ Fetching summoner spells...' + spells = service.summoner_spells + puts " ✅ Loaded #{spells.count} summoner spells" + + # Fetch profile icons + puts '🖼️ Fetching profile icons...' + icons = service.profile_icons + puts " ✅ Loaded #{icons.count} profile icons" + + puts "\n✅ Data Dragon cache updated successfully!" + puts " Version: #{version}" + puts ' Cache will expire in 1 week' + rescue StandardError => e + puts "\n❌ Error updating Data Dragon cache: #{e.message}" + puts e.backtrace.first(5).join("\n") + exit 1 + end + end + + desc 'Show Data Dragon cache info' + task cache_info: :environment do + service = DataDragonService.new + + puts '📊 Data Dragon Cache Information' + puts '=' * 50 + + begin + version = service.latest_version + champions = service.champion_id_map + all_champions = service.all_champions + items = service.items + spells = service.summoner_spells + icons = service.profile_icons + + puts "Game Version: #{version}" + puts "Champions: #{champions.count}" + puts "Champion Details: #{all_champions.count}" + puts "Items: #{items.count}" + puts "Summoner Spells: #{spells.count}" + puts "Profile Icons: #{icons.count}" + + puts "\nSample Champions:" + champions.first(5).each do |id, name| + puts " [#{id}] #{name}" + end + rescue StandardError => e + puts "❌ Error: #{e.message}" + exit 1 + end + end + + desc 'Clear Data Dragon cache' + task clear_cache: :environment do + puts '🗑️ Clearing Data Dragon cache...' + + service = DataDragonService.new + service.clear_cache! + + puts '✅ Cache cleared successfully!' + end + + desc 'Sync all active players from Riot API' + task sync_all_players: :environment do + puts '🔄 Syncing all active players from Riot API...' + + Player.active.find_each do |player| + puts " Syncing #{player.summoner_name} (#{player.id})..." + SyncPlayerFromRiotJob.perform_later(player.id) + end + + puts "✅ Queued #{Player.active.count} players for sync!" + end + + desc 'Sync all scouting targets from Riot API' + task sync_all_scouting_targets: :environment do + puts '🔄 Syncing all scouting targets from Riot API...' + + ScoutingTarget.find_each do |target| + puts " Syncing #{target.summoner_name} (#{target.id})..." + SyncScoutingTargetJob.perform_later(target.id) + end + + puts "✅ Queued #{ScoutingTarget.count} scouting targets for sync!" + end +end diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..0e681fe --- /dev/null +++ b/render.yaml @@ -0,0 +1,29 @@ + + + +services: + - type: redis + name: prostaff-redis + + plan: free + ipAllowList: [] # Access only from within Render + + - type: web + name: prostaff-api + runtime: docker + + plan: free + dockerfilePath: ./Dockerfile.production + envVars: + - key: RAILS_MASTER_KEY + sync: false + - key: CORS_ORIGINS + value: https://prostaffgg.netlify.app,http://localhost:5173 + + - key: REDIS_URL + fromService: + type: redis + name: prostaff-redis + property: connectionString + - key: WEB_CONCURRENCY + value: 2 diff --git a/scripts/create_test_user.rb b/scripts/create_test_user.rb index be4fc05..2f44183 100644 --- a/scripts/create_test_user.rb +++ b/scripts/create_test_user.rb @@ -1,11 +1,12 @@ #!/usr/bin/env ruby +# frozen_string_literal: true # Create test user for load testing org = Organization.first if org.nil? - puts "❌ No organization found. Please create one first." + puts '❌ No organization found. Please create one first.' exit 1 end @@ -27,7 +28,7 @@ end puts "\nTest Credentials:" -puts "==================" +puts '==================' puts "Email: #{user.email}" puts "Password: #{test_password.gsub(/./, '*')}" puts "Organization: #{org.name}" diff --git a/scripts/get-token.sh b/scripts/get-token.sh new file mode 100644 index 0000000..948e505 --- /dev/null +++ b/scripts/get-token.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +echo "================================================" +echo "🎫 ProStaff API - JWT Token Generator" +echo "================================================" +echo "" + +TEST_EMAIL="${TEST_EMAIL:-test@prostaff.gg}" +TEST_PASSWORD="${TEST_PASSWORD:-Test123!@#}" + +echo "📧 Gerando token para: $TEST_EMAIL" +echo "" + +TOKEN=$(bundle exec rails runner " +user = User.find_by(email: '$TEST_EMAIL') + +if user.nil? + puts '⚠️ Usuário não encontrado. Criando...' + + org = Organization.first_or_create!( + name: 'Test Organization', + slug: 'test-org', + region: 'BR', + tier: 'tier_1_professional' + ) + + user = User.create!( + email: '$TEST_EMAIL', + password: '$TEST_PASSWORD', + password_confirmation: '$TEST_PASSWORD', + full_name: 'Test User', + role: 'owner', + organization: org + ) + + puts '✅ Usuário criado com sucesso!' +end + +tokens = Authentication::Services::JwtService.generate_tokens(user) +puts tokens[:access_token] +" 2>&1) + +JWT_TOKEN=$(echo "$TOKEN" | tail -1) + +echo "================================================" +echo "✅ Token JWT gerado com sucesso!" +echo "================================================" +echo "" +echo "📋 Token (válido por ${JWT_EXPIRATION_HOURS:-24} horas):" +echo "" +echo "$JWT_TOKEN" +echo "" +echo "================================================" +echo "💡 Como usar:" +echo "================================================" +echo "" +echo "# Exportar para variável de ambiente:" +echo "export BEARER_TOKEN=\"$JWT_TOKEN\"" +echo "" +echo "# Usar no curl:" +echo "curl -H \"Authorization: Bearer \$BEARER_TOKEN\" http://localhost:3333/api/v1/players" +echo "" +echo "# Copiar para clipboard (Linux):" +echo "echo \"$JWT_TOKEN\" | xclip -selection clipboard" +echo "" +echo "================================================" diff --git a/scripts/update_architecture_diagram.rb b/scripts/update_architecture_diagram.rb index 658f4e1..79c2186 100755 --- a/scripts/update_architecture_diagram.rb +++ b/scripts/update_architecture_diagram.rb @@ -1,422 +1,556 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# This script analyzes the Rails application structure and updates the architecture diagram -# in README.md with current modules, controllers, models, and services - -require 'pathname' -require 'set' - -class ArchitectureDiagramGenerator - RAILS_ROOT = Pathname.new(__dir__).join('..') - README_PATH = RAILS_ROOT.join('README.md') - - def initialize - @modules = discover_modules - @models = discover_models - @controllers = discover_controllers - @services = discover_services - end - - def run - puts "Analyzing project structure..." - diagram = generate_mermaid_diagram - update_readme(diagram) - puts "✅ Architecture diagram updated successfully!" - end - - private - - def discover_modules - modules_path = RAILS_ROOT.join('app', 'modules') - return [] unless modules_path.exist? - - Dir.glob(modules_path.join('*')).select(&File.method(:directory?)).map do |dir| - File.basename(dir) - end.sort - end - - def discover_models - models_path = RAILS_ROOT.join('app', 'models') - return [] unless models_path.exist? - - Dir.glob(models_path.join('*.rb')).map do |file| - File.basename(file, '.rb') - end.reject { |m| m == 'application_record' }.sort - end - - def discover_controllers - controllers = {} - - # Discover module controllers - @modules.each do |mod| - controllers_path = RAILS_ROOT.join('app', 'modules', mod, 'controllers') - next unless controllers_path.exist? - - controllers[mod] = Dir.glob(controllers_path.join('*_controller.rb')).map do |file| - File.basename(file, '_controller.rb') - end - end - - # Discover main API controllers - api_controllers_path = RAILS_ROOT.join('app', 'controllers', 'api', 'v1') - if api_controllers_path.exist? - controllers['api_v1'] = Dir.glob(api_controllers_path.join('*_controller.rb')).map do |file| - File.basename(file, '_controller.rb') - end.reject { |c| c == 'base' } - end - - controllers - end - - def discover_services - services = {} - - @modules.each do |mod| - services_path = RAILS_ROOT.join('app', 'modules', mod, 'services') - next unless services_path.exist? - - services[mod] = Dir.glob(services_path.join('*_service.rb')).map do |file| - File.basename(file, '_service.rb') - end - end - - services - end - - def generate_mermaid_diagram - <<~MERMAID - ```mermaid - graph TB - subgraph "Client Layer" - Client[Frontend Application] - end - - subgraph "API Gateway" - Router[Rails Router] - CORS[CORS Middleware] - RateLimit[Rate Limiting] - Auth[Authentication Middleware] - end - - subgraph "Application Layer - Modular Monolith" - #{generate_module_sections} - end - - subgraph "Data Layer" - PostgreSQL[(PostgreSQL Database)] - Redis[(Redis Cache)] - end - - subgraph "Background Jobs" - Sidekiq[Sidekiq Workers] - JobQueue[Job Queue] - end - - subgraph "External Services" - RiotAPI[Riot Games API] - end - - Client -->|HTTP/JSON| CORS - CORS --> RateLimit - RateLimit --> Auth - Auth --> Router - - #{generate_router_connections} - #{generate_data_connections} - #{generate_external_connections} - - style Client fill:#e1f5ff - style PostgreSQL fill:#336791 - style Redis fill:#d82c20 - style RiotAPI fill:#eb0029 - style Sidekiq fill:#b1003e - ``` - MERMAID - end - - def generate_module_sections - sections = [] - - # Authentication module - sections << generate_auth_module if @modules.include?('authentication') - - # Other discovered modules - (@modules - ['authentication']).each do |mod| - sections << generate_generic_module(mod) - end - - # Core modules based on routes and models - sections << generate_dashboard_module if has_dashboard_routes? - sections << generate_players_module if @models.include?('player') - sections << generate_scouting_module if @models.include?('scouting_target') - sections << generate_analytics_module if has_analytics_routes? - sections << generate_matches_module if @models.include?('match') - sections << generate_schedules_module if @models.include?('schedule') - sections << generate_vod_module if @models.include?('vod_review') - sections << generate_goals_module if @models.include?('team_goal') - sections << generate_riot_module if has_riot_integration? - - sections.compact.join("\n") - end - - def generate_auth_module - <<~MODULE.chomp - subgraph "Authentication Module" - AuthController[Auth Controller] - JWTService[JWT Service] - UserModel[User Model] - end - MODULE - end - - def generate_generic_module(name) - <<~MODULE.chomp - subgraph "#{name.capitalize} Module" - #{name.capitalize}Controller[#{name.capitalize} Controller] - end - MODULE - end - - def generate_dashboard_module - <<~MODULE.chomp - subgraph "Dashboard Module" - DashboardController[Dashboard Controller] - DashStats[Statistics Service] - end - MODULE - end - - def generate_players_module - <<~MODULE.chomp - subgraph "Players Module" - PlayersController[Players Controller] - PlayerModel[Player Model] - ChampionPool[Champion Pool Model] - end - MODULE - end - - def generate_scouting_module - <<~MODULE.chomp - subgraph "Scouting Module" - ScoutingController[Scouting Controller] - ScoutingTarget[Scouting Target Model] - Watchlist[Watchlist Service] - end - MODULE - end - - def generate_analytics_module - <<~MODULE.chomp - subgraph "Analytics Module" - AnalyticsController[Analytics Controller] - PerformanceService[Performance Service] - KDAService[KDA Trend Service] - end - MODULE - end - - def generate_matches_module - <<~MODULE.chomp - subgraph "Matches Module" - MatchesController[Matches Controller] - MatchModel[Match Model] - PlayerMatchStats[Player Match Stats Model] - end - MODULE - end - - def generate_schedules_module - <<~MODULE.chomp - subgraph "Schedules Module" - SchedulesController[Schedules Controller] - ScheduleModel[Schedule Model] - end - MODULE - end - - def generate_vod_module - <<~MODULE.chomp - subgraph "VOD Reviews Module" - VODController[VOD Reviews Controller] - VODModel[VOD Review Model] - TimestampModel[Timestamp Model] - end - MODULE - end - - def generate_goals_module - <<~MODULE.chomp - subgraph "Team Goals Module" - GoalsController[Team Goals Controller] - GoalModel[Team Goal Model] - end - MODULE - end - - def generate_riot_module - <<~MODULE.chomp - subgraph "Riot Integration Module" - RiotService[Riot API Service] - RiotSync[Sync Service] - end - MODULE - end - - def generate_router_connections - connections = [] - connections << " Router --> AuthController" if @modules.include?('authentication') - connections << " Router --> DashboardController" if has_dashboard_routes? - connections << " Router --> PlayersController" if @models.include?('player') - connections << " Router --> ScoutingController" if @models.include?('scouting_target') - connections << " Router --> AnalyticsController" if has_analytics_routes? - connections << " Router --> MatchesController" if @models.include?('match') - connections << " Router --> SchedulesController" if @models.include?('schedule') - connections << " Router --> VODController" if @models.include?('vod_review') - connections << " Router --> GoalsController" if @models.include?('team_goal') - connections.join("\n") - end - - def generate_data_connections - connections = [] - - # Auth connections - if @modules.include?('authentication') - connections << " AuthController --> JWTService" - connections << " AuthController --> UserModel" - end - - # Players connections - if @models.include?('player') - connections << " PlayersController --> PlayerModel" - connections << " PlayerModel --> ChampionPool" if @models.include?('champion_pool') - end - - # Scouting connections - if @models.include?('scouting_target') - connections << " ScoutingController --> ScoutingTarget" - connections << " ScoutingController --> Watchlist" - end - - # Matches connections - if @models.include?('match') - connections << " MatchesController --> MatchModel" - connections << " MatchModel --> PlayerMatchStats" if @models.include?('player_match_stat') - end - - # Other model connections - connections << " SchedulesController --> ScheduleModel" if @models.include?('schedule') - - if @models.include?('vod_review') - connections << " VODController --> VODModel" - connections << " VODModel --> TimestampModel" if @models.include?('vod_timestamp') - end - - connections << " GoalsController --> GoalModel" if @models.include?('team_goal') - - # Analytics connections - if has_analytics_routes? - connections << " AnalyticsController --> PerformanceService" - connections << " AnalyticsController --> KDAService" - end - - # Database connections - @models.each do |model| - model_name = model.split('_').map(&:capitalize).join - connections << " #{model_name}Model[#{model_name} Model] --> PostgreSQL" - end - - # Redis connections - connections << " JWTService --> Redis" if @modules.include?('authentication') - connections << " DashStats --> Redis" if has_dashboard_routes? - connections << " PerformanceService --> Redis" if has_analytics_routes? - - connections.join("\n") - end - - def generate_external_connections - return "" unless has_riot_integration? - - <<~CONNECTIONS.chomp - PlayersController --> RiotService - MatchesController --> RiotService - ScoutingController --> RiotService - RiotService --> RiotAPI - - RiotService --> Sidekiq - Sidekiq --> JobQueue - JobQueue --> Redis - CONNECTIONS - end - - def has_dashboard_routes? - routes_content = File.read(RAILS_ROOT.join('config', 'routes.rb')) - routes_content.include?('dashboard') - end - - def has_analytics_routes? - routes_content = File.read(RAILS_ROOT.join('config', 'routes.rb')) - routes_content.include?('analytics') - end - - def has_riot_integration? - gemfile = File.read(RAILS_ROOT.join('Gemfile')) - gemfile.include?('faraday') || @services.values.any? { |s| s.include?('riot') } - end - - def update_readme(diagram) - content = File.read(README_PATH) - - # Find the architecture section - arch_start = content.index('## Architecture') - return unless arch_start - - # Find the end of architecture section (next ## heading or end of file) - arch_end = content.index(/^## /, arch_start + 1) || content.length - - # Extract before and after sections - before_arch = content[0...arch_start] - after_arch = content[arch_end..-1] - - # Build new architecture section - new_arch_section = <<~ARCH - ## Architecture - - This API follows a modular monolith architecture with the following modules: - - - `authentication` - User authentication and authorization - - `dashboard` - Dashboard statistics and metrics - - `players` - Player management and statistics - - `scouting` - Player scouting and talent discovery - - `analytics` - Performance analytics and reporting - - `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 - - ### Architecture Diagram - - #{diagram} - - **Key Architecture Principles:** - - 1. **Modular Monolith**: Each module is self-contained with its own controllers, models, and services - 2. **API-Only**: Rails configured in API mode for JSON responses - 3. **JWT Authentication**: Stateless authentication using JWT tokens - 4. **Background Processing**: Long-running tasks handled by Sidekiq - 5. **Caching**: Redis used for session management and performance optimization - 6. **External Integration**: Riot Games API integration for real-time data - 7. **Rate Limiting**: Rack::Attack for API rate limiting - 8. **CORS**: Configured for cross-origin requests from frontend - - ARCH - - # Write back to file - File.write(README_PATH, before_arch + new_arch_section + after_arch) - end -end - -# Run the generator -ArchitectureDiagramGenerator.new.run +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This script analyzes the Rails application structure and updates the architecture diagram +# in README.md with current modules, controllers, models, and services + +require 'pathname' + +class ArchitectureDiagramGenerator + RAILS_ROOT = Pathname.new(__dir__).join('..') + README_PATH = RAILS_ROOT.join('README.md') + + def initialize + @modules = discover_modules + @models = discover_models + @controllers = discover_controllers + @services = discover_services + end + + def run + puts 'Analyzing project structure...' + diagram = generate_mermaid_diagram + update_readme(diagram) + puts '✅ Architecture diagram updated successfully!' + end + + private + + def discover_modules + modules_path = RAILS_ROOT.join('app', 'modules') + return [] unless modules_path.exist? + + Dir.glob(modules_path.join('*')).select(&File.method(:directory?)).map do |dir| + File.basename(dir) + end.sort + end + + def discover_models + models_path = RAILS_ROOT.join('app', 'models') + return [] unless models_path.exist? + + Dir.glob(models_path.join('*.rb')).map do |file| + File.basename(file, '.rb') + end.reject { |m| m == 'application_record' }.sort + end + + def discover_controllers + controllers = {} + + # Discover module controllers + @modules.each do |mod| + controllers_path = RAILS_ROOT.join('app', 'modules', mod, 'controllers') + next unless controllers_path.exist? + + controllers[mod] = Dir.glob(controllers_path.join('*_controller.rb')).map do |file| + File.basename(file, '_controller.rb') + end + end + + # Discover main API controllers + api_controllers_path = RAILS_ROOT.join('app', 'controllers', 'api', 'v1') + if api_controllers_path.exist? + controllers['api_v1'] = Dir.glob(api_controllers_path.join('*_controller.rb')).map do |file| + File.basename(file, '_controller.rb') + end.reject { |c| c == 'base' } + end + + controllers + end + + def discover_services + services = {} + + @modules.each do |mod| + services_path = RAILS_ROOT.join('app', 'modules', mod, 'services') + next unless services_path.exist? + + services[mod] = Dir.glob(services_path.join('*_service.rb')).map do |file| + File.basename(file, '_service.rb') + end + end + + services + end + + def generate_mermaid_diagram + <<~MERMAID + ```mermaid + graph TB + subgraph "Client Layer" + Client[Frontend Application] + end + + subgraph "API Gateway" + Router[Rails Router] + CORS[CORS Middleware] + RateLimit[Rate Limiting] + Auth[Authentication Middleware] + end + + subgraph "Application Layer - Modular Monolith" + #{generate_module_sections} + end + + subgraph "Data Layer" + PostgreSQL[(PostgreSQL Database)] + Redis[(Redis Cache)] + end + + subgraph "Background Jobs" + Sidekiq[Sidekiq Workers] + JobQueue[Job Queue] + end + + subgraph "External Services" + RiotAPI[Riot Games API] + PandaScoreAPI[PandaScore API] + end + + Client -->|HTTP/JSON| CORS + CORS --> RateLimit + RateLimit --> Auth + Auth --> Router + #{' '} + #{generate_router_connections} + #{generate_data_connections} + #{generate_external_connections} + #{' '} + style Client fill:#e1f5ff + style PostgreSQL fill:#336791 + style Redis fill:#d82c20 + style RiotAPI fill:#eb0029 + style PandaScoreAPI fill:#ff6b35 + style Sidekiq fill:#b1003e + ``` + MERMAID + end + + def generate_module_sections + sections = [] + + # Authentication module + sections << generate_auth_module if @modules.include?('authentication') + + # Core modules based on routes and models + sections << generate_dashboard_module if has_dashboard_routes? + sections << generate_players_module if @models.include?('player') + sections << generate_scouting_module if @models.include?('scouting_target') + sections << generate_analytics_module if has_analytics_routes? + sections << generate_matches_module if @models.include?('match') + sections << generate_schedules_module if @models.include?('schedule') + sections << generate_vod_module if @models.include?('vod_review') + sections << generate_goals_module if @models.include?('team_goal') + sections << generate_riot_module if has_riot_integration? + + # New modules + sections << generate_competitive_module if @modules.include?('competitive') + sections << generate_scrims_module if @modules.include?('scrims') + sections << generate_strategy_module if @models.include?('draft_plan') || @models.include?('tactical_board') + sections << generate_support_module if @models.include?('support_ticket') + + sections.compact.join("\n\n") + end + + # Helper to indent module content + def indent_module(content) + content.split("\n").map { |line| " #{line}" }.join("\n") + end + + def generate_auth_module + indent_module(<<~MODULE.chomp) +subgraph "Authentication Module" + AuthController[Auth Controller] + JWTService[JWT Service] + UserModel[User Model] +end + MODULE + end + + def generate_generic_module(name) + indent_module(<<~MODULE.chomp) +subgraph "#{name.capitalize} Module" + #{name.capitalize}Controller[#{name.capitalize} Controller] +end + MODULE + end + + def generate_dashboard_module + indent_module(<<~MODULE.chomp) +subgraph "Dashboard Module" + DashboardController[Dashboard Controller] + DashStats[Statistics Service] +end + MODULE + end + + def generate_players_module + indent_module(<<~MODULE.chomp) +subgraph "Players Module" + PlayersController[Players Controller] + PlayerModel[Player Model] + ChampionPoolModel[Champion Pool Model] +end + MODULE + end + + def generate_scouting_module + indent_module(<<~MODULE.chomp) +subgraph "Scouting Module" + ScoutingController[Scouting Controller] + ScoutingTargetModel[Scouting Target Model] + Watchlist[Watchlist Service] +end + MODULE + end + + def generate_analytics_module + indent_module(<<~MODULE.chomp) +subgraph "Analytics Module" + AnalyticsController[Analytics Controller] + PerformanceService[Performance Service] + KDAService[KDA Trend Service] +end + MODULE + end + + def generate_matches_module + indent_module(<<~MODULE.chomp) +subgraph "Matches Module" + MatchesController[Matches Controller] + MatchModel[Match Model] + PlayerMatchStatModel[Player Match Stat Model] +end + MODULE + end + + def generate_schedules_module + indent_module(<<~MODULE.chomp) +subgraph "Schedules Module" + SchedulesController[Schedules Controller] + ScheduleModel[Schedule Model] +end + MODULE + end + + def generate_vod_module + indent_module(<<~MODULE.chomp) +subgraph "VOD Reviews Module" + VODController[VOD Reviews Controller] + VodReviewModel[VOD Review Model] + VodTimestampModel[VOD Timestamp Model] +end + MODULE + end + + def generate_goals_module + indent_module(<<~MODULE.chomp) +subgraph "Team Goals Module" + GoalsController[Team Goals Controller] + TeamGoalModel[Team Goal Model] +end + MODULE + end + + def generate_riot_module + indent_module(<<~MODULE.chomp) +subgraph "Riot Integration Module" + RiotService[Riot API Service] + RiotSync[Sync Service] +end + MODULE + end + + def generate_competitive_module + indent_module(<<~MODULE.chomp) +subgraph "Competitive Module" + CompetitiveController[Competitive Controller] + ProMatchesController[Pro Matches Controller] + PandaScoreService[PandaScore Service] + DraftAnalyzer[Draft Analyzer] +end + MODULE + end + + def generate_scrims_module + indent_module(<<~MODULE.chomp) +subgraph "Scrims Module" + ScrimsController[Scrims Controller] + OpponentTeamsController[Opponent Teams Controller] + ScrimAnalytics[Scrim Analytics Service] +end + MODULE + end + + def generate_strategy_module + indent_module(<<~MODULE.chomp) +subgraph "Strategy Module" + DraftPlansController[Draft Plans Controller] + TacticalBoardsController[Tactical Boards Controller] + DraftAnalysisService[Draft Analysis Service] +end + MODULE + end + + def generate_support_module + indent_module(<<~MODULE.chomp) +subgraph "Support Module" + SupportTicketsController[Support Tickets Controller] + SupportFaqsController[Support FAQs Controller] + SupportStaffController[Support Staff Controller] + SupportTicketModel[Support Ticket Model] + SupportFaqModel[Support FAQ Model] +end + MODULE + end + + def generate_router_connections + connections = [] + connections << ' Router --> AuthController' if @modules.include?('authentication') + connections << ' Router --> DashboardController' if has_dashboard_routes? + connections << ' Router --> PlayersController' if @models.include?('player') + connections << ' Router --> ScoutingController' if @models.include?('scouting_target') + connections << ' Router --> AnalyticsController' if has_analytics_routes? + connections << ' Router --> MatchesController' if @models.include?('match') + connections << ' Router --> SchedulesController' if @models.include?('schedule') + connections << ' Router --> VODController' if @models.include?('vod_review') + connections << ' Router --> GoalsController' if @models.include?('team_goal') + + # Competitive module routes + if @modules.include?('competitive') + connections << ' Router --> CompetitiveController' + connections << ' Router --> ProMatchesController' + end + + # Scrims module routes + if @modules.include?('scrims') + connections << ' Router --> ScrimsController' + connections << ' Router --> OpponentTeamsController' + end + + # Strategy module routes + if @models.include?('draft_plan') || @models.include?('tactical_board') + connections << ' Router --> DraftPlansController' if @models.include?('draft_plan') + connections << ' Router --> TacticalBoardsController' if @models.include?('tactical_board') + end + + # Support module routes + if @models.include?('support_ticket') + connections << ' Router --> SupportTicketsController' + connections << ' Router --> SupportFaqsController' + connections << ' Router --> SupportStaffController' + end + + connections.join("\n") + end + + def generate_data_connections + connections = [] + + # Auth connections + if @modules.include?('authentication') + connections << ' AuthController --> JWTService' + connections << ' AuthController --> UserModel' + end + + # Players connections + if @models.include?('player') + connections << ' PlayersController --> PlayerModel' + connections << ' PlayerModel --> ChampionPoolModel' if @models.include?('champion_pool') + end + + # Scouting connections + if @models.include?('scouting_target') + connections << ' ScoutingController --> ScoutingTargetModel' + connections << ' ScoutingController --> Watchlist' + connections << ' Watchlist --> PostgreSQL' + end + + # Matches connections + if @models.include?('match') + connections << ' MatchesController --> MatchModel' + connections << ' MatchModel --> PlayerMatchStatModel' if @models.include?('player_match_stat') + end + + # Other model connections + connections << ' SchedulesController --> ScheduleModel' if @models.include?('schedule') + + if @models.include?('vod_review') + connections << ' VODController --> VodReviewModel' + connections << ' VodReviewModel --> VodTimestampModel' if @models.include?('vod_timestamp') + end + + connections << ' GoalsController --> TeamGoalModel' if @models.include?('team_goal') + + # Analytics connections + if has_analytics_routes? + connections << ' AnalyticsController --> PerformanceService' + connections << ' AnalyticsController --> KDAService' + end + + # Competitive connections + if @modules.include?('competitive') + connections << ' CompetitiveController --> PandaScoreService' + connections << ' CompetitiveController --> DraftAnalyzer' + end + + # Scrims connections + if @modules.include?('scrims') + connections << ' ScrimsController --> ScrimAnalytics' + connections << ' ScrimAnalytics --> PostgreSQL' + end + + # Strategy connections + if @models.include?('draft_plan') + connections << ' DraftPlansController --> DraftAnalysisService' + end + + # Support connections + if @models.include?('support_ticket') + connections << ' SupportTicketsController --> SupportTicketModel' + connections << ' SupportFaqsController --> SupportFaqModel' + connections << ' SupportStaffController --> UserModel' + end + + # Database connections + @models.each do |model| + model_name = model.split('_').map(&:capitalize).join + connections << " #{model_name}Model[#{model_name} Model] --> PostgreSQL" + end + + # Redis connections + connections << ' JWTService --> Redis' if @modules.include?('authentication') + connections << ' DashStats --> Redis' if has_dashboard_routes? + connections << ' PerformanceService --> Redis' if has_analytics_routes? + + connections.join("\n") + end + + def generate_external_connections + connections = [] + + # Riot API connections + if has_riot_integration? + connections << ' PlayersController --> RiotService' + connections << ' MatchesController --> RiotService' + connections << ' ScoutingController --> RiotService' + connections << ' RiotService --> RiotSync' + connections << ' RiotService --> RiotAPI' + connections << '' + connections << ' RiotService --> Sidekiq' + end + + # PandaScore connections + if @modules.include?('competitive') + connections << ' PandaScoreService --> PandaScoreAPI[PandaScore API]' + end + + # Sidekiq connections (simplified) + if has_riot_integration? + connections << ' Sidekiq -- Uses --> Redis' + end + + connections.compact.join("\n") + end + + def has_dashboard_routes? + routes_path = RAILS_ROOT.join('config', 'routes.rb').realpath + validate_path_within_project(routes_path) + routes_content = File.read(routes_path) + routes_content.include?('dashboard') + end + + def has_analytics_routes? + routes_path = RAILS_ROOT.join('config', 'routes.rb').realpath + validate_path_within_project(routes_path) + routes_content = File.read(routes_path) + routes_content.include?('analytics') + end + + def has_riot_integration? + gemfile_path = RAILS_ROOT.join('Gemfile').realpath + validate_path_within_project(gemfile_path) + gemfile = File.read(gemfile_path) + gemfile.include?('faraday') || @services.values.any? { |s| s.include?('riot') } + end + + def validate_path_within_project(path) + rails_root_realpath = RAILS_ROOT.realpath + unless path.to_s.start_with?(rails_root_realpath.to_s) + raise SecurityError, "Path is outside project root: #{path}" + end + end + + def update_readme(diagram) + # Validate README_PATH is within project root + readme_realpath = README_PATH.realpath + validate_path_within_project(readme_realpath) + + content = File.read(readme_realpath) + + # Find the architecture section + arch_start = content.index('## Architecture') + return unless arch_start + + # Find the end of architecture section (next ## heading or end of file) + arch_end = content.index(/^## /, arch_start + 1) || content.length + + # Extract before and after sections + before_arch = content[0...arch_start] + after_arch = content[arch_end..] + + # Build new architecture section + new_arch_section = <<~ARCH + ## Architecture + + This API follows a modular monolith architecture with the following modules: + + - `authentication` - User authentication and authorization + - `dashboard` - Dashboard statistics and metrics + - `players` - Player management and statistics + - `scouting` - Player scouting and talent discovery + - `analytics` - Performance analytics and reporting + - `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 + - `scrims` - Scrim management and opponent team tracking + - `strategy` - Draft planning and tactical board system + - `support` - Support ticket system with staff and FAQ management + + ### Architecture Diagram + + #{diagram} + + **Key Architecture Principles:** + + 1. **Modular Monolith**: Each module is self-contained with its own controllers, models, and services + 2. **API-Only**: Rails configured in API mode for JSON responses + 3. **JWT Authentication**: Stateless authentication using JWT tokens + 4. **Background Processing**: Long-running tasks handled by Sidekiq + 5. **Caching**: Redis used for session management and performance optimization + 6. **External Integration**: Riot Games API integration for real-time data + 7. **Rate Limiting**: Rack::Attack for API rate limiting + 8. **CORS**: Configured for cross-origin requests from frontend + + ARCH + + # Write back to file with validated path + File.write(readme_realpath, before_arch + new_arch_section + after_arch) + end +end + +# Run the generator +ArchitectureDiagramGenerator.new.run diff --git a/security_tests/OWASP_TOP_10_CHECKLIST.md b/security_tests/OWASP_TOP_10_CHECKLIST.md index edcaae0..8f3851c 100644 --- a/security_tests/OWASP_TOP_10_CHECKLIST.md +++ b/security_tests/OWASP_TOP_10_CHECKLIST.md @@ -1,6 +1,318 @@ # OWASP Top 10 Security Checklist - ProStaff API -Comprehensive security checklist based on OWASP Top 10 2021 +Comprehensive security checklist covering both OWASP Top 10 2025 (Release Candidate) and OWASP Top 10 2021. + +--- + +# OWASP Top 10 2025 (Release Candidate) + +## A01:2025 – Broken Access Control + +### Authentication & Authorization + +- [ ] **JWT Token Security** + - [ ] Tokens have expiration time + - [ ] Refresh tokens implemented securely + - [ ] Token blacklist on logout + - [ ] Token stored securely (not in localStorage for frontend) + - [ ] Secret key is strong and environment-specific + +- [ ] **API Authorization** + - [ ] All endpoints require authentication (except public routes) + - [ ] Pundit policies implemented for all resources + - [ ] Organization-scoped queries (`current_organization` check) + - [ ] Role-based access control (admin, coach, analyst, viewer) + - [ ] No IDOR (Insecure Direct Object Reference) vulnerabilities + +- [ ] **Server-Side Request Forgery (SSRF)** (Merged into A01 in 2025) + - [ ] **Riot API Integration** + - [ ] URL validation before requests + - [ ] Whitelist allowed domains + - [ ] No user-controlled URLs + - [ ] Timeout on external requests + - [ ] **Internal Service Protection** + - [ ] No access to localhost + - [ ] No access to private IPs (192.168.*, 10.*, 127.*) + - [ ] No access to metadata endpoints (169.254.169.254) + +- [ ] **Tests** + ```bash + # Manual test + curl -H "Authorization: Bearer INVALID_TOKEN" http://localhost:3333/api/v1/dashboard + # Should return 401 Unauthorized + + # Try accessing another org's data + curl -H "Authorization: Bearer USER_ORG_A_TOKEN" \ + http://localhost:3333/api/v1/players/ORG_B_PLAYER_ID + # Should return 403 Forbidden or 404 Not Found + + # Try SSRF via webhook/callback + curl -X POST http://localhost:3333/api/v1/webhooks \ + -d '{"url":"http://169.254.169.254/latest/meta-data/"}' + # Should be rejected + ``` + +## A02:2025 – Security Misconfiguration + +### Configuration Security + +- [ ] **Rails Configuration** + - [ ] `config.force_ssl = true` in production + - [ ] Debug mode disabled in production + - [ ] Detailed error pages disabled in production + - [ ] Asset host configured for CDN + +- [ ] **Headers** + - [ ] `X-Frame-Options: DENY` + - [ ] `X-Content-Type-Options: nosniff` + - [ ] `X-XSS-Protection: 1; mode=block` + - [ ] `Strict-Transport-Security: max-age=31536000` + - [ ] `Content-Security-Policy` configured + - [ ] `Referrer-Policy: strict-origin-when-cross-origin` + +- [ ] **CORS** + - [ ] Whitelist specific origins, not `*` + - [ ] Credentials allowed only for trusted origins + - [ ] Proper preflight handling + +- [ ] **Tests** + ```bash + # Security headers check + curl -I http://localhost:3333/up | grep -E "X-Frame-Options|X-Content-Type" + + # Brakeman scan + ./security_tests/scripts/brakeman-scan.sh + ``` + +## A03:2025 – Software Supply Chain Failures + +### Dependency & Pipeline Security + +- [ ] **Dependency Management** + - [ ] All gems up to date + - [ ] No known vulnerabilities (Bundle Audit) + - [ ] Unused gems removed + - [ ] `Gemfile.lock` committed and verified + - [ ] Dependabot enabled and monitored + +- [ ] **Build Pipeline Integrity** + - [ ] CI/CD pipelines defined in code + - [ ] Build scripts verified + - [ ] Secrets not exposed in build logs + - [ ] Artifact signing (if applicable) + +- [ ] **Tests** + ```bash + # Check for vulnerabilities + bundle audit check --update + + # List outdated gems + bundle outdated + + # OWASP Dependency Check + docker run --rm -v $(pwd):/src owasp/dependency-check:latest \ + --scan /src --format ALL + ``` + +## A04:2025 – Cryptographic Failures + +### Data Encryption + +- [ ] **Passwords** + - [ ] BCrypt with proper cost factor (12+) + - [ ] No password in logs or error messages + - [ ] Password complexity requirements enforced + +- [ ] **Sensitive Data** + - [ ] API keys encrypted at rest + - [ ] Database encryption for PII + - [ ] HTTPS enforced in production + - [ ] TLS 1.2+ only + +- [ ] **Environment Variables** + - [ ] All secrets in environment variables + - [ ] `.env` file in `.gitignore` + - [ ] No secrets in git history + - [ ] Different secrets per environment + +- [ ] **Tests** + ```bash + # Check for exposed secrets + git log -p | grep -i "api_key\|secret\|password" | grep "+" + + # Scan for secrets in code + docker run --rm -v $(pwd):/src trufflesecurity/trufflehog:latest \ + git file:///src --only-verified + ``` + +## A05:2025 – Injection + +### SQL & Command Injection + +- [ ] **ActiveRecord Queries** + - [ ] No string interpolation in queries + - [ ] Parameterized queries only + - [ ] `.where(id: params[:id])` NOT `.where("id = #{params[:id]}")` + - [ ] Review all raw SQL queries + +- [ ] **Command Injection** + - [ ] No `system()`, `exec()`, backticks with user input + - [ ] If shell commands needed, use `Open3.capture3` with whitelisting + +- [ ] **Tests** + ```bash + # ZAP API scan includes injection tests + ./security_tests/scripts/zap-api-scan.sh + + # Manual SQL injection test + curl -X GET "http://localhost:3333/api/v1/players?name=admin'%20OR%20'1'='1" + # Should NOT return data or error with SQL + ``` + +## A06:2025 – Insecure Design + +### Architecture Security + +- [ ] **Rate Limiting** + - [ ] Rack::Attack configured + - [ ] Login endpoint throttled + - [ ] API endpoints rate limited per user/IP + - [ ] Exponential backoff on failed attempts + +- [ ] **Input Validation** + - [ ] Strong parameters in all controllers + - [ ] Data type validation + - [ ] Length limits on strings + - [ ] Regex validation where needed + +- [ ] **Business Logic** + - [ ] State transitions validated + - [ ] No race conditions in critical operations + - [ ] Idempotency for mutations + - [ ] Transaction locks where needed + +- [ ] **Tests** + ```bash + # Rate limiting test + for i in {1..100}; do + curl -X POST http://localhost:3333/api/v1/auth/login \ + -d '{"email":"test@test.com","password":"wrong"}' & + done + # Should eventually return 429 Too Many Requests + ``` + +## A07:2025 – Authentication Failures + +### Authentication Security + +- [ ] **Password Security** + - [ ] Minimum 8 characters + - [ ] Complexity requirements + - [ ] No common passwords (have_i_been_pwned check) + - [ ] Bcrypt cost factor 12+ + +- [ ] **Session Management** + - [ ] JWT expiration (15 min access, 7 day refresh) + - [ ] Secure session storage (Redis) + - [ ] Session invalidation on logout + - [ ] Session timeout after inactivity + +- [ ] **Account Recovery** + - [ ] Secure password reset flow + - [ ] Time-limited reset tokens + - [ ] Email verification + - [ ] Rate limited reset requests + +- [ ] **Tests** + ```bash + # Weak password test + curl -X POST http://localhost:3333/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@test.com","password":"123"}' + # Should be rejected + ``` + +## A08:2025 – Software or Data Integrity Failures + +### Code Integrity + +- [ ] **CI/CD Security** + - [ ] Signed commits + - [ ] Code review required + - [ ] Branch protection + - [ ] Automated tests pass + +- [ ] **Serialization** + - [ ] No unsafe deserialization + - [ ] JSON parsing only + - [ ] No YAML.load (use YAML.safe_load) + - [ ] No Marshal.load on user input + +- [ ] **Auto-updates** + - [ ] Review before auto-merge + - [ ] Test auto-updated dependencies + - [ ] Pin critical dependencies + +- [ ] **Tests** + ```bash + # Check for unsafe deserialization + grep -r "Marshal.load\|YAML.load" app/ + ``` + +## A09:2025 – Logging & Alerting Failures + +### Logging & Monitoring + +- [ ] **Application Logs** + - [ ] Authentication attempts logged + - [ ] Authorization failures logged + - [ ] Sensitive operations logged + - [ ] No sensitive data in logs (passwords, tokens) + +- [ ] **Audit Trail** + - [ ] AuditLog model tracks changes + - [ ] Who, what, when recorded + - [ ] IP address logged + - [ ] Tamper-proof logs + +- [ ] **Monitoring** + - [ ] Error tracking (Sentry/Rollbar) + - [ ] Performance monitoring (New Relic/Scout) + - [ ] Uptime monitoring + - [ ] Alert on suspicious activity + +- [ ] **Tests** + ```bash + # Check logs don't contain secrets + grep -r "password\|token\|secret" log/ | grep -v "filtered" + ``` + +## A10:2025 – Mishandling of Exceptional Conditions + +### Error Handling & Logic + +- [ ] **Fail Safe** + - [ ] System fails closed (deny access) on error + - [ ] Transactions rolled back on failure + - [ ] Default case in switch/case statements handles unexpected values + +- [ ] **Error Messages** + - [ ] No stack traces in API responses (production) + - [ ] Generic error messages for security failures (e.g., "Invalid credentials" not "User not found") + - [ ] Proper HTTP status codes (400, 401, 403, 404, 500) + +- [ ] **Tests** + ```bash + # Trigger error and check response + curl -H "Content-Type: application/json" \ + -d '{"malformed_json":' \ + http://localhost:3333/api/v1/dashboard + # Should return 400 Bad Request, no stack trace + ``` + +--- + +# OWASP Top 10 2021 ## A01:2021 – Broken Access Control diff --git a/security_tests/docker-compose.security.yml b/security_tests/docker-compose.security.yml index a2e510c..912fb95 100644 --- a/security_tests/docker-compose.security.yml +++ b/security_tests/docker-compose.security.yml @@ -81,4 +81,5 @@ services: networks: security-net: - driver: bridge + external: true + name: prostaff-api_security-net diff --git a/security_tests/scripts/dependency-scan.sh b/security_tests/scripts/dependency-scan.sh index 35c30d4..00a46a7 100644 --- a/security_tests/scripts/dependency-scan.sh +++ b/security_tests/scripts/dependency-scan.sh @@ -34,14 +34,14 @@ fi # Method 2: OWASP Dependency Check (comprehensive) echo -e "${YELLOW}Running OWASP Dependency Check...${NC}" -docker run --rm \ - -v "$(pwd):/src:ro" \ - -v "$(pwd)/$REPORT_DIR:/report:rw" \ - owasp/dependency-check:latest \ - --scan /src/Gemfile.lock \ - --format ALL \ - --project "ProStaff API" \ - --out /report/owasp-${TIMESTAMP} +# docker run --rm \ +# -v "$(pwd):/src:ro" \ +# -v "$(pwd)/$REPORT_DIR:/report:rw" \ +# owasp/dependency-check:latest \ +# --scan /src/Gemfile.lock \ +# --format ALL \ +# --project "ProStaff API" \ +# --out /report/owasp-${TIMESTAMP} echo -e "${GREEN}✅ Dependency scan complete!${NC}" echo "Bundler Audit: $REPORT_DIR/bundler-audit-${TIMESTAMP}.txt" diff --git a/security_tests/scripts/full-security-audit.sh b/security_tests/scripts/full-security-audit.sh index d3cf094..440110b 100644 --- a/security_tests/scripts/full-security-audit.sh +++ b/security_tests/scripts/full-security-audit.sh @@ -43,7 +43,7 @@ echo -e "${GREEN}✅ API is running${NC}\n" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${GREEN}[1/6] Running Brakeman (Rails Security Scanner)${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -"$SCRIPT_DIR/brakeman-scan.sh" +"$SCRIPT_DIR/brakeman-scan.sh" || true cp "$PROJECT_ROOT/security_tests/reports/brakeman/brakeman-"*.{html,json} "$REPORT_DIR/" 2>/dev/null || true echo "" @@ -139,6 +139,44 @@ check_header "Referrer-Policy" "no-referrer or strict-origin-when-cross-origin" echo "" + +# 7. Configuration Security Check +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}[7/7] Checking Rails Configuration${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + +CONFIG_REPORT="$REPORT_DIR/configuration-check.txt" +echo "Configuration Security Analysis" > "$CONFIG_REPORT" +echo "===============================" >> "$CONFIG_REPORT" + +# Check production configuration +PROD_CONFIG="config/environments/production.rb" + +if [ -f "$PROD_CONFIG" ]; then + # Check force_ssl + if grep -q "config.force_ssl = true" "$PROD_CONFIG"; then + echo -e "${GREEN}✅ config.force_ssl is enabled in production${NC}" + echo "[PASS] config.force_ssl is enabled" >> "$CONFIG_REPORT" + else + echo -e "${YELLOW}⚠️ config.force_ssl NOT found enabled in production${NC}" + echo "[WARN] config.force_ssl should be enabled in production" >> "$CONFIG_REPORT" + fi + + # Check consider_all_requests_local + if grep -q "config.consider_all_requests_local = false" "$PROD_CONFIG"; then + echo -e "${GREEN}✅ config.consider_all_requests_local is false in production${NC}" + echo "[PASS] config.consider_all_requests_local is false" >> "$CONFIG_REPORT" + else + echo -e "${YELLOW}⚠️ config.consider_all_requests_local might not be false in production${NC}" + echo "[WARN] config.consider_all_requests_local should be false in production" >> "$CONFIG_REPORT" + fi +else + echo -e "${YELLOW}⚠️ Production config file not found at $PROD_CONFIG${NC}" + echo "[WARN] Production config file not found" >> "$CONFIG_REPORT" +fi + +echo "" + # Generate summary report SUMMARY_REPORT="$REPORT_DIR/SECURITY_AUDIT_SUMMARY.md" diff --git a/security_tests/scripts/zap-api-scan.sh b/security_tests/scripts/zap-api-scan.sh index 2f6a023..6fdca27 100644 --- a/security_tests/scripts/zap-api-scan.sh +++ b/security_tests/scripts/zap-api-scan.sh @@ -29,7 +29,7 @@ mkdir -p "$REPORT_DIR" docker run --rm \ --network=host \ -v "$(pwd)/$REPORT_DIR:/zap/wrk:rw" \ - owasp/zap2docker-stable \ + zaproxy/zap-stable \ zap-api-scan.py \ -t "$API_SPEC" \ -f openapi \ diff --git a/security_tests/scripts/zap-baseline-scan.sh b/security_tests/scripts/zap-baseline-scan.sh index 2d87b13..d476b39 100644 --- a/security_tests/scripts/zap-baseline-scan.sh +++ b/security_tests/scripts/zap-baseline-scan.sh @@ -27,7 +27,7 @@ mkdir -p "$REPORT_DIR" docker run --rm \ --network=host \ -v "$(pwd)/$REPORT_DIR:/zap/wrk:rw" \ - owasp/zap2docker-stable \ + zaproxy/zap-stable \ zap-baseline.py \ -t "$TARGET_URL" \ -g gen.conf \ diff --git a/spec/factories/matches.rb b/spec/factories/matches.rb index ddfc92b..6722624 100644 --- a/spec/factories/matches.rb +++ b/spec/factories/matches.rb @@ -1,15 +1,17 @@ -FactoryBot.define do - factory :match do - association :organization - match_type { %w[official scrim tournament].sample } - game_start { Faker::Time.between(from: 30.days.ago, to: Time.current) } - game_end { game_start + rand(1200..2400).seconds } - game_duration { (game_end - game_start).to_i } - victory { [true, false].sample } - patch_version { "13.#{rand(1..24)}.1" } - opponent_name { Faker::Esport.team } - our_side { %w[blue red].sample } - our_score { rand(5..30) } - opponent_score { rand(5..30) } - end -end +# frozen_string_literal: true + +FactoryBot.define do + factory :match do + association :organization + match_type { %w[official scrim tournament].sample } + game_start { Faker::Time.between(from: 30.days.ago, to: Time.current) } + game_end { game_start + rand(1200..2400).seconds } + game_duration { (game_end - game_start).to_i } + victory { [true, false].sample } + patch_version { "13.#{rand(1..24)}.1" } + opponent_name { Faker::Esport.team } + our_side { %w[blue red].sample } + our_score { rand(5..30) } + opponent_score { rand(5..30) } + end +end diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb index d6a7f36..b05564e 100644 --- a/spec/factories/organizations.rb +++ b/spec/factories/organizations.rb @@ -1,12 +1,10 @@ -FactoryBot.define do - factory :organization do - name { Faker::Esport.team } - slug { name.parameterize } - region { %w[BR NA EUW KR].sample } - tier { %w[amateur semi_pro professional].sample } - primary_color { Faker::Color.hex_color } - secondary_color { Faker::Color.hex_color } - logo_url { Faker::Internet.url } - website_url { Faker::Internet.url } - end -end +# frozen_string_literal: true + +FactoryBot.define do + factory :organization do + name { Faker::Esport.team } + slug { name.parameterize } + region { %w[BR NA EUW KR].sample } + tier { %w[tier_3_amateur tier_2_semi_pro tier_1_professional].sample } + end +end diff --git a/spec/factories/players.rb b/spec/factories/players.rb index 958684e..c000a67 100644 --- a/spec/factories/players.rb +++ b/spec/factories/players.rb @@ -1,18 +1,19 @@ -FactoryBot.define do - factory :player do - association :organization - summoner_name { Faker::Esport.player } - real_name { Faker::Name.name } - role { %w[top jungle mid adc support].sample } - status { 'active' } - jersey_number { rand(1..99) } - birth_date { Faker::Date.birthday(min_age: 18, max_age: 30) } - country { 'BR' } - nationality { 'Brazilian' } - solo_queue_tier { %w[DIAMOND MASTER GRANDMASTER CHALLENGER].sample } - solo_queue_rank { %w[I II III IV].sample } - solo_queue_lp { rand(0..100) } - solo_queue_wins { rand(50..500) } - solo_queue_losses { rand(50..500) } - end -end +# frozen_string_literal: true + +FactoryBot.define do + factory :player do + association :organization + summoner_name { Faker::Esport.player } + real_name { Faker::Name.name } + role { %w[top jungle mid adc support].sample } + status { 'active' } + jersey_number { rand(1..99) } + birth_date { Faker::Date.birthday(min_age: 18, max_age: 30) } + country { 'BR' } + solo_queue_tier { %w[DIAMOND MASTER GRANDMASTER CHALLENGER].sample } + solo_queue_rank { %w[I II III IV].sample } + solo_queue_lp { rand(0..100) } + solo_queue_wins { rand(50..500) } + solo_queue_losses { rand(50..500) } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 3825a8b..740d34f 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -1,32 +1,32 @@ -FactoryBot.define do - factory :user do - association :organization - email { Faker::Internet.email } - password { 'password123' } - password_confirmation { 'password123' } - first_name { Faker::Name.first_name } - last_name { Faker::Name.last_name } - role { 'analyst' } - status { 'active' } - - trait :owner do - role { 'owner' } - end - - trait :admin do - role { 'admin' } - end - - trait :coach do - role { 'coach' } - end - - trait :analyst do - role { 'analyst' } - end - - trait :viewer do - role { 'viewer' } - end - end -end +# frozen_string_literal: true + +FactoryBot.define do + factory :user do + association :organization + email { Faker::Internet.email } + password { 'password123' } + password_confirmation { 'password123' } + full_name { Faker::Name.name } + role { 'analyst' } + + trait :owner do + role { 'owner' } + end + + trait :admin do + role { 'admin' } + end + + trait :coach do + role { 'coach' } + end + + trait :analyst do + role { 'analyst' } + end + + trait :viewer do + role { 'viewer' } + end + end +end diff --git a/spec/factories/vod_reviews.rb b/spec/factories/vod_reviews.rb new file mode 100644 index 0000000..948d414 --- /dev/null +++ b/spec/factories/vod_reviews.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :vod_review do + association :organization + association :reviewer, factory: :user + association :match, factory: :match + + title { Faker::Lorem.sentence(word_count: 3) } + description { Faker::Lorem.paragraph } + review_type { %w[team individual opponent].sample } + review_date { Faker::Time.between(from: 30.days.ago, to: Time.current) } + video_url { "https://www.youtube.com/watch?v=#{Faker::Alphanumeric.alpha(number: 11)}" } + thumbnail_url { Faker::Internet.url } + duration { rand(1800..3600) } + status { 'draft' } + is_public { false } + tags { %w[scrim review analysis].sample(2) } + + trait :published do + status { 'published' } + end + + trait :archived do + status { 'archived' } + end + + trait :public do + is_public { true } + share_link { SecureRandom.urlsafe_base64(16) } + end + + trait :with_timestamps do + after(:create) do |vod_review| + create_list(:vod_timestamp, 3, vod_review: vod_review) + end + end + end +end diff --git a/spec/factories/vod_timestamps.rb b/spec/factories/vod_timestamps.rb new file mode 100644 index 0000000..7b4c589 --- /dev/null +++ b/spec/factories/vod_timestamps.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :vod_timestamp do + association :vod_review + association :target_player, factory: :player + association :created_by, factory: :user + + timestamp_seconds { rand(60..3600) } + title { Faker::Lorem.sentence(word_count: 3) } + description { Faker::Lorem.paragraph } + category { %w[mistake good_play team_fight objective laning].sample } + importance { %w[low normal high critical].sample } + target_type { %w[player team opponent].sample } + + trait :mistake do + category { 'mistake' } + importance { %w[high critical].sample } + end + + trait :good_play do + category { 'good_play' } + end + + trait :critical do + importance { 'critical' } + end + + trait :important do + importance { 'high' } + end + end +end diff --git a/spec/integration/analytics_spec.rb b/spec/integration/analytics_spec.rb new file mode 100644 index 0000000..f98574c --- /dev/null +++ b/spec/integration/analytics_spec.rb @@ -0,0 +1,395 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Analytics API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/analytics/performance' do + get 'Get team performance analytics' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Returns comprehensive team and player performance metrics' + + parameter name: :start_date, in: :query, type: :string, required: false, description: 'Start date (YYYY-MM-DD)' + parameter name: :end_date, in: :query, type: :string, required: false, description: 'End date (YYYY-MM-DD)' + parameter name: :time_period, in: :query, type: :string, required: false, + description: 'Predefined period (week, month, season)' + parameter name: :player_id, in: :query, type: :string, required: false, + description: 'Player ID for individual stats' + + response '200', 'performance data retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + team_overview: { + type: :object, + properties: { + total_matches: { type: :integer }, + wins: { type: :integer }, + losses: { type: :integer }, + win_rate: { type: :number, format: :float }, + avg_game_duration: { type: :integer }, + avg_kda: { type: :number, format: :float }, + avg_kills_per_game: { type: :number, format: :float }, + avg_deaths_per_game: { type: :number, format: :float }, + avg_assists_per_game: { type: :number, format: :float } + } + }, + best_performers: { type: :array }, + win_rate_trend: { type: :array } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/analytics/team-comparison' do + get 'Compare team players performance' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Provides side-by-side comparison of all team players' + + parameter name: :start_date, in: :query, type: :string, required: false, description: 'Start date (YYYY-MM-DD)' + parameter name: :end_date, in: :query, type: :string, required: false, description: 'End date (YYYY-MM-DD)' + + response '200', 'comparison data retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + players: { + type: :array, + items: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + games_played: { type: :integer }, + kda: { type: :number, format: :float }, + avg_damage: { type: :integer }, + avg_gold: { type: :integer }, + avg_cs: { type: :number, format: :float }, + avg_vision_score: { type: :number, format: :float }, + avg_performance_score: { type: :number, format: :float }, + multikills: { + type: :object, + properties: { + double: { type: :integer }, + triple: { type: :integer }, + quadra: { type: :integer }, + penta: { type: :integer } + } + } + } + } + }, + team_averages: { type: :object }, + role_rankings: { type: :object } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/analytics/champions/{player_id}' do + parameter name: :player_id, in: :path, type: :string, description: 'Player ID' + + get 'Get player champion statistics' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Returns champion pool and performance statistics for a specific player' + + response '200', 'champion stats retrieved' do + let(:player_id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + champion_stats: { + type: :array, + items: { + type: :object, + properties: { + champion: { type: :string }, + games_played: { type: :integer }, + win_rate: { type: :number, format: :float }, + avg_kda: { type: :number, format: :float }, + mastery_grade: { type: :string, enum: %w[S A B C D] } + } + } + }, + top_champions: { type: :array }, + champion_diversity: { + type: :object, + properties: { + total_champions: { type: :integer }, + highly_played: { type: :integer }, + average_games: { type: :number, format: :float } + } + } + } + } + } + + run_test! + end + + response '404', 'player not found' do + let(:player_id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/analytics/kda-trend/{player_id}' do + parameter name: :player_id, in: :path, type: :string, description: 'Player ID' + + get 'Get player KDA trend over recent matches' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Shows KDA performance trend for the last 50 matches' + + response '200', 'KDA trend retrieved' do + let(:player_id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + kda_by_match: { + type: :array, + items: { + type: :object, + properties: { + match_id: { type: :string }, + date: { type: :string, format: 'date-time' }, + kills: { type: :integer }, + deaths: { type: :integer }, + assists: { type: :integer }, + kda: { type: :number, format: :float }, + champion: { type: :string }, + victory: { type: :boolean } + } + } + }, + averages: { + type: :object, + properties: { + last_10_games: { type: :number, format: :float }, + last_20_games: { type: :number, format: :float }, + overall: { type: :number, format: :float } + } + } + } + } + } + + run_test! + end + + response '404', 'player not found' do + let(:player_id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/analytics/laning/{player_id}' do + parameter name: :player_id, in: :path, type: :string, description: 'Player ID' + + get 'Get player laning phase statistics' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Returns CS and gold performance metrics for laning phase' + + response '200', 'laning stats retrieved' do + let(:player_id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + cs_performance: { + type: :object, + properties: { + avg_cs_total: { type: :number, format: :float }, + avg_cs_per_min: { type: :number, format: :float }, + best_cs_game: { type: :integer }, + worst_cs_game: { type: :integer } + } + }, + gold_performance: { + type: :object, + properties: { + avg_gold: { type: :integer }, + best_gold_game: { type: :integer }, + worst_gold_game: { type: :integer } + } + }, + cs_by_match: { type: :array } + } + } + } + + run_test! + end + + response '404', 'player not found' do + let(:player_id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/analytics/teamfights/{player_id}' do + parameter name: :player_id, in: :path, type: :string, description: 'Player ID' + + get 'Get player teamfight performance' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Returns damage dealt/taken and teamfight participation metrics' + + response '200', 'teamfight stats retrieved' do + let(:player_id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + damage_performance: { + type: :object, + properties: { + avg_damage_dealt: { type: :integer }, + avg_damage_taken: { type: :integer }, + best_damage_game: { type: :integer }, + avg_damage_per_min: { type: :integer } + } + }, + participation: { + type: :object, + properties: { + avg_kills: { type: :number, format: :float }, + avg_assists: { type: :number, format: :float }, + avg_deaths: { type: :number, format: :float }, + multikill_stats: { + type: :object, + properties: { + double_kills: { type: :integer }, + triple_kills: { type: :integer }, + quadra_kills: { type: :integer }, + penta_kills: { type: :integer } + } + } + } + }, + by_match: { type: :array } + } + } + } + + run_test! + end + + response '404', 'player not found' do + let(:player_id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/analytics/vision/{player_id}' do + parameter name: :player_id, in: :path, type: :string, description: 'Player ID' + + get 'Get player vision control statistics' do + tags 'Analytics' + produces 'application/json' + security [bearerAuth: []] + description 'Returns ward placement, vision score, and vision control metrics' + + response '200', 'vision stats retrieved' do + let(:player_id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + vision_stats: { + type: :object, + properties: { + avg_vision_score: { type: :number, format: :float }, + avg_wards_placed: { type: :number, format: :float }, + avg_wards_killed: { type: :number, format: :float }, + best_vision_game: { type: :integer }, + total_wards_placed: { type: :integer }, + total_wards_killed: { type: :integer } + } + }, + vision_per_min: { type: :number, format: :float }, + by_match: { type: :array }, + role_comparison: { + type: :object, + properties: { + player_avg: { type: :number, format: :float }, + role_avg: { type: :number, format: :float }, + percentile: { type: :integer } + } + } + } + } + } + + run_test! + end + + response '404', 'player not found' do + let(:player_id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end +end diff --git a/spec/integration/authentication_spec.rb b/spec/integration/authentication_spec.rb index d446394..9ebac29 100644 --- a/spec/integration/authentication_spec.rb +++ b/spec/integration/authentication_spec.rb @@ -1,329 +1,331 @@ -require 'swagger_helper' - -RSpec.describe 'Authentication API', type: :request do - path '/api/v1/auth/register' do - post 'Register new organization and admin user' do - tags 'Authentication' - consumes 'application/json' - produces 'application/json' - - parameter name: :registration, in: :body, schema: { - type: :object, - properties: { - organization: { - type: :object, - properties: { - name: { type: :string, example: 'Team Alpha' }, - region: { type: :string, example: 'BR' }, - tier: { type: :string, enum: ['amateur', 'semi_pro', 'professional'], example: 'semi_pro' } - }, - required: ['name', 'region', 'tier'] - }, - user: { - type: :object, - properties: { - email: { type: :string, format: :email, example: 'admin@teamalpha.gg' }, - password: { type: :string, format: :password, example: 'password123' }, - full_name: { type: :string, example: 'John Doe' }, - timezone: { type: :string, example: 'America/Sao_Paulo' }, - language: { type: :string, example: 'pt-BR' } - }, - required: ['email', 'password', 'full_name'] - } - }, - required: ['organization', 'user'] - } - - response '201', 'registration successful' do - schema type: :object, - properties: { - message: { type: :string }, - data: { - type: :object, - properties: { - user: { '$ref' => '#/components/schemas/User' }, - organization: { '$ref' => '#/components/schemas/Organization' }, - access_token: { type: :string }, - refresh_token: { type: :string }, - expires_in: { type: :integer } - } - } - } - - let(:registration) do - { - organization: { - name: 'Team Alpha', - region: 'BR', - tier: 'semi_pro' - }, - user: { - email: 'admin@teamalpha.gg', - password: 'password123', - full_name: 'John Doe', - timezone: 'America/Sao_Paulo', - language: 'pt-BR' - } - } - end - - run_test! - end - - response '422', 'validation errors' do - schema '$ref' => '#/components/schemas/Error' - - let(:registration) do - { - organization: { name: '', region: '', tier: '' }, - user: { email: 'invalid', password: '123' } - } - end - - run_test! - end - end - end - - path '/api/v1/auth/login' do - post 'Login user' do - tags 'Authentication' - consumes 'application/json' - produces 'application/json' - - parameter name: :credentials, in: :body, schema: { - type: :object, - properties: { - email: { type: :string, format: :email, example: 'admin@teamalpha.gg' }, - password: { type: :string, format: :password, example: 'password123' } - }, - required: ['email', 'password'] - } - - response '200', 'login successful' do - schema type: :object, - properties: { - message: { type: :string }, - data: { - type: :object, - properties: { - user: { '$ref' => '#/components/schemas/User' }, - organization: { '$ref' => '#/components/schemas/Organization' }, - access_token: { type: :string }, - refresh_token: { type: :string }, - expires_in: { type: :integer } - } - } - } - - let(:credentials) { { email: user.email, password: 'password123' } } - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization, password: 'password123') } - - run_test! - end - - response '401', 'invalid credentials' do - schema '$ref' => '#/components/schemas/Error' - - let(:credentials) { { email: 'wrong@email.com', password: 'wrong' } } - - run_test! - end - end - end - - path '/api/v1/auth/refresh' do - post 'Refresh access token' do - tags 'Authentication' - consumes 'application/json' - produces 'application/json' - - parameter name: :refresh, in: :body, schema: { - type: :object, - properties: { - refresh_token: { type: :string, example: 'eyJhbGciOiJIUzI1NiJ9...' } - }, - required: ['refresh_token'] - } - - response '200', 'token refreshed successfully' do - schema type: :object, - properties: { - message: { type: :string }, - data: { - type: :object, - properties: { - access_token: { type: :string }, - refresh_token: { type: :string }, - expires_in: { type: :integer } - } - } - } - - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } - let(:tokens) { Authentication::Services::JwtService.generate_tokens(user) } - let(:refresh) { { refresh_token: tokens[:refresh_token] } } - - run_test! - end - - response '401', 'invalid refresh token' do - schema '$ref' => '#/components/schemas/Error' - - let(:refresh) { { refresh_token: 'invalid_token' } } - - run_test! - end - end - end - - path '/api/v1/auth/me' do - get 'Get current user info' do - tags 'Authentication' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'user info retrieved' do - schema type: :object, - properties: { - data: { - type: :object, - properties: { - user: { '$ref' => '#/components/schemas/User' }, - organization: { '$ref' => '#/components/schemas/Organization' } - } - } - } - - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } - let(:Authorization) { "Bearer #{Authentication::Services::JwtService.generate_tokens(user)[:access_token]}" } - - run_test! - end - - response '401', 'unauthorized' do - schema '$ref' => '#/components/schemas/Error' - - let(:Authorization) { 'Bearer invalid_token' } - - run_test! - end - end - end - - path '/api/v1/auth/logout' do - post 'Logout user' do - tags 'Authentication' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'logout successful' do - schema type: :object, - properties: { - message: { type: :string }, - data: { type: :object } - } - - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } - let(:Authorization) { "Bearer #{Authentication::Services::JwtService.generate_tokens(user)[:access_token]}" } - - run_test! - end - end - end - - path '/api/v1/auth/forgot-password' do - post 'Request password reset' do - tags 'Authentication' - consumes 'application/json' - produces 'application/json' - - parameter name: :email_params, in: :body, schema: { - type: :object, - properties: { - email: { type: :string, format: :email, example: 'user@example.com' } - }, - required: ['email'] - } - - response '200', 'password reset email sent' do - schema type: :object, - properties: { - message: { type: :string }, - data: { type: :object } - } - - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } - let(:email_params) { { email: user.email } } - - run_test! - end - end - end - - path '/api/v1/auth/reset-password' do - post 'Reset password with token' do - tags 'Authentication' - consumes 'application/json' - produces 'application/json' - - parameter name: :reset_params, in: :body, schema: { - type: :object, - properties: { - token: { type: :string, example: 'reset_token_here' }, - password: { type: :string, format: :password, example: 'newpassword123' }, - password_confirmation: { type: :string, format: :password, example: 'newpassword123' } - }, - required: ['token', 'password', 'password_confirmation'] - } - - response '200', 'password reset successful' do - schema type: :object, - properties: { - message: { type: :string }, - data: { type: :object } - } - - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } - let(:reset_token) do - payload = { - user_id: user.id, - type: 'password_reset', - exp: 1.hour.from_now.to_i, - iat: Time.current.to_i - } - JWT.encode(payload, Authentication::Services::JwtService::SECRET_KEY, 'HS256') - end - let(:reset_params) do - { - token: reset_token, - password: 'newpassword123', - password_confirmation: 'newpassword123' - } - end - - run_test! - end - - response '400', 'invalid or expired token' do - schema '$ref' => '#/components/schemas/Error' - - let(:reset_params) do - { - token: 'invalid_token', - password: 'newpassword123', - password_confirmation: 'newpassword123' - } - end - - run_test! - end - end - end -end +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Authentication API', type: :request do + path '/api/v1/auth/register' do + post 'Register new organization and admin user' do + tags 'Authentication' + consumes 'application/json' + produces 'application/json' + + parameter name: :registration, in: :body, schema: { + type: :object, + properties: { + organization: { + type: :object, + properties: { + name: { type: :string, example: 'Team Alpha' }, + region: { type: :string, example: 'BR' }, + tier: { type: :string, enum: %w[amateur semi_pro professional], example: 'semi_pro' } + }, + required: %w[name region tier] + }, + user: { + type: :object, + properties: { + email: { type: :string, format: :email, example: 'admin@teamalpha.gg' }, + password: { type: :string, format: :password, example: 'password123' }, + full_name: { type: :string, example: 'John Doe' }, + timezone: { type: :string, example: 'America/Sao_Paulo' }, + language: { type: :string, example: 'pt-BR' } + }, + required: %w[email password full_name] + } + }, + required: %w[organization user] + } + + response '201', 'registration successful' do + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + user: { '$ref' => '#/components/schemas/User' }, + organization: { '$ref' => '#/components/schemas/Organization' }, + access_token: { type: :string }, + refresh_token: { type: :string }, + expires_in: { type: :integer } + } + } + } + + let(:registration) do + { + organization: { + name: 'Team Alpha', + region: 'BR', + tier: 'semi_pro' + }, + user: { + email: 'admin@teamalpha.gg', + password: 'password123', + full_name: 'John Doe', + timezone: 'America/Sao_Paulo', + language: 'pt-BR' + } + } + end + + run_test! + end + + response '422', 'validation errors' do + schema '$ref' => '#/components/schemas/Error' + + let(:registration) do + { + organization: { name: '', region: '', tier: '' }, + user: { email: 'invalid', password: '123' } + } + end + + run_test! + end + end + end + + path '/api/v1/auth/login' do + post 'Login user' do + tags 'Authentication' + consumes 'application/json' + produces 'application/json' + + parameter name: :credentials, in: :body, schema: { + type: :object, + properties: { + email: { type: :string, format: :email, example: 'admin@teamalpha.gg' }, + password: { type: :string, format: :password, example: 'password123' } + }, + required: %w[email password] + } + + response '200', 'login successful' do + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + user: { '$ref' => '#/components/schemas/User' }, + organization: { '$ref' => '#/components/schemas/Organization' }, + access_token: { type: :string }, + refresh_token: { type: :string }, + expires_in: { type: :integer } + } + } + } + + let(:credentials) { { email: user.email, password: 'password123' } } + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization, password: 'password123') } + + run_test! + end + + response '401', 'invalid credentials' do + schema '$ref' => '#/components/schemas/Error' + + let(:credentials) { { email: 'wrong@email.com', password: 'wrong' } } + + run_test! + end + end + end + + path '/api/v1/auth/refresh' do + post 'Refresh access token' do + tags 'Authentication' + consumes 'application/json' + produces 'application/json' + + parameter name: :refresh, in: :body, schema: { + type: :object, + properties: { + refresh_token: { type: :string, example: 'eyJhbGciOiJIUzI1NiJ9...' } + }, + required: ['refresh_token'] + } + + response '200', 'token refreshed successfully' do + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + expires_in: { type: :integer } + } + } + } + + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization) } + let(:tokens) { Authentication::Services::JwtService.generate_tokens(user) } + let(:refresh) { { refresh_token: tokens[:refresh_token] } } + + run_test! + end + + response '401', 'invalid refresh token' do + schema '$ref' => '#/components/schemas/Error' + + let(:refresh) { { refresh_token: 'invalid_token' } } + + run_test! + end + end + end + + path '/api/v1/auth/me' do + get 'Get current user info' do + tags 'Authentication' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'user info retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + user: { '$ref' => '#/components/schemas/User' }, + organization: { '$ref' => '#/components/schemas/Organization' } + } + } + } + + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.generate_tokens(user)[:access_token]}" } + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/Error' + + let(:Authorization) { 'Bearer invalid_token' } + + run_test! + end + end + end + + path '/api/v1/auth/logout' do + post 'Logout user' do + tags 'Authentication' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'logout successful' do + schema type: :object, + properties: { + message: { type: :string }, + data: { type: :object } + } + + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.generate_tokens(user)[:access_token]}" } + + run_test! + end + end + end + + path '/api/v1/auth/forgot-password' do + post 'Request password reset' do + tags 'Authentication' + consumes 'application/json' + produces 'application/json' + + parameter name: :email_params, in: :body, schema: { + type: :object, + properties: { + email: { type: :string, format: :email, example: 'user@example.com' } + }, + required: ['email'] + } + + response '200', 'password reset email sent' do + schema type: :object, + properties: { + message: { type: :string }, + data: { type: :object } + } + + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization) } + let(:email_params) { { email: user.email } } + + run_test! + end + end + end + + path '/api/v1/auth/reset-password' do + post 'Reset password with token' do + tags 'Authentication' + consumes 'application/json' + produces 'application/json' + + parameter name: :reset_params, in: :body, schema: { + type: :object, + properties: { + token: { type: :string, example: 'reset_token_here' }, + password: { type: :string, format: :password, example: 'newpassword123' }, + password_confirmation: { type: :string, format: :password, example: 'newpassword123' } + }, + required: %w[token password password_confirmation] + } + + response '200', 'password reset successful' do + schema type: :object, + properties: { + message: { type: :string }, + data: { type: :object } + } + + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization) } + let(:reset_token) do + payload = { + user_id: user.id, + type: 'password_reset', + exp: 1.hour.from_now.to_i, + iat: Time.current.to_i + } + JWT.encode(payload, Authentication::Services::JwtService::SECRET_KEY, 'HS256') + end + let(:reset_params) do + { + token: reset_token, + password: 'newpassword123', + password_confirmation: 'newpassword123' + } + end + + run_test! + end + + response '400', 'invalid or expired token' do + schema '$ref' => '#/components/schemas/Error' + + let(:reset_params) do + { + token: 'invalid_token', + password: 'newpassword123', + password_confirmation: 'newpassword123' + } + end + + run_test! + end + end + end +end diff --git a/spec/integration/dashboard_spec.rb b/spec/integration/dashboard_spec.rb index bc61219..87688ce 100644 --- a/spec/integration/dashboard_spec.rb +++ b/spec/integration/dashboard_spec.rb @@ -1,158 +1,160 @@ -require 'swagger_helper' - -RSpec.describe 'Dashboard API', type: :request do - let(:organization) { create(:organization) } - let(:user) { create(:user, organization: organization) } - let(:Authorization) { "Bearer #{Authentication::Services::JwtService.generate_tokens(user)[:access_token]}" } - - path '/api/v1/dashboard' do - get 'Get dashboard overview' do - tags 'Dashboard' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'dashboard data retrieved' do - schema type: :object, - properties: { - data: { - type: :object, - properties: { - stats: { - type: :object, - properties: { - total_players: { type: :integer }, - active_players: { type: :integer }, - total_matches: { type: :integer }, - wins: { type: :integer }, - losses: { type: :integer }, - win_rate: { type: :number, format: :float }, - recent_form: { type: :string, example: 'WWLWW' }, - avg_kda: { type: :number, format: :float }, - active_goals: { type: :integer }, - completed_goals: { type: :integer }, - upcoming_matches: { type: :integer } - } - }, - recent_matches: { - type: :array, - items: { '$ref' => '#/components/schemas/Match' } - }, - upcoming_events: { type: :array }, - active_goals: { type: :array }, - roster_status: { - type: :object, - properties: { - by_role: { type: :object }, - by_status: { type: :object }, - contracts_expiring: { type: :integer } - } - } - } - } - } - - run_test! - end - - response '401', 'unauthorized' do - schema '$ref' => '#/components/schemas/Error' - - let(:Authorization) { 'Bearer invalid_token' } - - run_test! - end - end - end - - path '/api/v1/dashboard/stats' do - get 'Get dashboard statistics' do - tags 'Dashboard' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'stats retrieved' do - schema type: :object, - properties: { - data: { - type: :object, - properties: { - total_players: { type: :integer }, - active_players: { type: :integer }, - total_matches: { type: :integer }, - wins: { type: :integer }, - losses: { type: :integer }, - win_rate: { type: :number, format: :float }, - recent_form: { type: :string, example: 'WWLWW' }, - avg_kda: { type: :number, format: :float }, - active_goals: { type: :integer }, - completed_goals: { type: :integer }, - upcoming_matches: { type: :integer } - } - } - } - - run_test! - end - end - end - - path '/api/v1/dashboard/activities' do - get 'Get recent activities' do - tags 'Dashboard' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'activities retrieved' do - schema type: :object, - properties: { - data: { - type: :object, - properties: { - activities: { - type: :array, - items: { - type: :object, - properties: { - id: { type: :string, format: :uuid }, - action: { type: :string }, - entity_type: { type: :string }, - entity_id: { type: :string, format: :uuid }, - user: { type: :string }, - timestamp: { type: :string, format: 'date-time' }, - changes: { type: :object, nullable: true } - } - } - }, - count: { type: :integer } - } - } - } - - run_test! - end - end - end - - path '/api/v1/dashboard/schedule' do - get 'Get upcoming schedule' do - tags 'Dashboard' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'schedule retrieved' do - schema type: :object, - properties: { - data: { - type: :object, - properties: { - events: { type: :array }, - count: { type: :integer } - } - } - } - - run_test! - end - end - end -end +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Dashboard API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.generate_tokens(user)[:access_token]}" } + + path '/api/v1/dashboard' do + get 'Get dashboard overview' do + tags 'Dashboard' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'dashboard data retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + stats: { + type: :object, + properties: { + total_players: { type: :integer }, + active_players: { type: :integer }, + total_matches: { type: :integer }, + wins: { type: :integer }, + losses: { type: :integer }, + win_rate: { type: :number, format: :float }, + recent_form: { type: :string, example: 'WWLWW' }, + avg_kda: { type: :number, format: :float }, + active_goals: { type: :integer }, + completed_goals: { type: :integer }, + upcoming_matches: { type: :integer } + } + }, + recent_matches: { + type: :array, + items: { '$ref' => '#/components/schemas/Match' } + }, + upcoming_events: { type: :array }, + active_goals: { type: :array }, + roster_status: { + type: :object, + properties: { + by_role: { type: :object }, + by_status: { type: :object }, + contracts_expiring: { type: :integer } + } + } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/Error' + + let(:Authorization) { 'Bearer invalid_token' } + + run_test! + end + end + end + + path '/api/v1/dashboard/stats' do + get 'Get dashboard statistics' do + tags 'Dashboard' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'stats retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + total_players: { type: :integer }, + active_players: { type: :integer }, + total_matches: { type: :integer }, + wins: { type: :integer }, + losses: { type: :integer }, + win_rate: { type: :number, format: :float }, + recent_form: { type: :string, example: 'WWLWW' }, + avg_kda: { type: :number, format: :float }, + active_goals: { type: :integer }, + completed_goals: { type: :integer }, + upcoming_matches: { type: :integer } + } + } + } + + run_test! + end + end + end + + path '/api/v1/dashboard/activities' do + get 'Get recent activities' do + tags 'Dashboard' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'activities retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + activities: { + type: :array, + items: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + action: { type: :string }, + entity_type: { type: :string }, + entity_id: { type: :string, format: :uuid }, + user: { type: :string }, + timestamp: { type: :string, format: 'date-time' }, + changes: { type: :object, nullable: true } + } + } + }, + count: { type: :integer } + } + } + } + + run_test! + end + end + end + + path '/api/v1/dashboard/schedule' do + get 'Get upcoming schedule' do + tags 'Dashboard' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'schedule retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + events: { type: :array }, + count: { type: :integer } + } + } + } + + run_test! + end + end + end +end diff --git a/spec/integration/matches_spec.rb b/spec/integration/matches_spec.rb new file mode 100644 index 0000000..7e42db7 --- /dev/null +++ b/spec/integration/matches_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Matches API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/matches' do + get 'List all matches' do + tags 'Matches' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' + parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' + parameter name: :match_type, in: :query, type: :string, required: false, + description: 'Filter by match type (official, scrim, tournament)' + parameter name: :result, in: :query, type: :string, required: false, + description: 'Filter by result (victory, defeat)' + parameter name: :start_date, in: :query, type: :string, required: false, + description: 'Start date for filtering (YYYY-MM-DD)' + parameter name: :end_date, in: :query, type: :string, required: false, + description: 'End date for filtering (YYYY-MM-DD)' + parameter name: :days, in: :query, type: :integer, required: false, + description: 'Filter recent matches (e.g., 7, 30, 90 days)' + parameter name: :opponent, in: :query, type: :string, required: false, description: 'Filter by opponent name' + parameter name: :tournament, in: :query, type: :string, required: false, description: 'Filter by tournament name' + parameter name: :sort_by, in: :query, type: :string, required: false, + description: 'Sort field (game_start, game_duration, match_type, victory)' + parameter name: :sort_order, in: :query, type: :string, required: false, description: 'Sort order (asc, desc)' + + response '200', 'matches found' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + matches: { + type: :array, + items: { '$ref' => '#/components/schemas/Match' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' }, + summary: { + type: :object, + properties: { + total: { type: :integer }, + victories: { type: :integer }, + defeats: { type: :integer }, + win_rate: { type: :number, format: :float }, + by_type: { type: :object }, + avg_duration: { type: :integer } + } + } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + post 'Create a match' do + tags 'Matches' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :match, in: :body, schema: { + type: :object, + properties: { + match: { + type: :object, + properties: { + match_type: { type: :string, enum: %w[official scrim tournament] }, + game_start: { type: :string, format: 'date-time' }, + game_end: { type: :string, format: 'date-time' }, + game_duration: { type: :integer, description: 'Duration in seconds' }, + opponent_name: { type: :string }, + opponent_tag: { type: :string }, + victory: { type: :boolean }, + our_side: { type: :string, enum: %w[blue red] }, + our_score: { type: :integer }, + opponent_score: { type: :integer }, + tournament_name: { type: :string }, + stage: { type: :string }, + patch_version: { type: :string }, + vod_url: { type: :string }, + notes: { type: :string } + }, + required: %w[match_type game_start victory] + } + } + } + + response '201', 'match created' do + let(:match) do + { + match: { + match_type: 'scrim', + game_start: Time.current.iso8601, + victory: true, + our_score: 1, + opponent_score: 0, + opponent_name: 'Enemy Team' + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + match: { '$ref' => '#/components/schemas/Match' } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:match) { { match: { match_type: 'invalid' } } } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/matches/{id}' do + parameter name: :id, in: :path, type: :string, description: 'Match ID' + + get 'Show match details' do + tags 'Matches' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'match found' do + let(:id) { create(:match, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + match: { '$ref' => '#/components/schemas/Match' }, + player_stats: { + type: :array, + items: { '$ref' => '#/components/schemas/PlayerMatchStat' } + }, + team_composition: { type: :object }, + mvp: { '$ref' => '#/components/schemas/Player', nullable: true } + } + } + } + + run_test! + end + + response '404', 'match not found' do + let(:id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + patch 'Update a match' do + tags 'Matches' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :match, in: :body, schema: { + type: :object, + properties: { + match: { + type: :object, + properties: { + match_type: { type: :string }, + victory: { type: :boolean }, + notes: { type: :string }, + vod_url: { type: :string } + } + } + } + } + + response '200', 'match updated' do + let(:id) { create(:match, organization: organization).id } + let(:match) { { match: { notes: 'Updated notes' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + match: { '$ref' => '#/components/schemas/Match' } + } + } + } + + run_test! + end + end + + delete 'Delete a match' do + tags 'Matches' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'match deleted' do + let(:user) { create(:user, :owner, organization: organization) } + let(:id) { create(:match, organization: organization).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end + + path '/api/v1/matches/{id}/stats' do + parameter name: :id, in: :path, type: :string, description: 'Match ID' + + get 'Get match statistics' do + tags 'Matches' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'statistics retrieved' do + let(:id) { create(:match, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + match: { '$ref' => '#/components/schemas/Match' }, + team_stats: { + type: :object, + properties: { + total_kills: { type: :integer }, + total_deaths: { type: :integer }, + total_assists: { type: :integer }, + total_gold: { type: :integer }, + total_damage: { type: :integer }, + total_cs: { type: :integer }, + total_vision_score: { type: :integer }, + avg_kda: { type: :number, format: :float } + } + } + } + } + } + + run_test! + end + end + end + + path '/api/v1/matches/import' do + post 'Import matches from Riot API' do + tags 'Matches' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :import_params, in: :body, schema: { + type: :object, + properties: { + player_id: { type: :string, description: 'Player ID to import matches for' }, + count: { type: :integer, description: 'Number of matches to import (1-100)', default: 20 } + }, + required: %w[player_id] + } + + response '200', 'import started' do + let(:player) { create(:player, organization: organization, riot_puuid: 'test-puuid') } + let(:import_params) { { player_id: player.id, count: 10 } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + job_id: { type: :string }, + player_id: { type: :string }, + count: { type: :integer } + } + } + } + + run_test! + end + + response '400', 'player missing PUUID' do + let(:player) { create(:player, organization: organization, riot_puuid: nil) } + let(:import_params) { { player_id: player.id } } + + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end +end diff --git a/spec/integration/players_spec.rb b/spec/integration/players_spec.rb index 0ec43b2..1cdd691 100644 --- a/spec/integration/players_spec.rb +++ b/spec/integration/players_spec.rb @@ -1,222 +1,225 @@ -require 'swagger_helper' - -RSpec.describe 'Players API', type: :request do - let(:organization) { create(:organization) } - let(:user) { create(:user, :admin, organization: organization) } - let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } - - path '/api/v1/players' do - get 'List all players' do - tags 'Players' - produces 'application/json' - security [bearerAuth: []] - - parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' - parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' - parameter name: :role, in: :query, type: :string, required: false, description: 'Filter by role' - parameter name: :status, in: :query, type: :string, required: false, description: 'Filter by status' - parameter name: :search, in: :query, type: :string, required: false, description: 'Search by summoner name or real name' - - response '200', 'players found' do - schema type: :object, - properties: { - data: { - type: :object, - properties: { - players: { - type: :array, - items: { '$ref' => '#/components/schemas/Player' } - }, - pagination: { '$ref' => '#/components/schemas/Pagination' } - } - } - } - - run_test! - end - - response '401', 'unauthorized' do - let(:Authorization) { nil } - schema '$ref' => '#/components/schemas/Error' - run_test! - end - end - - post 'Create a player' do - tags 'Players' - consumes 'application/json' - produces 'application/json' - security [bearerAuth: []] - - parameter name: :player, in: :body, schema: { - type: :object, - properties: { - player: { - type: :object, - properties: { - summoner_name: { type: :string }, - real_name: { type: :string }, - role: { type: :string, enum: %w[top jungle mid adc support] }, - status: { type: :string, enum: %w[active inactive benched trial] }, - jersey_number: { type: :integer }, - birth_date: { type: :string, format: :date }, - country: { type: :string } - }, - required: %w[summoner_name role] - } - } - } - - response '201', 'player created' do - let(:player) do - { - player: { - summoner_name: 'TestPlayer', - real_name: 'Test User', - role: 'mid', - status: 'active' - } - } - end - - schema type: :object, - properties: { - message: { type: :string }, - data: { - type: :object, - properties: { - player: { '$ref' => '#/components/schemas/Player' } - } - } - } - - run_test! - end - - response '422', 'invalid request' do - let(:player) { { player: { summoner_name: '' } } } - schema '$ref' => '#/components/schemas/Error' - run_test! - end - end - end - - path '/api/v1/players/{id}' do - parameter name: :id, in: :path, type: :string, description: 'Player ID' - - get 'Show player details' do - tags 'Players' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'player found' do - let(:id) { create(:player, organization: organization).id } - - schema type: :object, - properties: { - data: { - type: :object, - properties: { - player: { '$ref' => '#/components/schemas/Player' } - } - } - } - - run_test! - end - - response '404', 'player not found' do - let(:id) { '99999' } - schema '$ref' => '#/components/schemas/Error' - run_test! - end - end - - patch 'Update a player' do - tags 'Players' - consumes 'application/json' - produces 'application/json' - security [bearerAuth: []] - - parameter name: :player, in: :body, schema: { - type: :object, - properties: { - player: { - type: :object, - properties: { - summoner_name: { type: :string }, - real_name: { type: :string }, - status: { type: :string } - } - } - } - } - - response '200', 'player updated' do - let(:id) { create(:player, organization: organization).id } - let(:player) { { player: { summoner_name: 'UpdatedName' } } } - - schema type: :object, - properties: { - message: { type: :string }, - data: { - type: :object, - properties: { - player: { '$ref' => '#/components/schemas/Player' } - } - } - } - - run_test! - end - end - - delete 'Delete a player' do - tags 'Players' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'player deleted' do - let(:user) { create(:user, :owner, organization: organization) } - let(:id) { create(:player, organization: organization).id } - - schema type: :object, - properties: { - message: { type: :string } - } - - run_test! - end - end - end - - path '/api/v1/players/{id}/stats' do - parameter name: :id, in: :path, type: :string, description: 'Player ID' - - get 'Get player statistics' do - tags 'Players' - produces 'application/json' - security [bearerAuth: []] - - response '200', 'statistics retrieved' do - let(:id) { create(:player, organization: organization).id } - - schema type: :object, - properties: { - data: { - type: :object, - properties: { - player: { '$ref' => '#/components/schemas/Player' }, - overall: { type: :object }, - recent_form: { type: :object }, - champion_pool: { type: :array }, - performance_by_role: { type: :array } - } - } - } - - run_test! - end - end - end -end +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Players API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/players' do + get 'List all players' do + tags 'Players' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' + parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' + parameter name: :role, in: :query, type: :string, required: false, description: 'Filter by role' + parameter name: :status, in: :query, type: :string, required: false, description: 'Filter by status' + parameter name: :search, in: :query, type: :string, required: false, + description: 'Search by summoner name or real name' + + response '200', 'players found' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + players: { + type: :array, + items: { '$ref' => '#/components/schemas/Player' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + post 'Create a player' do + tags 'Players' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :player, in: :body, schema: { + type: :object, + properties: { + player: { + type: :object, + properties: { + summoner_name: { type: :string }, + real_name: { type: :string }, + role: { type: :string, enum: %w[top jungle mid adc support] }, + status: { type: :string, enum: %w[active inactive benched trial] }, + jersey_number: { type: :integer }, + birth_date: { type: :string, format: :date }, + country: { type: :string } + }, + required: %w[summoner_name role] + } + } + } + + response '201', 'player created' do + let(:player) do + { + player: { + summoner_name: 'TestPlayer', + real_name: 'Test User', + role: 'mid', + status: 'active' + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:player) { { player: { summoner_name: '' } } } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/players/{id}' do + parameter name: :id, in: :path, type: :string, description: 'Player ID' + + get 'Show player details' do + tags 'Players' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'player found' do + let(:id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' } + } + } + } + + run_test! + end + + response '404', 'player not found' do + let(:id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + patch 'Update a player' do + tags 'Players' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :player, in: :body, schema: { + type: :object, + properties: { + player: { + type: :object, + properties: { + summoner_name: { type: :string }, + real_name: { type: :string }, + status: { type: :string } + } + } + } + } + + response '200', 'player updated' do + let(:id) { create(:player, organization: organization).id } + let(:player) { { player: { summoner_name: 'UpdatedName' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' } + } + } + } + + run_test! + end + end + + delete 'Delete a player' do + tags 'Players' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'player deleted' do + let(:user) { create(:user, :owner, organization: organization) } + let(:id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end + + path '/api/v1/players/{id}/stats' do + parameter name: :id, in: :path, type: :string, description: 'Player ID' + + get 'Get player statistics' do + tags 'Players' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'statistics retrieved' do + let(:id) { create(:player, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + player: { '$ref' => '#/components/schemas/Player' }, + overall: { type: :object }, + recent_form: { type: :object }, + champion_pool: { type: :array }, + performance_by_role: { type: :array } + } + } + } + + run_test! + end + end + end +end diff --git a/spec/integration/riot_data_spec.rb b/spec/integration/riot_data_spec.rb new file mode 100644 index 0000000..bd1cb24 --- /dev/null +++ b/spec/integration/riot_data_spec.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Riot Data API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/riot-data/champions' do + get 'Get champions ID map' do + tags 'Riot Data' + produces 'application/json' + + response '200', 'champions retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + champions: { type: :object }, + count: { type: :integer } + } + } + } + + run_test! + end + + response '503', 'service unavailable' do + schema '$ref' => '#/components/schemas/Error' + + before do + allow_any_instance_of(DataDragonService).to receive(:champion_id_map) + .and_raise(DataDragonService::DataDragonError.new('API unavailable')) + end + + run_test! + end + end + end + + path '/api/v1/riot-data/champions/{champion_key}' do + parameter name: :champion_key, in: :path, type: :string, description: 'Champion key (e.g., "266" for Aatrox)' + + get 'Get champion details by key' do + tags 'Riot Data' + produces 'application/json' + + response '200', 'champion found' do + let(:champion_key) { '266' } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + champion: { type: :object } + } + } + } + + run_test! + end + + response '404', 'champion not found' do + let(:champion_key) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/riot-data/all-champions' do + get 'Get all champions details' do + tags 'Riot Data' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'champions retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + champions: { + type: :array, + items: { type: :object } + }, + count: { type: :integer } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/riot-data/items' do + get 'Get all items' do + tags 'Riot Data' + produces 'application/json' + + response '200', 'items retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + items: { type: :object }, + count: { type: :integer } + } + } + } + + run_test! + end + + response '503', 'service unavailable' do + schema '$ref' => '#/components/schemas/Error' + + before do + allow_any_instance_of(DataDragonService).to receive(:items) + .and_raise(DataDragonService::DataDragonError.new('API unavailable')) + end + + run_test! + end + end + end + + path '/api/v1/riot-data/summoner-spells' do + get 'Get all summoner spells' do + tags 'Riot Data' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'summoner spells retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + summoner_spells: { type: :object }, + count: { type: :integer } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/riot-data/version' do + get 'Get current Data Dragon version' do + tags 'Riot Data' + produces 'application/json' + + response '200', 'version retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + version: { type: :string } + } + } + } + + run_test! + end + + response '503', 'service unavailable' do + schema '$ref' => '#/components/schemas/Error' + + before do + allow_any_instance_of(DataDragonService).to receive(:latest_version) + .and_raise(DataDragonService::DataDragonError.new('API unavailable')) + end + + run_test! + end + end + end + + path '/api/v1/riot-data/clear-cache' do + post 'Clear Data Dragon cache' do + tags 'Riot Data' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'cache cleared' do + let(:user) { create(:user, :owner, organization: organization) } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + message: { type: :string } + } + } + } + + run_test! + end + + response '403', 'forbidden' do + let(:user) { create(:user, :member, organization: organization) } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/riot-data/update-cache' do + post 'Update Data Dragon cache' do + tags 'Riot Data' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'cache updated' do + let(:user) { create(:user, :owner, organization: organization) } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + message: { type: :string }, + version: { type: :string }, + data: { + type: :object, + properties: { + champions: { type: :integer }, + items: { type: :integer }, + summoner_spells: { type: :integer } + } + } + } + } + } + + run_test! + end + + response '403', 'forbidden' do + let(:user) { create(:user, :member, organization: organization) } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end +end diff --git a/spec/integration/riot_integration_spec.rb b/spec/integration/riot_integration_spec.rb new file mode 100644 index 0000000..4d74813 --- /dev/null +++ b/spec/integration/riot_integration_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Riot Integration API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/riot-integration/sync-status' do + get 'Get Riot API synchronization status' do + tags 'Riot Integration' + produces 'application/json' + security [bearerAuth: []] + description 'Returns statistics about player data synchronization with Riot API' + + response '200', 'sync status retrieved' do + before do + create(:player, organization: organization, sync_status: 'success', last_sync_at: 1.hour.ago) + create(:player, organization: organization, sync_status: 'pending', last_sync_at: nil) + create(:player, organization: organization, sync_status: 'error', last_sync_at: 2.hours.ago) + end + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + stats: { + type: :object, + properties: { + total_players: { type: :integer, description: 'Total number of players in the organization' }, + synced_players: { type: :integer, description: 'Players successfully synced' }, + pending_sync: { type: :integer, description: 'Players pending synchronization' }, + failed_sync: { type: :integer, description: 'Players with failed sync' }, + recently_synced: { type: :integer, description: 'Players synced in the last 24 hours' }, + needs_sync: { type: :integer, description: 'Players that need to be synced' } + } + }, + recent_syncs: { + type: :array, + description: 'List of 10 most recently synced players', + items: { + type: :object, + properties: { + id: { type: :string }, + summoner_name: { type: :string }, + last_sync_at: { type: :string, format: 'date-time' }, + sync_status: { type: :string, enum: %w[pending success error] } + } + } + } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end +end diff --git a/spec/integration/schedules_spec.rb b/spec/integration/schedules_spec.rb new file mode 100644 index 0000000..013b9de --- /dev/null +++ b/spec/integration/schedules_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Schedules API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/schedules' do + get 'List all schedules' do + tags 'Schedules' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' + parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' + parameter name: :event_type, in: :query, type: :string, required: false, + description: 'Filter by event type (match, scrim, practice, meeting, other)' + parameter name: :status, in: :query, type: :string, required: false, + description: 'Filter by status (scheduled, ongoing, completed, cancelled)' + parameter name: :start_date, in: :query, type: :string, required: false, + description: 'Start date for filtering (YYYY-MM-DD)' + parameter name: :end_date, in: :query, type: :string, required: false, + description: 'End date for filtering (YYYY-MM-DD)' + parameter name: :upcoming, in: :query, type: :boolean, required: false, description: 'Filter upcoming events' + parameter name: :past, in: :query, type: :boolean, required: false, description: 'Filter past events' + parameter name: :today, in: :query, type: :boolean, required: false, description: 'Filter today\'s events' + parameter name: :this_week, in: :query, type: :boolean, required: false, description: 'Filter this week\'s events' + parameter name: :sort_order, in: :query, type: :string, required: false, description: 'Sort order (asc, desc)' + + response '200', 'schedules found' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + schedules: { + type: :array, + items: { '$ref' => '#/components/schemas/Schedule' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + post 'Create a schedule' do + tags 'Schedules' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :schedule, in: :body, schema: { + type: :object, + properties: { + schedule: { + type: :object, + properties: { + event_type: { type: :string, enum: %w[match scrim practice meeting other] }, + title: { type: :string }, + description: { type: :string }, + start_time: { type: :string, format: 'date-time' }, + end_time: { type: :string, format: 'date-time' }, + location: { type: :string }, + opponent_name: { type: :string }, + status: { type: :string, enum: %w[scheduled ongoing completed cancelled], default: 'scheduled' }, + match_id: { type: :string }, + meeting_url: { type: :string }, + all_day: { type: :boolean }, + timezone: { type: :string }, + color: { type: :string }, + is_recurring: { type: :boolean }, + recurrence_rule: { type: :string }, + recurrence_end_date: { type: :string, format: 'date' }, + reminder_minutes: { type: :integer }, + required_players: { type: :array, items: { type: :string } }, + optional_players: { type: :array, items: { type: :string } }, + tags: { type: :array, items: { type: :string } } + }, + required: %w[event_type title start_time end_time] + } + } + } + + response '201', 'schedule created' do + let(:schedule) do + { + schedule: { + event_type: 'scrim', + title: 'Scrim vs Team X', + start_time: 2.days.from_now.iso8601, + end_time: 2.days.from_now.advance(hours: 2).iso8601, + status: 'scheduled' + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + schedule: { '$ref' => '#/components/schemas/Schedule' } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:schedule) { { schedule: { event_type: 'invalid' } } } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/schedules/{id}' do + parameter name: :id, in: :path, type: :string, description: 'Schedule ID' + + get 'Show schedule details' do + tags 'Schedules' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'schedule found' do + let(:id) { create(:schedule, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + schedule: { '$ref' => '#/components/schemas/Schedule' } + } + } + } + + run_test! + end + + response '404', 'schedule not found' do + let(:id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + patch 'Update a schedule' do + tags 'Schedules' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :schedule, in: :body, schema: { + type: :object, + properties: { + schedule: { + type: :object, + properties: { + title: { type: :string }, + description: { type: :string }, + status: { type: :string }, + location: { type: :string }, + meeting_url: { type: :string } + } + } + } + } + + response '200', 'schedule updated' do + let(:id) { create(:schedule, organization: organization).id } + let(:schedule) { { schedule: { title: 'Updated Title' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + schedule: { '$ref' => '#/components/schemas/Schedule' } + } + } + } + + run_test! + end + end + + delete 'Delete a schedule' do + tags 'Schedules' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'schedule deleted' do + let(:id) { create(:schedule, organization: organization).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end +end diff --git a/spec/integration/scouting_spec.rb b/spec/integration/scouting_spec.rb new file mode 100644 index 0000000..eaa9b86 --- /dev/null +++ b/spec/integration/scouting_spec.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Scouting API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/scouting/players' do + get 'List all scouting targets' do + tags 'Scouting' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' + parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' + parameter name: :role, in: :query, type: :string, required: false, + description: 'Filter by role (top, jungle, mid, adc, support)' + parameter name: :status, in: :query, type: :string, required: false, + description: 'Filter by status (watching, contacted, negotiating, rejected, signed)' + parameter name: :priority, in: :query, type: :string, required: false, + description: 'Filter by priority (low, medium, high, critical)' + parameter name: :region, in: :query, type: :string, required: false, description: 'Filter by region' + parameter name: :active, in: :query, type: :boolean, required: false, description: 'Filter active targets only' + parameter name: :high_priority, in: :query, type: :boolean, required: false, + description: 'Filter high priority targets only' + parameter name: :needs_review, in: :query, type: :boolean, required: false, + description: 'Filter targets needing review' + parameter name: :assigned_to_id, in: :query, type: :string, required: false, + description: 'Filter by assigned user' + parameter name: :search, in: :query, type: :string, required: false, + description: 'Search by summoner name or real name' + parameter name: :sort_by, in: :query, type: :string, required: false, description: 'Sort field' + parameter name: :sort_order, in: :query, type: :string, required: false, description: 'Sort order (asc, desc)' + + response '200', 'scouting targets found' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + players: { + type: :array, + items: { '$ref' => '#/components/schemas/ScoutingTarget' } + }, + total: { type: :integer }, + page: { type: :integer }, + per_page: { type: :integer }, + total_pages: { type: :integer } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + post 'Create a scouting target' do + tags 'Scouting' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :scouting_target, in: :body, schema: { + type: :object, + properties: { + scouting_target: { + type: :object, + properties: { + summoner_name: { type: :string }, + real_name: { type: :string }, + role: { type: :string, enum: %w[top jungle mid adc support] }, + region: { type: :string, enum: %w[BR NA EUW KR EUNE LAN LAS OCE RU TR JP] }, + nationality: { type: :string }, + age: { type: :integer }, + status: { type: :string, enum: %w[watching contacted negotiating rejected signed], default: 'watching' }, + priority: { type: :string, enum: %w[low medium high critical], default: 'medium' }, + current_team: { type: :string }, + email: { type: :string, format: :email }, + phone: { type: :string }, + discord_username: { type: :string }, + twitter_handle: { type: :string }, + scouting_notes: { type: :string }, + contact_notes: { type: :string }, + availability: { type: :string }, + salary_expectations: { type: :string }, + assigned_to_id: { type: :string } + }, + required: %w[summoner_name region role] + } + } + } + + response '201', 'scouting target created' do + let(:scouting_target) do + { + scouting_target: { + summoner_name: 'ProPlayer123', + real_name: 'João Silva', + role: 'mid', + region: 'BR', + priority: 'high', + status: 'watching' + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + scouting_target: { '$ref' => '#/components/schemas/ScoutingTarget' } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:scouting_target) { { scouting_target: { summoner_name: '' } } } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/scouting/players/{id}' do + parameter name: :id, in: :path, type: :string, description: 'Scouting Target ID' + + get 'Show scouting target details' do + tags 'Scouting' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'scouting target found' do + let(:id) { create(:scouting_target, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + scouting_target: { '$ref' => '#/components/schemas/ScoutingTarget' } + } + } + } + + run_test! + end + + response '404', 'scouting target not found' do + let(:id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + patch 'Update a scouting target' do + tags 'Scouting' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :scouting_target, in: :body, schema: { + type: :object, + properties: { + scouting_target: { + type: :object, + properties: { + status: { type: :string }, + priority: { type: :string }, + scouting_notes: { type: :string }, + contact_notes: { type: :string } + } + } + } + } + + response '200', 'scouting target updated' do + let(:id) { create(:scouting_target, organization: organization).id } + let(:scouting_target) { { scouting_target: { status: 'contacted', priority: 'critical' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + scouting_target: { '$ref' => '#/components/schemas/ScoutingTarget' } + } + } + } + + run_test! + end + end + + delete 'Delete a scouting target' do + tags 'Scouting' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'scouting target deleted' do + let(:user) { create(:user, :owner, organization: organization) } + let(:id) { create(:scouting_target, organization: organization).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end + + path '/api/v1/scouting/regions' do + get 'Get scouting statistics by region' do + tags 'Scouting' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'regional statistics retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + regions: { + type: :array, + items: { + type: :object, + properties: { + region: { type: :string }, + total_targets: { type: :integer }, + by_status: { type: :object }, + by_priority: { type: :object }, + avg_tier: { type: :string } + } + } + } + } + } + } + + run_test! + end + end + end + + path '/api/v1/scouting/watchlist' do + get 'Get watchlist (active scouting targets)' do + tags 'Scouting' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :assigned_to_me, in: :query, type: :boolean, required: false, + description: 'Filter targets assigned to current user' + + response '200', 'watchlist retrieved' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + watchlist: { + type: :array, + items: { '$ref' => '#/components/schemas/ScoutingTarget' } + }, + stats: { + type: :object, + properties: { + total: { type: :integer }, + needs_review: { type: :integer }, + high_priority: { type: :integer } + } + } + } + } + } + + run_test! + end + end + end +end diff --git a/spec/integration/team_goals_spec.rb b/spec/integration/team_goals_spec.rb new file mode 100644 index 0000000..4d26656 --- /dev/null +++ b/spec/integration/team_goals_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Team Goals API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/team-goals' do + get 'List all team goals' do + tags 'Team Goals' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' + parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' + parameter name: :status, in: :query, type: :string, required: false, + description: 'Filter by status (not_started, in_progress, completed, cancelled)' + parameter name: :category, in: :query, type: :string, required: false, + description: 'Filter by category (performance, training, tournament, development, team_building, other)' + parameter name: :player_id, in: :query, type: :string, required: false, description: 'Filter by player ID' + parameter name: :type, in: :query, type: :string, required: false, description: 'Filter by type (team, player)' + parameter name: :active, in: :query, type: :boolean, required: false, description: 'Filter active goals only' + parameter name: :overdue, in: :query, type: :boolean, required: false, description: 'Filter overdue goals only' + parameter name: :expiring_soon, in: :query, type: :boolean, required: false, + description: 'Filter goals expiring soon' + parameter name: :expiring_days, in: :query, type: :integer, required: false, + description: 'Days threshold for expiring soon (default: 7)' + parameter name: :assigned_to_id, in: :query, type: :string, required: false, + description: 'Filter by assigned user ID' + parameter name: :sort_by, in: :query, type: :string, required: false, + description: 'Sort field (created_at, updated_at, title, status, category, start_date, end_date, progress)' + parameter name: :sort_order, in: :query, type: :string, required: false, description: 'Sort order (asc, desc)' + + response '200', 'team goals found' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + goals: { + type: :array, + items: { '$ref' => '#/components/schemas/TeamGoal' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' }, + summary: { + type: :object, + properties: { + total: { type: :integer }, + by_status: { type: :object }, + by_category: { type: :object }, + active_count: { type: :integer }, + completed_count: { type: :integer }, + overdue_count: { type: :integer }, + avg_progress: { type: :number, format: :float } + } + } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + post 'Create a team goal' do + tags 'Team Goals' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :team_goal, in: :body, schema: { + type: :object, + properties: { + team_goal: { + type: :object, + properties: { + title: { type: :string }, + description: { type: :string }, + category: { type: :string, enum: %w[performance training tournament development team_building other] }, + metric_type: { type: :string, enum: %w[percentage number kda win_rate rank other] }, + target_value: { type: :number, format: :float }, + current_value: { type: :number, format: :float }, + start_date: { type: :string, format: 'date' }, + end_date: { type: :string, format: 'date' }, + status: { type: :string, enum: %w[not_started in_progress completed cancelled], default: 'not_started' }, + progress: { type: :integer, description: 'Progress percentage (0-100)' }, + notes: { type: :string }, + player_id: { type: :string, description: 'Player ID if this is a player-specific goal' }, + assigned_to_id: { type: :string, description: 'User ID responsible for tracking this goal' } + }, + required: %w[title category metric_type target_value start_date end_date] + } + } + } + + response '201', 'team goal created' do + let(:team_goal) do + { + team_goal: { + title: 'Improve team KDA to 3.0', + description: 'Focus on reducing deaths and improving team coordination', + category: 'performance', + metric_type: 'kda', + target_value: 3.0, + current_value: 2.5, + start_date: Date.current.iso8601, + end_date: 1.month.from_now.to_date.iso8601, + status: 'in_progress', + progress: 50 + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + goal: { '$ref' => '#/components/schemas/TeamGoal' } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:team_goal) { { team_goal: { title: '' } } } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/team-goals/{id}' do + parameter name: :id, in: :path, type: :string, description: 'Team Goal ID' + + get 'Show team goal details' do + tags 'Team Goals' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'team goal found' do + let(:id) { create(:team_goal, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + goal: { '$ref' => '#/components/schemas/TeamGoal' } + } + } + } + + run_test! + end + + response '404', 'team goal not found' do + let(:id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + patch 'Update a team goal' do + tags 'Team Goals' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :team_goal, in: :body, schema: { + type: :object, + properties: { + team_goal: { + type: :object, + properties: { + title: { type: :string }, + description: { type: :string }, + status: { type: :string }, + current_value: { type: :number, format: :float }, + progress: { type: :integer }, + notes: { type: :string } + } + } + } + } + + response '200', 'team goal updated' do + let(:id) { create(:team_goal, organization: organization).id } + let(:team_goal) { { team_goal: { progress: 75, status: 'in_progress' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + goal: { '$ref' => '#/components/schemas/TeamGoal' } + } + } + } + + run_test! + end + end + + delete 'Delete a team goal' do + tags 'Team Goals' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'team goal deleted' do + let(:id) { create(:team_goal, organization: organization).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end +end diff --git a/spec/integration/vod_reviews_spec.rb b/spec/integration/vod_reviews_spec.rb new file mode 100644 index 0000000..fcb9d43 --- /dev/null +++ b/spec/integration/vod_reviews_spec.rb @@ -0,0 +1,355 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'VOD Reviews API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:Authorization) { "Bearer #{Authentication::Services::JwtService.encode(user_id: user.id)}" } + + path '/api/v1/vod-reviews' do + get 'List all VOD reviews' do + tags 'VOD Reviews' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number' + parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page' + parameter name: :match_id, in: :query, type: :string, required: false, description: 'Filter by match ID' + parameter name: :reviewed_by_id, in: :query, type: :string, required: false, description: 'Filter by reviewer ID' + parameter name: :status, in: :query, type: :string, required: false, + description: 'Filter by status (draft, published, archived)' + + response '200', 'VOD reviews found' do + schema type: :object, + properties: { + data: { + type: :object, + properties: { + vod_reviews: { + type: :array, + items: { '$ref' => '#/components/schemas/VodReview' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + } + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { nil } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + post 'Create a VOD review' do + tags 'VOD Reviews' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :vod_review, in: :body, schema: { + type: :object, + properties: { + vod_review: { + type: :object, + properties: { + match_id: { type: :string }, + title: { type: :string }, + vod_url: { type: :string }, + vod_platform: { type: :string, enum: %w[youtube twitch gdrive other] }, + summary: { type: :string }, + status: { type: :string, enum: %w[draft published archived], default: 'draft' }, + tags: { type: :array, items: { type: :string } } + }, + required: %w[title vod_url] + } + } + } + + response '201', 'VOD review created' do + let(:match) { create(:match, organization: organization) } + let(:vod_review) do + { + vod_review: { + match_id: match.id, + title: 'Game Review vs Team X', + vod_url: 'https://youtube.com/watch?v=test', + vod_platform: 'youtube', + summary: 'Strong early game, need to work on mid-game transitions', + status: 'draft' + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + vod_review: { '$ref' => '#/components/schemas/VodReview' } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:vod_review) { { vod_review: { title: '' } } } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + end + + path '/api/v1/vod-reviews/{id}' do + parameter name: :id, in: :path, type: :string, description: 'VOD Review ID' + + get 'Show VOD review details' do + tags 'VOD Reviews' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'VOD review found' do + let(:id) { create(:vod_review, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + vod_review: { '$ref' => '#/components/schemas/VodReview' }, + timestamps: { + type: :array, + items: { '$ref' => '#/components/schemas/VodTimestamp' } + } + } + } + } + + run_test! + end + + response '404', 'VOD review not found' do + let(:id) { '99999' } + schema '$ref' => '#/components/schemas/Error' + run_test! + end + end + + patch 'Update a VOD review' do + tags 'VOD Reviews' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :vod_review, in: :body, schema: { + type: :object, + properties: { + vod_review: { + type: :object, + properties: { + title: { type: :string }, + summary: { type: :string }, + status: { type: :string } + } + } + } + } + + response '200', 'VOD review updated' do + let(:id) { create(:vod_review, organization: organization).id } + let(:vod_review) { { vod_review: { status: 'published' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + vod_review: { '$ref' => '#/components/schemas/VodReview' } + } + } + } + + run_test! + end + end + + delete 'Delete a VOD review' do + tags 'VOD Reviews' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'VOD review deleted' do + let(:id) { create(:vod_review, organization: organization).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end + + path '/api/v1/vod-reviews/{vod_review_id}/timestamps' do + parameter name: :vod_review_id, in: :path, type: :string, description: 'VOD Review ID' + + get 'List timestamps for a VOD review' do + tags 'VOD Reviews' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :category, in: :query, type: :string, required: false, + description: 'Filter by category (mistake, good_play, objective, teamfight, other)' + parameter name: :importance, in: :query, type: :string, required: false, + description: 'Filter by importance (low, medium, high, critical)' + + response '200', 'timestamps found' do + let(:vod_review_id) { create(:vod_review, organization: organization).id } + + schema type: :object, + properties: { + data: { + type: :object, + properties: { + timestamps: { + type: :array, + items: { '$ref' => '#/components/schemas/VodTimestamp' } + } + } + } + } + + run_test! + end + end + + post 'Create a timestamp' do + tags 'VOD Reviews' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :vod_timestamp, in: :body, schema: { + type: :object, + properties: { + vod_timestamp: { + type: :object, + properties: { + timestamp_seconds: { type: :integer, description: 'Timestamp in seconds' }, + title: { type: :string }, + description: { type: :string }, + category: { type: :string, enum: %w[mistake good_play objective teamfight other] }, + importance: { type: :string, enum: %w[low medium high critical] }, + target_type: { type: :string, enum: %w[team player], description: 'Who this timestamp is about' }, + target_player_id: { type: :string, description: 'Player ID if target_type is player' }, + tags: { type: :array, items: { type: :string } } + }, + required: %w[timestamp_seconds title category importance] + } + } + } + + response '201', 'timestamp created' do + let(:vod_review_id) { create(:vod_review, organization: organization).id } + let(:player) { create(:player, organization: organization) } + let(:vod_timestamp) do + { + vod_timestamp: { + timestamp_seconds: 420, + title: 'Missed flash timing', + description: 'Should have flashed earlier to secure kill', + category: 'mistake', + importance: 'high', + target_type: 'player', + target_player_id: player.id + } + } + end + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + timestamp: { '$ref' => '#/components/schemas/VodTimestamp' } + } + } + } + + run_test! + end + end + end + + path '/api/v1/vod-timestamps/{id}' do + parameter name: :id, in: :path, type: :string, description: 'VOD Timestamp ID' + + patch 'Update a timestamp' do + tags 'VOD Reviews' + consumes 'application/json' + produces 'application/json' + security [bearerAuth: []] + + parameter name: :vod_timestamp, in: :body, schema: { + type: :object, + properties: { + vod_timestamp: { + type: :object, + properties: { + title: { type: :string }, + description: { type: :string }, + importance: { type: :string } + } + } + } + } + + response '200', 'timestamp updated' do + let(:vod_review) { create(:vod_review, organization: organization) } + let(:id) { create(:vod_timestamp, vod_review: vod_review).id } + let(:vod_timestamp) { { vod_timestamp: { title: 'Updated title' } } } + + schema type: :object, + properties: { + message: { type: :string }, + data: { + type: :object, + properties: { + timestamp: { '$ref' => '#/components/schemas/VodTimestamp' } + } + } + } + + run_test! + end + end + + delete 'Delete a timestamp' do + tags 'VOD Reviews' + produces 'application/json' + security [bearerAuth: []] + + response '200', 'timestamp deleted' do + let(:vod_review) { create(:vod_review, organization: organization) } + let(:id) { create(:vod_timestamp, vod_review: vod_review).id } + + schema type: :object, + properties: { + message: { type: :string } + } + + run_test! + end + end + end +end diff --git a/spec/jobs/sync_player_from_riot_job_spec.rb b/spec/jobs/sync_player_from_riot_job_spec.rb index f2a571d..4580124 100644 --- a/spec/jobs/sync_player_from_riot_job_spec.rb +++ b/spec/jobs/sync_player_from_riot_job_spec.rb @@ -1,5 +1,188 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe SyncPlayerFromRiotJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" + let(:organization) { create(:organization) } + let(:player) { create(:player, organization: organization, summoner_name: 'TestPlayer#BR1', riot_puuid: nil) } + + before do + # Allow ENV to be stubbed without breaking Database Cleaner + allow(ENV).to receive(:[]).and_call_original + end + + describe '#perform' do + context 'when player has no Riot info' do + it 'sets error status and logs error' do + player_no_info = build(:player, organization: organization, riot_puuid: nil) + player_no_info.summoner_name = nil + player_no_info.save(validate: false) + + expect(Rails.logger).to receive(:error).with("Player #{player_no_info.id} missing Riot info") + + described_class.new.perform(player_no_info.id) + + player_no_info.reload + expect(player_no_info.sync_status).to eq('error') + expect(player_no_info.last_sync_at).to be_present + end + end + + context 'when Riot API key is not configured' do + it 'sets error status and logs error' do + allow(ENV).to receive(:[]).with('RIOT_API_KEY').and_return(nil) + + expect(Rails.logger).to receive(:error).with('Riot API key not configured') + + described_class.new.perform(player.id) + + player.reload + expect(player.sync_status).to eq('error') + expect(player.last_sync_at).to be_present + end + end + + context 'when sync is successful' do + let(:summoner_data) do + { + 'puuid' => 'test-puuid-123', + 'id' => 'test-summoner-id', + 'summonerLevel' => 100, + 'profileIconId' => 1234 + } + end + + let(:ranked_data) do + [ + { + 'queueType' => 'RANKED_SOLO_5x5', + 'tier' => 'DIAMOND', + 'rank' => 'II', + 'leaguePoints' => 75, + 'wins' => 120, + 'losses' => 80 + }, + { + 'queueType' => 'RANKED_FLEX_SR', + 'tier' => 'PLATINUM', + 'rank' => 'I', + 'leaguePoints' => 50 + } + ] + end + + before do + allow(ENV).to receive(:[]).with('RIOT_API_KEY').and_return('test-api-key') + end + + it 'syncs player data from Riot API' do + job = described_class.new + allow(job).to receive(:fetch_summoner_by_name).and_return(summoner_data) + allow(job).to receive(:fetch_ranked_stats).and_return(ranked_data) + + expect(Rails.logger).to receive(:info).with("Successfully synced player #{player.id} from Riot API") + + job.perform(player.id) + + player.reload + expect(player.riot_puuid).to eq('test-puuid-123') + expect(player.riot_summoner_id).to eq('test-summoner-id') + expect(player.summoner_level).to eq(100) + expect(player.profile_icon_id).to eq(1234) + expect(player.solo_queue_tier).to eq('DIAMOND') + expect(player.solo_queue_rank).to eq('II') + expect(player.solo_queue_lp).to eq(75) + expect(player.solo_queue_wins).to eq(120) + expect(player.solo_queue_losses).to eq(80) + expect(player.flex_queue_tier).to eq('PLATINUM') + expect(player.flex_queue_rank).to eq('I') + expect(player.flex_queue_lp).to eq(50) + expect(player.sync_status).to eq('success') + expect(player.last_sync_at).to be_present + end + + it 'uses player region when available' do + player.update(region: 'NA1') + job = described_class.new + + expect(job).to receive(:fetch_summoner_by_name).with( + player.summoner_name, + 'na1', + 'test-api-key' + ).and_return(summoner_data) + + allow(job).to receive(:fetch_ranked_stats).and_return(ranked_data) + + job.perform(player.id) + end + + it 'defaults to BR1 when region is not set' do + player.update(region: nil) + job = described_class.new + + expect(job).to receive(:fetch_summoner_by_name).with( + player.summoner_name, + 'br1', + 'test-api-key' + ).and_return(summoner_data) + + allow(job).to receive(:fetch_ranked_stats).and_return(ranked_data) + + job.perform(player.id) + end + end + + context 'when sync fails' do + before do + allow(ENV).to receive(:[]).with('RIOT_API_KEY').and_return('test-api-key') + end + + it 'sets error status and logs error' do + job = described_class.new + allow(job).to receive(:fetch_summoner_by_name).and_raise(StandardError.new('API Error')) + + expect(Rails.logger).to receive(:error).with("Failed to sync player #{player.id}: API Error") + expect(Rails.logger).to receive(:error).with(anything) # backtrace + + job.perform(player.id) + + player.reload + expect(player.sync_status).to eq('error') + expect(player.last_sync_at).to be_present + end + end + + context 'when player has PUUID' do + let(:player_with_puuid) do + create(:player, organization: organization, riot_puuid: 'existing-puuid') + end + + let(:summoner_data) do + { + 'puuid' => 'existing-puuid', + 'id' => 'test-summoner-id', + 'summonerLevel' => 100, + 'profileIconId' => 1234 + } + end + + before do + allow(ENV).to receive(:[]).with('RIOT_API_KEY').and_return('test-api-key') + end + + it 'fetches summoner by PUUID instead of name' do + job = described_class.new + + expect(job).to receive(:fetch_summoner_by_puuid).with( + 'existing-puuid', + 'br1', + 'test-api-key' + ).and_return(summoner_data) + + allow(job).to receive(:fetch_ranked_stats).and_return([]) + + job.perform(player_with_puuid.id) + end + end + end end diff --git a/spec/models/match_spec.rb b/spec/models/match_spec.rb index 904e346..a8e395e 100644 --- a/spec/models/match_spec.rb +++ b/spec/models/match_spec.rb @@ -1,57 +1,59 @@ -require 'rails_helper' - -RSpec.describe Match, type: :model do - describe 'associations' do - it { should belong_to(:organization) } - it { should have_many(:player_match_stats).dependent(:destroy) } - it { should have_many(:players).through(:player_match_stats) } - it { should have_many(:vod_reviews).dependent(:destroy) } - end - - describe 'validations' do - it { should validate_presence_of(:match_type) } - it { should validate_inclusion_of(:match_type).in_array(%w[official scrim tournament]) } - it { should validate_inclusion_of(:our_side).in_array(%w[blue red]).allow_nil } - end - - describe 'instance methods' do - let(:match) { create(:match, victory: true, game_duration: 1800) } - - describe '#result_text' do - it 'returns Victory for won match' do - expect(match.result_text).to eq('Victory') - end - - it 'returns Defeat for lost match' do - match.update(victory: false) - expect(match.result_text).to eq('Defeat') - end - end - - describe '#duration_formatted' do - it 'formats duration correctly' do - expect(match.duration_formatted).to eq('30:00') - end - end - end - - describe 'scopes' do - let(:organization) { create(:organization) } - let!(:victory) { create(:match, victory: true, organization: organization) } - let!(:defeat) { create(:match, victory: false, organization: organization) } - - describe '.victories' do - it 'returns only victories' do - expect(Match.victories).to include(victory) - expect(Match.victories).not_to include(defeat) - end - end - - describe '.defeats' do - it 'returns only defeats' do - expect(Match.defeats).to include(defeat) - expect(Match.defeats).not_to include(victory) - end - end - end -end +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Match, type: :model do + describe 'associations' do + it { should belong_to(:organization) } + it { should have_many(:player_match_stats).dependent(:destroy) } + it { should have_many(:players).through(:player_match_stats) } + it { should have_many(:vod_reviews).dependent(:destroy) } + end + + describe 'validations' do + it { should validate_presence_of(:match_type) } + it { should validate_inclusion_of(:match_type).in_array(%w[official scrim tournament]) } + it { should validate_inclusion_of(:our_side).in_array(%w[blue red]).allow_nil } + end + + describe 'instance methods' do + let(:match) { create(:match, victory: true, game_duration: 1800) } + + describe '#result_text' do + it 'returns Victory for won match' do + expect(match.result_text).to eq('Victory') + end + + it 'returns Defeat for lost match' do + match.update(victory: false) + expect(match.result_text).to eq('Defeat') + end + end + + describe '#duration_formatted' do + it 'formats duration correctly' do + expect(match.duration_formatted).to eq('30:00') + end + end + end + + describe 'scopes' do + let(:organization) { create(:organization) } + let!(:victory) { create(:match, victory: true, organization: organization) } + let!(:defeat) { create(:match, victory: false, organization: organization) } + + describe '.victories' do + it 'returns only victories' do + expect(Match.victories).to include(victory) + expect(Match.victories).not_to include(defeat) + end + end + + describe '.defeats' do + it 'returns only defeats' do + expect(Match.defeats).to include(defeat) + expect(Match.defeats).not_to include(victory) + end + end + end +end diff --git a/spec/models/player_spec.rb b/spec/models/player_spec.rb index 5fa541d..9e9bd26 100644 --- a/spec/models/player_spec.rb +++ b/spec/models/player_spec.rb @@ -1,79 +1,81 @@ -require 'rails_helper' - -RSpec.describe Player, type: :model do - describe 'associations' do - it { should belong_to(:organization) } - it { should have_many(:player_match_stats).dependent(:destroy) } - it { should have_many(:matches).through(:player_match_stats) } - it { should have_many(:champion_pools).dependent(:destroy) } - it { should have_many(:team_goals).dependent(:destroy) } - end - - describe 'validations' do - it { should validate_presence_of(:summoner_name) } - it { should validate_length_of(:summoner_name).is_at_most(100) } - it { should validate_presence_of(:role) } - it { should validate_inclusion_of(:role).in_array(%w[top jungle mid adc support]) } - it { should validate_inclusion_of(:status).in_array(%w[active inactive benched trial]) } - end - - describe 'instance methods' do - let(:player) { create(:player, solo_queue_tier: 'CHALLENGER', solo_queue_rank: 'I', solo_queue_lp: 500) } - - describe '#current_rank_display' do - it 'returns formatted rank' do - expect(player.current_rank_display).to eq('Challenger I (500 LP)') - end - - it 'returns Unranked for unranked player' do - player.update(solo_queue_tier: nil) - expect(player.current_rank_display).to eq('Unranked') - end - end - - describe '#win_rate' do - it 'calculates win rate correctly' do - player.update(solo_queue_wins: 60, solo_queue_losses: 40) - expect(player.win_rate).to eq(60.0) - end - - it 'returns 0 for no games' do - player.update(solo_queue_wins: 0, solo_queue_losses: 0) - expect(player.win_rate).to eq(0) - end - end - - describe '#age' do - it 'calculates age from birth date' do - player.update(birth_date: 20.years.ago) - expect(player.age).to eq(20) - end - - it 'returns nil when no birth date' do - player.update(birth_date: nil) - expect(player.age).to be_nil - end - end - end - - describe 'scopes' do - let(:organization) { create(:organization) } - let!(:active_player) { create(:player, status: 'active', organization: organization) } - let!(:benched_player) { create(:player, status: 'benched', organization: organization) } - - describe '.active' do - it 'returns only active players' do - expect(Player.active).to include(active_player) - expect(Player.active).not_to include(benched_player) - end - end - - describe '.by_role' do - let!(:mid_player) { create(:player, role: 'mid', organization: organization) } - - it 'filters players by role' do - expect(Player.by_role('mid')).to include(mid_player) - end - end - end -end +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Player, type: :model do + describe 'associations' do + it { should belong_to(:organization) } + it { should have_many(:player_match_stats).dependent(:destroy) } + it { should have_many(:matches).through(:player_match_stats) } + it { should have_many(:champion_pools).dependent(:destroy) } + it { should have_many(:team_goals).dependent(:destroy) } + end + + describe 'validations' do + it { should validate_presence_of(:summoner_name) } + it { should validate_length_of(:summoner_name).is_at_most(100) } + it { should validate_presence_of(:role) } + it { should validate_inclusion_of(:role).in_array(%w[top jungle mid adc support]) } + it { should validate_inclusion_of(:status).in_array(%w[active inactive benched trial]) } + end + + describe 'instance methods' do + let(:player) { create(:player, solo_queue_tier: 'CHALLENGER', solo_queue_rank: 'I', solo_queue_lp: 500) } + + describe '#current_rank_display' do + it 'returns formatted rank' do + expect(player.current_rank_display).to eq('Challenger I (500 LP)') + end + + it 'returns Unranked for unranked player' do + player.update(solo_queue_tier: nil) + expect(player.current_rank_display).to eq('Unranked') + end + end + + describe '#win_rate' do + it 'calculates win rate correctly' do + player.update(solo_queue_wins: 60, solo_queue_losses: 40) + expect(player.win_rate).to eq(60.0) + end + + it 'returns 0 for no games' do + player.update(solo_queue_wins: 0, solo_queue_losses: 0) + expect(player.win_rate).to eq(0) + end + end + + describe '#age' do + it 'calculates age from birth date' do + player.update(birth_date: 20.years.ago) + expect(player.age).to eq(20) + end + + it 'returns nil when no birth date' do + player.update(birth_date: nil) + expect(player.age).to be_nil + end + end + end + + describe 'scopes' do + let(:organization) { create(:organization) } + let!(:active_player) { create(:player, status: 'active', organization: organization) } + let!(:benched_player) { create(:player, status: 'benched', organization: organization) } + + describe '.active' do + it 'returns only active players' do + expect(Player.active).to include(active_player) + expect(Player.active).not_to include(benched_player) + end + end + + describe '.by_role' do + let!(:mid_player) { create(:player, role: 'mid', organization: organization) } + + it 'filters players by role' do + expect(Player.by_role('mid')).to include(mid_player) + end + end + end +end diff --git a/spec/models/vod_review_spec.rb b/spec/models/vod_review_spec.rb new file mode 100644 index 0000000..4945455 --- /dev/null +++ b/spec/models/vod_review_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe VodReview, type: :model do + describe 'associations' do + it { should belong_to(:organization) } + it { should belong_to(:match).optional } + it { should belong_to(:reviewer).class_name('User').optional } + it { should have_many(:vod_timestamps).dependent(:destroy) } + end + + describe 'validations' do + it { should validate_presence_of(:title) } + it { should validate_length_of(:title).is_at_most(255) } + it { should validate_presence_of(:video_url) } + it { should validate_inclusion_of(:review_type).in_array(%w[team individual opponent]).allow_blank } + it { should validate_inclusion_of(:status).in_array(%w[draft published archived]) } + + describe 'video_url format' do + it 'accepts valid URLs' do + vod_review = build(:vod_review, video_url: 'https://www.youtube.com/watch?v=abc123') + expect(vod_review).to be_valid + end + + it 'rejects invalid URLs' do + vod_review = build(:vod_review, video_url: 'not-a-valid-url') + expect(vod_review).not_to be_valid + end + end + + describe 'share_link uniqueness' do + let!(:existing_vod_review) { create(:vod_review, :public) } + + it 'validates uniqueness of share_link' do + new_vod_review = build(:vod_review, share_link: existing_vod_review.share_link) + expect(new_vod_review).not_to be_valid + end + end + end + + describe 'callbacks' do + describe 'generate_share_link' do + it 'generates share_link for public vod reviews on create' do + vod_review = create(:vod_review, is_public: true) + expect(vod_review.share_link).to be_present + end + + it 'does not generate share_link for private vod reviews' do + vod_review = create(:vod_review, is_public: false, share_link: nil) + expect(vod_review.share_link).to be_nil + end + end + end + + describe 'scopes' do + let(:organization) { create(:organization) } + let!(:draft_review) { create(:vod_review, status: 'draft', organization: organization) } + let!(:published_review) { create(:vod_review, :published, organization: organization) } + let!(:archived_review) { create(:vod_review, :archived, organization: organization) } + let!(:public_review) { create(:vod_review, :public, organization: organization) } + + describe '.by_status' do + it 'filters by status' do + expect(VodReview.by_status('draft')).to include(draft_review) + expect(VodReview.by_status('draft')).not_to include(published_review) + end + end + + describe '.published' do + it 'returns only published reviews' do + expect(VodReview.published).to include(published_review) + expect(VodReview.published).not_to include(draft_review) + end + end + + describe '.public_reviews' do + it 'returns only public reviews' do + expect(VodReview.public_reviews).to include(public_review) + expect(VodReview.public_reviews).not_to include(draft_review) + end + end + + describe '.by_type' do + let!(:team_review) { create(:vod_review, review_type: 'team', organization: organization) } + + it 'filters by review type' do + expect(VodReview.by_type('team')).to include(team_review) + end + end + end + + describe 'instance methods' do + let(:vod_review) { create(:vod_review, duration: 3665) } + + describe '#duration_formatted' do + it 'formats duration with hours' do + expect(vod_review.duration_formatted).to eq('1:01:05') + end + + it 'formats duration without hours' do + vod_review.update(duration: 125) + expect(vod_review.duration_formatted).to eq('2:05') + end + + it 'returns Unknown for blank duration' do + vod_review.update(duration: nil) + expect(vod_review.duration_formatted).to eq('Unknown') + end + end + + describe '#status_color' do + it 'returns yellow for draft' do + vod_review.update(status: 'draft') + expect(vod_review.status_color).to eq('yellow') + end + + it 'returns green for published' do + vod_review.update(status: 'published') + expect(vod_review.status_color).to eq('green') + end + + it 'returns gray for archived' do + vod_review.update(status: 'archived') + expect(vod_review.status_color).to eq('gray') + end + end + + describe '#publish!' do + it 'publishes the review' do + vod_review.publish! + expect(vod_review.status).to eq('published') + expect(vod_review.share_link).to be_present + end + end + + describe '#archive!' do + it 'archives the review' do + vod_review.archive! + expect(vod_review.status).to eq('archived') + end + end + + describe '#make_public!' do + it 'makes the review public' do + vod_review.make_public! + expect(vod_review.is_public).to be true + expect(vod_review.share_link).to be_present + end + end + + describe '#make_private!' do + it 'makes the review private' do + vod_review.update(is_public: true) + vod_review.make_private! + expect(vod_review.is_public).to be false + end + end + + describe '#timestamp_count' do + it 'returns the count of timestamps' do + create_list(:vod_timestamp, 3, vod_review: vod_review) + expect(vod_review.timestamp_count).to eq(3) + end + end + + describe '#important_timestamps' do + it 'returns only high and critical timestamps' do + create(:vod_timestamp, vod_review: vod_review, importance: 'high') + create(:vod_timestamp, vod_review: vod_review, importance: 'critical') + create(:vod_timestamp, vod_review: vod_review, importance: 'low') + + expect(vod_review.important_timestamps.count).to eq(2) + end + end + + describe 'player sharing' do + let(:player1) { create(:player, organization: vod_review.organization) } + let(:player2) { create(:player, organization: vod_review.organization) } + + describe '#share_with_player!' do + it 'adds player to shared_with_players' do + vod_review.share_with_player!(player1.id) + expect(vod_review.shared_with_players).to include(player1.id) + end + + it 'does not duplicate players' do + vod_review.share_with_player!(player1.id) + vod_review.share_with_player!(player1.id) + expect(vod_review.shared_with_players.count(player1.id)).to eq(1) + end + end + + describe '#unshare_with_player!' do + it 'removes player from shared_with_players' do + vod_review.update(shared_with_players: [player1.id, player2.id]) + vod_review.unshare_with_player!(player1.id) + expect(vod_review.shared_with_players).not_to include(player1.id) + expect(vod_review.shared_with_players).to include(player2.id) + end + end + + describe '#share_with_all_players!' do + it 'shares with all organization players' do + player1 + player2 + vod_review.share_with_all_players! + expect(vod_review.shared_with_players).to include(player1.id, player2.id) + end + end + end + end +end diff --git a/spec/models/vod_timestamp_spec.rb b/spec/models/vod_timestamp_spec.rb new file mode 100644 index 0000000..23326ab --- /dev/null +++ b/spec/models/vod_timestamp_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe VodTimestamp, type: :model do + describe 'associations' do + it { should belong_to(:vod_review) } + it { should belong_to(:target_player).class_name('Player').optional } + it { should belong_to(:created_by).class_name('User').optional } + end + + describe 'validations' do + it { should validate_presence_of(:timestamp_seconds) } + it { should validate_numericality_of(:timestamp_seconds).is_greater_than_or_equal_to(0) } + it { should validate_presence_of(:title) } + it { should validate_length_of(:title).is_at_most(255) } + it { + should validate_inclusion_of(:category).in_array(%w[mistake good_play team_fight objective laning]).allow_blank + } + it { should validate_inclusion_of(:importance).in_array(%w[low normal high critical]) } + it { should validate_inclusion_of(:target_type).in_array(%w[player team opponent]).allow_blank } + end + + describe 'scopes' do + let(:vod_review) { create(:vod_review) } + let!(:mistake_timestamp) { create(:vod_timestamp, :mistake, vod_review: vod_review) } + let!(:good_play_timestamp) { create(:vod_timestamp, :good_play, vod_review: vod_review) } + let!(:critical_timestamp) { create(:vod_timestamp, :critical, vod_review: vod_review) } + + describe '.by_category' do + it 'filters by category' do + expect(VodTimestamp.by_category('mistake')).to include(mistake_timestamp) + expect(VodTimestamp.by_category('mistake')).not_to include(good_play_timestamp) + end + end + + describe '.by_importance' do + it 'filters by importance' do + expect(VodTimestamp.by_importance('critical')).to include(critical_timestamp) + end + end + + describe '.important' do + it 'returns high and critical timestamps' do + expect(VodTimestamp.important).to include(critical_timestamp, mistake_timestamp) + expect(VodTimestamp.important).not_to include(good_play_timestamp) + end + end + + describe '.chronological' do + it 'orders by timestamp_seconds' do + create(:vod_timestamp, vod_review: vod_review, timestamp_seconds: 100) + create(:vod_timestamp, vod_review: vod_review, timestamp_seconds: 50) + create(:vod_timestamp, vod_review: vod_review, timestamp_seconds: 200) + + expect(VodTimestamp.chronological.pluck(:timestamp_seconds)).to eq([50, 100, 200]) + end + end + end + + describe 'instance methods' do + let(:vod_timestamp) { create(:vod_timestamp, timestamp_seconds: 3665) } + + describe '#timestamp_formatted' do + it 'formats timestamp with hours' do + expect(vod_timestamp.timestamp_formatted).to eq('1:01:05') + end + + it 'formats timestamp without hours' do + vod_timestamp.update(timestamp_seconds: 125) + expect(vod_timestamp.timestamp_formatted).to eq('2:05') + end + end + + describe '#importance_color' do + it 'returns correct color for each importance' do + vod_timestamp.update(importance: 'low') + expect(vod_timestamp.importance_color).to eq('gray') + + vod_timestamp.update(importance: 'normal') + expect(vod_timestamp.importance_color).to eq('blue') + + vod_timestamp.update(importance: 'high') + expect(vod_timestamp.importance_color).to eq('orange') + + vod_timestamp.update(importance: 'critical') + expect(vod_timestamp.importance_color).to eq('red') + end + end + + describe '#category_color' do + it 'returns correct color for each category' do + vod_timestamp.update(category: 'mistake') + expect(vod_timestamp.category_color).to eq('red') + + vod_timestamp.update(category: 'good_play') + expect(vod_timestamp.category_color).to eq('green') + + vod_timestamp.update(category: 'team_fight') + expect(vod_timestamp.category_color).to eq('purple') + end + end + + describe '#category_icon' do + it 'returns correct icon for each category' do + vod_timestamp.update(category: 'mistake') + expect(vod_timestamp.category_icon).to eq('⚠️') + + vod_timestamp.update(category: 'good_play') + expect(vod_timestamp.category_icon).to eq('✅') + + vod_timestamp.update(category: 'team_fight') + expect(vod_timestamp.category_icon).to eq('⚔️') + end + end + + describe '#target_display' do + let(:player) { create(:player, summoner_name: 'TestPlayer') } + + it 'returns player name for player target' do + vod_timestamp.update(target_type: 'player', target_player: player) + expect(vod_timestamp.target_display).to eq('TestPlayer') + end + + it 'returns Team for team target' do + vod_timestamp.update(target_type: 'team') + expect(vod_timestamp.target_display).to eq('Team') + end + + it 'returns Opponent for opponent target' do + vod_timestamp.update(target_type: 'opponent') + expect(vod_timestamp.target_display).to eq('Opponent') + end + end + + describe '#video_url_with_timestamp' do + it 'adds timestamp to YouTube URL' do + vod_timestamp.vod_review.update(video_url: 'https://www.youtube.com/watch?v=abc123') + vod_timestamp.update(timestamp_seconds: 120) + expect(vod_timestamp.video_url_with_timestamp).to include('t=120s') + end + + it 'adds timestamp to Twitch URL' do + vod_timestamp.vod_review.update(video_url: 'https://www.twitch.tv/videos/123456') + vod_timestamp.update(timestamp_seconds: 120) + expect(vod_timestamp.video_url_with_timestamp).to include('t=120s') + end + + it 'returns base URL for other platforms' do + vod_timestamp.vod_review.update(video_url: 'https://other.com/video') + expect(vod_timestamp.video_url_with_timestamp).to eq('https://other.com/video') + end + end + + describe '#is_important?' do + it 'returns true for high importance' do + vod_timestamp.update(importance: 'high') + expect(vod_timestamp.is_important?).to be true + end + + it 'returns true for critical importance' do + vod_timestamp.update(importance: 'critical') + expect(vod_timestamp.is_important?).to be true + end + + it 'returns false for normal importance' do + vod_timestamp.update(importance: 'normal') + expect(vod_timestamp.is_important?).to be false + end + end + + describe '#is_mistake?' do + it 'returns true for mistake category' do + vod_timestamp.update(category: 'mistake') + expect(vod_timestamp.is_mistake?).to be true + end + + it 'returns false for other categories' do + vod_timestamp.update(category: 'good_play') + expect(vod_timestamp.is_mistake?).to be false + end + end + + describe '#is_highlight?' do + it 'returns true for good_play category' do + vod_timestamp.update(category: 'good_play') + expect(vod_timestamp.is_highlight?).to be true + end + + it 'returns false for other categories' do + vod_timestamp.update(category: 'mistake') + expect(vod_timestamp.is_highlight?).to be false + end + end + + describe 'navigation methods' do + let(:vod_review) { create(:vod_review) } + let!(:timestamp1) { create(:vod_timestamp, vod_review: vod_review, timestamp_seconds: 100) } + let!(:timestamp2) { create(:vod_timestamp, vod_review: vod_review, timestamp_seconds: 200) } + let!(:timestamp3) { create(:vod_timestamp, vod_review: vod_review, timestamp_seconds: 300) } + + describe '#next_timestamp' do + it 'returns the next timestamp' do + expect(timestamp2.next_timestamp).to eq(timestamp3) + end + + it 'returns nil for last timestamp' do + expect(timestamp3.next_timestamp).to be_nil + end + end + + describe '#previous_timestamp' do + it 'returns the previous timestamp' do + expect(timestamp2.previous_timestamp).to eq(timestamp1) + end + + it 'returns nil for first timestamp' do + expect(timestamp1.previous_timestamp).to be_nil + end + end + end + end +end diff --git a/spec/policies/player_policy_spec.rb b/spec/policies/player_policy_spec.rb index fe392fc..a6e7ddb 100644 --- a/spec/policies/player_policy_spec.rb +++ b/spec/policies/player_policy_spec.rb @@ -1,57 +1,59 @@ -require 'rails_helper' - -RSpec.describe PlayerPolicy, type: :policy do - subject { described_class.new(user, player) } - - let(:organization) { create(:organization) } - let(:player) { create(:player, organization: organization) } - - context 'for an owner' do - let(:user) { create(:user, :owner, organization: organization) } - - it { should permit_action(:index) } - it { should permit_action(:show) } - it { should permit_action(:create) } - it { should permit_action(:update) } - it { should permit_action(:destroy) } - end - - context 'for an admin' do - let(:user) { create(:user, :admin, organization: organization) } - - it { should permit_action(:index) } - it { should permit_action(:show) } - it { should permit_action(:create) } - it { should permit_action(:update) } - it { should_not permit_action(:destroy) } - end - - context 'for a coach' do - let(:user) { create(:user, :coach, organization: organization) } - - it { should permit_action(:index) } - it { should permit_action(:show) } - it { should_not permit_action(:create) } - it { should_not permit_action(:update) } - it { should_not permit_action(:destroy) } - end - - context 'for a viewer' do - let(:user) { create(:user, :viewer, organization: organization) } - - it { should permit_action(:index) } - it { should permit_action(:show) } - it { should_not permit_action(:create) } - it { should_not permit_action(:update) } - it { should_not permit_action(:destroy) } - end - - context 'for a user from different organization' do - let(:other_organization) { create(:organization) } - let(:user) { create(:user, :admin, organization: other_organization) } - - it { should_not permit_action(:show) } - it { should_not permit_action(:update) } - it { should_not permit_action(:destroy) } - end -end +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PlayerPolicy, type: :policy do + subject { described_class.new(user, player) } + + let(:organization) { create(:organization) } + let(:player) { create(:player, organization: organization) } + + context 'for an owner' do + let(:user) { create(:user, :owner, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should permit_action(:destroy) } + end + + context 'for an admin' do + let(:user) { create(:user, :admin, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a coach' do + let(:user) { create(:user, :coach, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should_not permit_action(:create) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a viewer' do + let(:user) { create(:user, :viewer, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should_not permit_action(:create) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a user from different organization' do + let(:other_organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: other_organization) } + + it { should_not permit_action(:show) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end +end diff --git a/spec/policies/vod_review_policy_spec.rb b/spec/policies/vod_review_policy_spec.rb new file mode 100644 index 0000000..321dde2 --- /dev/null +++ b/spec/policies/vod_review_policy_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe VodReviewPolicy, type: :policy do + subject { described_class.new(user, vod_review) } + + let(:organization) { create(:organization) } + let(:vod_review) { create(:vod_review, organization: organization) } + + context 'for an owner' do + let(:user) { create(:user, :owner, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should permit_action(:destroy) } + end + + context 'for an admin' do + let(:user) { create(:user, :admin, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should permit_action(:destroy) } + end + + context 'for a coach' do + let(:user) { create(:user, :coach, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for an analyst' do + let(:user) { create(:user, :analyst, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a viewer' do + let(:user) { create(:user, :viewer, organization: organization) } + + it { should_not permit_action(:index) } + it { should_not permit_action(:show) } + it { should_not permit_action(:create) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a user from different organization' do + let(:other_organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: other_organization) } + + it { should_not permit_action(:show) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + describe 'Scope' do + let!(:user) { create(:user, :analyst, organization: organization) } + let!(:vod_review1) { create(:vod_review, organization: organization) } + let!(:vod_review2) { create(:vod_review, organization: organization) } + let!(:other_vod_review) { create(:vod_review, organization: create(:organization)) } + + it 'includes vod reviews from user organization' do + scope = described_class::Scope.new(user, VodReview).resolve + expect(scope).to include(vod_review1, vod_review2) + expect(scope).not_to include(other_vod_review) + end + + context 'for viewers' do + let!(:viewer) { create(:user, :viewer, organization: organization) } + + it 'excludes vod reviews for viewers' do + scope = described_class::Scope.new(viewer, VodReview).resolve + expect(scope).to be_empty + end + end + end +end diff --git a/spec/policies/vod_timestamp_policy_spec.rb b/spec/policies/vod_timestamp_policy_spec.rb new file mode 100644 index 0000000..8b84772 --- /dev/null +++ b/spec/policies/vod_timestamp_policy_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe VodTimestampPolicy, type: :policy do + subject { described_class.new(user, vod_timestamp) } + + let(:organization) { create(:organization) } + let(:vod_review) { create(:vod_review, organization: organization) } + let(:vod_timestamp) { create(:vod_timestamp, vod_review: vod_review) } + + context 'for an owner' do + let(:user) { create(:user, :owner, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should permit_action(:destroy) } + end + + context 'for an admin' do + let(:user) { create(:user, :admin, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should permit_action(:destroy) } + end + + context 'for a coach' do + let(:user) { create(:user, :coach, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for an analyst' do + let(:user) { create(:user, :analyst, organization: organization) } + + it { should permit_action(:index) } + it { should permit_action(:show) } + it { should permit_action(:create) } + it { should permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a viewer' do + let(:user) { create(:user, :viewer, organization: organization) } + + it { should_not permit_action(:index) } + it { should_not permit_action(:show) } + it { should_not permit_action(:create) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + context 'for a user from different organization' do + let(:other_organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: other_organization) } + + it { should_not permit_action(:show) } + it { should_not permit_action(:update) } + it { should_not permit_action(:destroy) } + end + + describe 'Scope' do + let!(:user) { create(:user, :analyst, organization: organization) } + let!(:timestamp1) { create(:vod_timestamp, vod_review: vod_review) } + let!(:timestamp2) { create(:vod_timestamp, vod_review: vod_review) } + let!(:other_vod_review) { create(:vod_review, organization: create(:organization)) } + let!(:other_timestamp) { create(:vod_timestamp, vod_review: other_vod_review) } + + it 'includes timestamps from user organization' do + scope = described_class::Scope.new(user, VodTimestamp).resolve + expect(scope).to include(timestamp1, timestamp2) + expect(scope).not_to include(other_timestamp) + end + + context 'for viewers' do + let!(:viewer) { create(:user, :viewer, organization: organization) } + + it 'excludes timestamps for viewers' do + scope = described_class::Scope.new(viewer, VodTimestamp).resolve + expect(scope).to be_empty + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 1da128f..10fa3be 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,67 +1,77 @@ -# This file is copied to spec/ when you run 'rails generate rspec:install' -require 'spec_helper' -ENV['RAILS_ENV'] ||= 'test' -require_relative '../config/environment' -# Prevent database truncation if the environment is production -abort("The Rails environment is running in production mode!") if Rails.env.production? -require 'rspec/rails' - -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. -Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } - -# Checks for pending migrations and applies them before tests are run. -begin - ActiveRecord::Migration.maintain_test_schema! -rescue ActiveRecord::PendingMigrationError => e - abort e.to_s.strip -end - -RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = "#{::Rails.root}/spec/fixtures" - - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. - config.use_transactional_fixtures = true - - # You can uncomment this line to turn off ActiveRecord support entirely. - # config.use_active_record = false - - config.infer_spec_type_from_file_location! - - # Filter lines from Rails gems in backtraces. - config.filter_rails_from_backtrace! - - # Include FactoryBot methods - config.include FactoryBot::Syntax::Methods - - # Include request helpers - config.include RequestSpecHelper, type: :request - - # Database cleaner - config.before(:suite) do - DatabaseCleaner.clean_with(:truncation) - end - - config.before(:each) do - DatabaseCleaner.strategy = :transaction - end - - config.before(:each) do - DatabaseCleaner.start - end - - config.after(:each) do - DatabaseCleaner.clean - end -end - -# Shoulda Matchers configuration -Shoulda::Matchers.configure do |config| - config.integrate do |with| - with.test_framework :rspec - with.library :rails - end -end +# frozen_string_literal: true + +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort('The Rails environment is running in production mode!') if Rails.env.production? +require 'rspec/rails' +require 'database_cleaner/active_record' + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } + +# Checks for pending migrations and applies them before tests are run. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end + +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + # config.fixture_path = "#{::Rails.root}/spec/fixtures" + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + + # Include FactoryBot methods + config.include FactoryBot::Syntax::Methods + + # Include request helpers + config.include RequestSpecHelper, type: :request + + # Database cleaner - allow remote URLs for test environment + DatabaseCleaner.allow_remote_database_url = true + + config.before(:suite) do + DatabaseCleaner.clean_with(:truncation) + end + + config.before(:each) do + DatabaseCleaner.strategy = :transaction + end + + config.before(:each) do + DatabaseCleaner.start + end + + config.after(:each) do + DatabaseCleaner.clean + end +end + +# Shoulda Matchers configuration +begin + require 'shoulda/matchers' + Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end + end +rescue LoadError + # Shoulda matchers not available +end diff --git a/spec/requests/api/scouting/regions_spec.rb b/spec/requests/api/scouting/regions_spec.rb index df2bd96..b38b51e 100644 --- a/spec/requests/api/scouting/regions_spec.rb +++ b/spec/requests/api/scouting/regions_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe 'Scouting Regions API', type: :request do diff --git a/spec/requests/api/v1/players_spec.rb b/spec/requests/api/v1/players_spec.rb index 09d3f9d..fe9c848 100644 --- a/spec/requests/api/v1/players_spec.rb +++ b/spec/requests/api/v1/players_spec.rb @@ -1,156 +1,158 @@ -require 'rails_helper' - -RSpec.describe 'Players API', type: :request do - let(:organization) { create(:organization) } - let(:user) { create(:user, :admin, organization: organization) } - let(:other_organization) { create(:organization) } - let(:other_user) { create(:user, organization: other_organization) } - - describe 'GET /api/v1/players' do - let!(:players) { create_list(:player, 5, organization: organization) } - - context 'when authenticated' do - it 'returns all players for the organization' do - get '/api/v1/players', headers: auth_headers(user) - - expect(response).to have_http_status(:success) - expect(json_response[:data][:players].size).to eq(5) - end - - it 'filters by role' do - top_player = create(:player, role: 'top', organization: organization) - - get '/api/v1/players', params: { role: 'top' }, headers: auth_headers(user) - - expect(response).to have_http_status(:success) - expect(json_response[:data][:players].size).to eq(1) - expect(json_response[:data][:players][0][:summoner_name]).to eq(top_player.summoner_name) - end - - it 'includes pagination metadata' do - get '/api/v1/players', headers: auth_headers(user) - - expect(response).to have_http_status(:success) - expect(json_response[:data][:pagination]).to include( - :current_page, - :per_page, - :total_pages, - :total_count - ) - end - end - - context 'when not authenticated' do - it 'returns unauthorized' do - get '/api/v1/players' - - expect(response).to have_http_status(:unauthorized) - end - end - end - - describe 'POST /api/v1/players' do - let(:valid_attributes) do - { - player: { - summoner_name: 'TestPlayer', - real_name: 'Test User', - role: 'mid', - status: 'active' - } - } - end - - context 'when authenticated as admin' do - it 'creates a new player' do - expect { - post '/api/v1/players', - params: valid_attributes.to_json, - headers: auth_headers(user) - }.to change(Player, :count).by(1) - - expect(response).to have_http_status(:created) - expect(json_response[:data][:player][:summoner_name]).to eq('TestPlayer') - end - - it 'returns validation errors for invalid data' do - invalid_attributes = { player: { summoner_name: '' } } - - post '/api/v1/players', - params: invalid_attributes.to_json, - headers: auth_headers(user) - - expect(response).to have_http_status(:unprocessable_entity) - expect(json_response[:error][:code]).to eq('VALIDATION_ERROR') - end - end - - context 'when authenticated as viewer' do - let(:viewer) { create(:user, :viewer, organization: organization) } - - it 'returns forbidden' do - post '/api/v1/players', - params: valid_attributes.to_json, - headers: auth_headers(viewer) - - expect(response).to have_http_status(:forbidden) - end - end - end - - describe 'GET /api/v1/players/:id' do - let(:player) { create(:player, organization: organization) } - - it 'returns the player' do - get "/api/v1/players/#{player.id}", headers: auth_headers(user) - - expect(response).to have_http_status(:success) - expect(json_response[:data][:player][:id]).to eq(player.id) - end - - it 'returns not found for non-existent player' do - get '/api/v1/players/99999', headers: auth_headers(user) - - expect(response).to have_http_status(:not_found) - end - end - - describe 'PATCH /api/v1/players/:id' do - let(:player) { create(:player, organization: organization) } - - it 'updates the player' do - patch "/api/v1/players/#{player.id}", - params: { player: { summoner_name: 'UpdatedName' } }.to_json, - headers: auth_headers(user) - - expect(response).to have_http_status(:success) - expect(json_response[:data][:player][:summoner_name]).to eq('UpdatedName') - end - end - - describe 'DELETE /api/v1/players/:id' do - let(:player) { create(:player, organization: organization) } - let(:owner) { create(:user, :owner, organization: organization) } - - it 'deletes the player' do - player_id = player.id - - expect { - delete "/api/v1/players/#{player_id}", headers: auth_headers(owner) - }.to change(Player, :count).by(-1) - - expect(response).to have_http_status(:success) - end - end - - describe 'GET /api/v1/players/:id/stats' do - let(:player) { create(:player, organization: organization) } - - it 'returns player statistics' do - get "/api/v1/players/#{player.id}/stats", headers: auth_headers(user) - - expect(response).to have_http_status(:success) - expect(json_response[:data]).to include(:player, :overall, :recent_form) - end - end -end +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Players API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization: organization) } + let(:other_organization) { create(:organization) } + let(:other_user) { create(:user, organization: other_organization) } + + describe 'GET /api/v1/players' do + let!(:players) { create_list(:player, 5, organization: organization) } + + context 'when authenticated' do + it 'returns all players for the organization' do + get '/api/v1/players', headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:players].size).to eq(5) + end + + it 'filters by role' do + top_player = create(:player, role: 'top', organization: organization) + + get '/api/v1/players', params: { role: 'top' }, headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:players].size).to eq(1) + expect(json_response[:data][:players][0][:summoner_name]).to eq(top_player.summoner_name) + end + + it 'includes pagination metadata' do + get '/api/v1/players', headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:pagination]).to include( + :current_page, + :per_page, + :total_pages, + :total_count + ) + end + end + + context 'when not authenticated' do + it 'returns unauthorized' do + get '/api/v1/players' + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/players' do + let(:valid_attributes) do + { + player: { + summoner_name: 'TestPlayer', + real_name: 'Test User', + role: 'mid', + status: 'active' + } + } + end + + context 'when authenticated as admin' do + it 'creates a new player' do + expect do + post '/api/v1/players', + params: valid_attributes.to_json, + headers: auth_headers(user) + end.to change(Player, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response[:data][:player][:summoner_name]).to eq('TestPlayer') + end + + it 'returns validation errors for invalid data' do + invalid_attributes = { player: { summoner_name: '' } } + + post '/api/v1/players', + params: invalid_attributes.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response[:error][:code]).to eq('VALIDATION_ERROR') + end + end + + context 'when authenticated as viewer' do + let(:viewer) { create(:user, :viewer, organization: organization) } + + it 'returns forbidden' do + post '/api/v1/players', + params: valid_attributes.to_json, + headers: auth_headers(viewer) + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'GET /api/v1/players/:id' do + let(:player) { create(:player, organization: organization) } + + it 'returns the player' do + get "/api/v1/players/#{player.id}", headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:player][:id]).to eq(player.id) + end + + it 'returns not found for non-existent player' do + get '/api/v1/players/99999', headers: auth_headers(user) + + expect(response).to have_http_status(:not_found) + end + end + + describe 'PATCH /api/v1/players/:id' do + let(:player) { create(:player, organization: organization) } + + it 'updates the player' do + patch "/api/v1/players/#{player.id}", + params: { player: { summoner_name: 'UpdatedName' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:player][:summoner_name]).to eq('UpdatedName') + end + end + + describe 'DELETE /api/v1/players/:id' do + let(:player) { create(:player, organization: organization) } + let(:owner) { create(:user, :owner, organization: organization) } + + it 'deletes the player' do + player_id = player.id + + expect do + delete "/api/v1/players/#{player_id}", headers: auth_headers(owner) + end.to change(Player, :count).by(-1) + + expect(response).to have_http_status(:success) + end + end + + describe 'GET /api/v1/players/:id/stats' do + let(:player) { create(:player, organization: organization) } + + it 'returns player statistics' do + get "/api/v1/players/#{player.id}/stats", headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data]).to include(:player, :overall, :recent_form) + end + end +end diff --git a/spec/requests/api/v1/vod_reviews_spec.rb b/spec/requests/api/v1/vod_reviews_spec.rb new file mode 100644 index 0000000..e50850a --- /dev/null +++ b/spec/requests/api/v1/vod_reviews_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'VOD Reviews API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :analyst, organization: organization) } + let(:admin) { create(:user, :admin, organization: organization) } + let(:other_organization) { create(:organization) } + let(:other_user) { create(:user, organization: other_organization) } + + describe 'GET /api/v1/vod-reviews' do + let!(:vod_reviews) { create_list(:vod_review, 3, organization: organization) } + + context 'when authenticated' do + it 'returns all vod reviews for the organization' do + get '/api/v1/vod-reviews', headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:vod_reviews].size).to eq(3) + end + + it 'filters by status' do + create(:vod_review, :published, organization: organization) + + get '/api/v1/vod-reviews', params: { status: 'published' }, headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:vod_reviews].size).to eq(1) + end + + it 'filters by match_id' do + match = create(:match, organization: organization) + create(:vod_review, match: match, organization: organization) + + get '/api/v1/vod-reviews', params: { match_id: match.id }, headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:vod_reviews].size).to eq(1) + end + + it 'includes pagination metadata' do + get '/api/v1/vod-reviews', headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:pagination]).to include( + :current_page, + :per_page, + :total_pages, + :total_count + ) + end + end + + context 'when not authenticated' do + it 'returns unauthorized' do + get '/api/v1/vod-reviews' + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/vod-reviews/:id' do + let(:vod_review) { create(:vod_review, :with_timestamps, organization: organization) } + + context 'when authenticated' do + it 'returns the vod review with timestamps' do + get "/api/v1/vod-reviews/#{vod_review.id}", headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:vod_review][:id]).to eq(vod_review.id) + expect(json_response[:data][:timestamps]).to be_present + end + end + + context 'when accessing another organization vod review' do + let(:other_vod_review) { create(:vod_review, organization: other_organization) } + + it 'returns forbidden' do + get "/api/v1/vod-reviews/#{other_vod_review.id}", headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + + context 'when vod review not found' do + it 'returns not found' do + get '/api/v1/vod-reviews/00000000-0000-0000-0000-000000000000', headers: auth_headers(user) + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /api/v1/vod-reviews' do + let(:valid_attributes) do + { + vod_review: { + title: 'Test VOD Review', + description: 'Test description', + video_url: 'https://www.youtube.com/watch?v=abc123', + review_type: 'team', + status: 'draft' + } + } + end + + context 'when authenticated as analyst' do + it 'creates a new vod review' do + expect do + post '/api/v1/vod-reviews', + params: valid_attributes.to_json, + headers: auth_headers(user) + end.to change(VodReview, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response[:data][:vod_review][:title]).to eq('Test VOD Review') + expect(json_response[:data][:vod_review][:reviewer][:id]).to eq(user.id) + end + + it 'returns validation errors for invalid data' do + invalid_attributes = { vod_review: { title: '' } } + + post '/api/v1/vod-reviews', + params: invalid_attributes.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response[:error][:code]).to eq('VALIDATION_ERROR') + end + end + end + + describe 'PATCH /api/v1/vod-reviews/:id' do + let(:vod_review) { create(:vod_review, organization: organization) } + + context 'when authenticated' do + it 'updates the vod review' do + patch "/api/v1/vod-reviews/#{vod_review.id}", + params: { vod_review: { title: 'Updated Title' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:vod_review][:title]).to eq('Updated Title') + end + + it 'returns validation errors for invalid data' do + patch "/api/v1/vod-reviews/#{vod_review.id}", + params: { vod_review: { title: '' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when accessing another organization vod review' do + let(:other_vod_review) { create(:vod_review, organization: other_organization) } + + it 'returns forbidden' do + patch "/api/v1/vod-reviews/#{other_vod_review.id}", + params: { vod_review: { title: 'Hacked' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'DELETE /api/v1/vod-reviews/:id' do + let!(:vod_review) { create(:vod_review, organization: organization) } + + context 'when authenticated as admin' do + it 'deletes the vod review' do + expect do + delete "/api/v1/vod-reviews/#{vod_review.id}", headers: auth_headers(admin) + end.to change(VodReview, :count).by(-1) + + expect(response).to have_http_status(:success) + end + end + + context 'when authenticated as analyst' do + it 'returns forbidden' do + delete "/api/v1/vod-reviews/#{vod_review.id}", headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/vod_timestamps_spec.rb b/spec/requests/api/v1/vod_timestamps_spec.rb new file mode 100644 index 0000000..90dad32 --- /dev/null +++ b/spec/requests/api/v1/vod_timestamps_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'VOD Timestamps API', type: :request do + let(:organization) { create(:organization) } + let(:user) { create(:user, :analyst, organization: organization) } + let(:admin) { create(:user, :admin, organization: organization) } + let(:vod_review) { create(:vod_review, organization: organization) } + let(:other_organization) { create(:organization) } + let(:other_vod_review) { create(:vod_review, organization: other_organization) } + + describe 'GET /api/v1/vod-reviews/:vod_review_id/timestamps' do + let!(:timestamps) { create_list(:vod_timestamp, 3, vod_review: vod_review) } + + context 'when authenticated' do + it 'returns all timestamps for the vod review' do + get "/api/v1/vod-reviews/#{vod_review.id}/timestamps", headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:timestamps].size).to eq(3) + end + + it 'filters by category' do + create(:vod_timestamp, :mistake, vod_review: vod_review) + + get "/api/v1/vod-reviews/#{vod_review.id}/timestamps", + params: { category: 'mistake' }, + headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:timestamps].size).to eq(1) + end + + it 'filters by importance' do + create(:vod_timestamp, :critical, vod_review: vod_review) + + get "/api/v1/vod-reviews/#{vod_review.id}/timestamps", + params: { importance: 'critical' }, + headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:timestamps].size).to eq(1) + end + end + + context 'when accessing another organization vod review' do + it 'returns forbidden' do + get "/api/v1/vod-reviews/#{other_vod_review.id}/timestamps", headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + + context 'when not authenticated' do + it 'returns unauthorized' do + get "/api/v1/vod-reviews/#{vod_review.id}/timestamps" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/vod-reviews/:vod_review_id/timestamps' do + let(:player) { create(:player, organization: organization) } + let(:valid_attributes) do + { + vod_timestamp: { + timestamp_seconds: 120, + title: 'Important moment', + description: 'Description here', + category: 'mistake', + importance: 'high', + target_type: 'player', + target_player_id: player.id + } + } + end + + context 'when authenticated' do + it 'creates a new timestamp' do + expect do + post "/api/v1/vod-reviews/#{vod_review.id}/timestamps", + params: valid_attributes.to_json, + headers: auth_headers(user) + end.to change(VodTimestamp, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response[:data][:timestamp][:title]).to eq('Important moment') + expect(json_response[:data][:timestamp][:created_by][:id]).to eq(user.id) + end + + it 'returns validation errors for invalid data' do + invalid_attributes = { vod_timestamp: { title: '' } } + + post "/api/v1/vod-reviews/#{vod_review.id}/timestamps", + params: invalid_attributes.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response[:error][:code]).to eq('VALIDATION_ERROR') + end + end + + context 'when accessing another organization vod review' do + it 'returns forbidden' do + post "/api/v1/vod-reviews/#{other_vod_review.id}/timestamps", + params: valid_attributes.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'PATCH /api/v1/vod-timestamps/:id' do + let(:timestamp) { create(:vod_timestamp, vod_review: vod_review) } + + context 'when authenticated' do + it 'updates the timestamp' do + patch "/api/v1/vod-timestamps/#{timestamp.id}", + params: { vod_timestamp: { title: 'Updated Title' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:success) + expect(json_response[:data][:timestamp][:title]).to eq('Updated Title') + end + + it 'returns validation errors for invalid data' do + patch "/api/v1/vod-timestamps/#{timestamp.id}", + params: { vod_timestamp: { title: '' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when accessing another organization timestamp' do + let(:other_timestamp) { create(:vod_timestamp, vod_review: other_vod_review) } + + it 'returns forbidden' do + patch "/api/v1/vod-timestamps/#{other_timestamp.id}", + params: { vod_timestamp: { title: 'Hacked' } }.to_json, + headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'DELETE /api/v1/vod-timestamps/:id' do + let!(:timestamp) { create(:vod_timestamp, vod_review: vod_review) } + + context 'when authenticated as analyst' do + it 'deletes the timestamp' do + expect do + delete "/api/v1/vod-timestamps/#{timestamp.id}", headers: auth_headers(user) + end.to change(VodTimestamp, :count).by(-1) + + expect(response).to have_http_status(:success) + end + end + + context 'when accessing another organization timestamp' do + let(:other_timestamp) { create(:vod_timestamp, vod_review: other_vod_review) } + + it 'returns forbidden' do + delete "/api/v1/vod-timestamps/#{other_timestamp.id}", headers: auth_headers(user) + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/services/riot_api_service_spec.rb b/spec/services/riot_api_service_spec.rb index 2f911c9..4764076 100644 --- a/spec/services/riot_api_service_spec.rb +++ b/spec/services/riot_api_service_spec.rb @@ -1,74 +1,76 @@ -require 'rails_helper' - -RSpec.describe RiotApiService do - let(:api_key) { 'test-api-key' } - let(:service) { described_class.new(api_key: api_key) } - - describe '#initialize' do - it 'requires an API key' do - expect { described_class.new }.not_to raise_error - end - - it 'accepts custom API key' do - expect(service.instance_variable_get(:@api_key)).to eq(api_key) - end - end - - describe '#get_summoner_by_name' do - let(:summoner_name) { 'TestPlayer' } - let(:region) { 'BR' } - - it 'fetches summoner data' do - stub_request(:get, /br1.api.riotgames.com/) - .to_return( - status: 200, - body: { - id: 'summoner-id', - puuid: 'puuid-123', - name: 'TestPlayer', - summonerLevel: 150, - profileIconId: 4567 - }.to_json, - headers: { 'Content-Type' => 'application/json' } - ) - - result = service.get_summoner_by_name(summoner_name: summoner_name, region: region) - - expect(result).to include( - summoner_id: 'summoner-id', - puuid: 'puuid-123', - summoner_name: 'TestPlayer' - ) - end - - it 'raises NotFoundError for non-existent summoner' do - stub_request(:get, /br1.api.riotgames.com/) - .to_return(status: 404) - - expect { - service.get_summoner_by_name(summoner_name: summoner_name, region: region) - }.to raise_error(RiotApiService::NotFoundError) - end - - it 'raises RateLimitError when rate limited' do - stub_request(:get, /br1.api.riotgames.com/) - .to_return(status: 429, headers: { 'Retry-After' => '120' }) - - expect { - service.get_summoner_by_name(summoner_name: summoner_name, region: region) - }.to raise_error(RiotApiService::RateLimitError) - end - end - - describe 'region mapping' do - it 'maps BR to correct platform' do - expect(service.send(:platform_for_region, 'BR')).to eq('BR1') - end - - it 'raises error for unknown region' do - expect { - service.send(:platform_for_region, 'INVALID') - }.to raise_error(RiotApiService::RiotApiError, /Unknown region/) - end - end -end +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RiotApiService do + let(:api_key) { 'test-api-key' } + let(:service) { described_class.new(api_key: api_key) } + + describe '#initialize' do + it 'requires an API key' do + expect { described_class.new }.not_to raise_error + end + + it 'accepts custom API key' do + expect(service.instance_variable_get(:@api_key)).to eq(api_key) + end + end + + describe '#get_summoner_by_name' do + let(:summoner_name) { 'TestPlayer' } + let(:region) { 'BR' } + + it 'fetches summoner data' do + stub_request(:get, /br1.api.riotgames.com/) + .to_return( + status: 200, + body: { + id: 'summoner-id', + puuid: 'puuid-123', + name: 'TestPlayer', + summonerLevel: 150, + profileIconId: 4567 + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = service.get_summoner_by_name(summoner_name: summoner_name, region: region) + + expect(result).to include( + summoner_id: 'summoner-id', + puuid: 'puuid-123', + summoner_name: 'TestPlayer' + ) + end + + it 'raises NotFoundError for non-existent summoner' do + stub_request(:get, /br1.api.riotgames.com/) + .to_return(status: 404) + + expect do + service.get_summoner_by_name(summoner_name: summoner_name, region: region) + end.to raise_error(RiotApiService::NotFoundError) + end + + it 'raises RateLimitError when rate limited' do + stub_request(:get, /br1.api.riotgames.com/) + .to_return(status: 429, headers: { 'Retry-After' => '120' }) + + expect do + service.get_summoner_by_name(summoner_name: summoner_name, region: region) + end.to raise_error(RiotApiService::RateLimitError) + end + end + + describe 'region mapping' do + it 'maps BR to correct platform' do + expect(service.send(:platform_for_region, 'BR')).to eq('BR1') + end + + it 'raises error for unknown region' do + expect do + service.send(:platform_for_region, 'INVALID') + end.to raise_error(RiotApiService::RiotApiError, /Unknown region/) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 19e9d0e..bb87ace 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,30 +1,30 @@ -require 'simplecov' -SimpleCov.start 'rails' do - add_filter '/bin/' - add_filter '/db/' - add_filter '/spec/' - add_filter '/config/' -end - -RSpec.configure do |config| - config.expect_with :rspec do |expectations| - expectations.include_chain_clauses_in_custom_matcher_descriptions = true - end - - config.mock_with :rspec do |mocks| - mocks.verify_partial_doubles = true - end - - config.shared_context_metadata_behavior = :apply_to_host_groups - config.filter_run_when_matching :focus - config.example_status_persistence_file_path = "spec/examples.txt" - config.disable_monkey_patching! - - if config.files_to_run.one? - config.default_formatter = "doc" - end - - config.profile_examples = 10 - config.order = :random - Kernel.srand config.seed -end +# frozen_string_literal: true + +require 'simplecov' +SimpleCov.start 'rails' do + add_filter '/bin/' + add_filter '/db/' + add_filter '/spec/' + add_filter '/config/' +end + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = 'spec/examples.txt' + config.disable_monkey_patching! + + config.default_formatter = 'doc' if config.files_to_run.one? + + config.profile_examples = 10 + config.order = :random + Kernel.srand config.seed +end diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb index 2cd7c9b..3186510 100644 --- a/spec/support/request_spec_helper.rb +++ b/spec/support/request_spec_helper.rb @@ -1,19 +1,21 @@ -module RequestSpecHelper - # Helper method to generate JWT token for testing - def auth_token(user) - Authentication::Services::JwtService.encode(user_id: user.id) - end - - # Helper method to set authentication headers - def auth_headers(user) - { - 'Authorization' => "Bearer #{auth_token(user)}", - 'Content-Type' => 'application/json' - } - end - - # Helper to parse JSON response - def json_response - JSON.parse(response.body, symbolize_names: true) - end -end +# frozen_string_literal: true + +module RequestSpecHelper + # Helper method to generate JWT token for testing + def auth_token(user) + Authentication::Services::JwtService.encode(user_id: user.id) + end + + # Helper method to set authentication headers + def auth_headers(user) + { + 'Authorization' => "Bearer #{auth_token(user)}", + 'Content-Type' => 'application/json' + } + end + + # Helper to parse JSON response + def json_response + JSON.parse(response.body, symbolize_names: true) + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 0f44710..154a075 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -1,140 +1,142 @@ -require 'rails_helper' - -RSpec.configure do |config| - # Specify a root folder where Swagger JSON files are generated - config.swagger_root = Rails.root.join('swagger').to_s - - # Define one or more Swagger documents - config.swagger_docs = { - 'v1/swagger.yaml' => { - openapi: '3.0.1', - info: { - title: 'ProStaff API V1', - version: 'v1', - description: 'API documentation for ProStaff - Esports Team Management Platform', - contact: { - name: 'ProStaff Support', - email: 'support@prostaff.gg' - } - }, - servers: [ - { - url: 'http://localhost:3333', - description: 'Development server' - }, - { - url: 'https://api.prostaff.gg', - description: 'Production server' - } - ], - paths: {}, - components: { - securitySchemes: { - bearerAuth: { - type: :http, - scheme: :bearer, - bearerFormat: 'JWT', - description: 'JWT authorization token' - } - }, - schemas: { - Error: { - type: :object, - properties: { - error: { - type: :object, - properties: { - code: { type: :string }, - message: { type: :string }, - details: { type: :object } - }, - required: %w[code message] - } - }, - required: ['error'] - }, - User: { - type: :object, - properties: { - id: { type: :string, format: :uuid }, - email: { type: :string, format: :email }, - full_name: { type: :string }, - role: { type: :string, enum: %w[owner admin coach analyst viewer] }, - timezone: { type: :string }, - language: { type: :string }, - created_at: { type: :string, format: 'date-time' }, - updated_at: { type: :string, format: 'date-time' } - }, - required: %w[id email full_name role] - }, - Organization: { - type: :object, - properties: { - id: { type: :string, format: :uuid }, - name: { type: :string }, - region: { type: :string }, - tier: { type: :string, enum: %w[amateur semi_pro professional] }, - created_at: { type: :string, format: 'date-time' }, - updated_at: { type: :string, format: 'date-time' } - }, - required: %w[id name region tier] - }, - Player: { - type: :object, - properties: { - id: { type: :string, format: :uuid }, - summoner_name: { type: :string }, - real_name: { type: :string, nullable: true }, - role: { type: :string, enum: %w[top jungle mid adc support] }, - status: { type: :string, enum: %w[active inactive benched trial] }, - jersey_number: { type: :integer, nullable: true }, - country: { type: :string, nullable: true }, - solo_queue_tier: { type: :string, nullable: true }, - solo_queue_rank: { type: :string, nullable: true }, - solo_queue_lp: { type: :integer, nullable: true }, - current_rank: { type: :string }, - win_rate: { type: :number, format: :float }, - created_at: { type: :string, format: 'date-time' }, - updated_at: { type: :string, format: 'date-time' } - }, - required: %w[id summoner_name role status] - }, - Match: { - type: :object, - properties: { - id: { type: :string, format: :uuid }, - match_type: { type: :string, enum: %w[official scrim tournament] }, - game_start: { type: :string, format: 'date-time' }, - game_duration: { type: :integer }, - victory: { type: :boolean }, - opponent_name: { type: :string, nullable: true }, - our_score: { type: :integer, nullable: true }, - opponent_score: { type: :integer, nullable: true }, - result: { type: :string }, - created_at: { type: :string, format: 'date-time' }, - updated_at: { type: :string, format: 'date-time' } - }, - required: %w[id match_type] - }, - Pagination: { - type: :object, - properties: { - current_page: { type: :integer }, - per_page: { type: :integer }, - total_pages: { type: :integer }, - total_count: { type: :integer }, - has_next_page: { type: :boolean }, - has_prev_page: { type: :boolean } - } - } - } - }, - security: [ - { bearerAuth: [] } - ] - } - } - - # Specify the format of the output Swagger file - config.swagger_format = :yaml -end +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.configure do |config| + # Specify a root folder where Swagger JSON files are generated + config.swagger_root = Rails.root.join('swagger').to_s + + # Define one or more Swagger documents + config.swagger_docs = { + 'v1/swagger.yaml' => { + openapi: '3.0.1', + info: { + title: 'ProStaff API V1', + version: 'v1', + description: 'API documentation for ProStaff - Esports Team Management Platform', + contact: { + name: 'ProStaff Support', + email: 'support@prostaff.gg' + } + }, + servers: [ + { + url: 'http://localhost:3333', + description: 'Development server' + }, + { + url: 'https://api.prostaff.gg', + description: 'Production server' + } + ], + paths: {}, + components: { + securitySchemes: { + bearerAuth: { + type: :http, + scheme: :bearer, + bearerFormat: 'JWT', + description: 'JWT authorization token' + } + }, + schemas: { + Error: { + type: :object, + properties: { + error: { + type: :object, + properties: { + code: { type: :string }, + message: { type: :string }, + details: { type: :object } + }, + required: %w[code message] + } + }, + required: ['error'] + }, + User: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string, format: :email }, + full_name: { type: :string }, + role: { type: :string, enum: %w[owner admin coach analyst viewer] }, + timezone: { type: :string }, + language: { type: :string }, + created_at: { type: :string, format: 'date-time' }, + updated_at: { type: :string, format: 'date-time' } + }, + required: %w[id email full_name role] + }, + Organization: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + region: { type: :string }, + tier: { type: :string, enum: %w[amateur semi_pro professional] }, + created_at: { type: :string, format: 'date-time' }, + updated_at: { type: :string, format: 'date-time' } + }, + required: %w[id name region tier] + }, + Player: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + summoner_name: { type: :string }, + real_name: { type: :string, nullable: true }, + role: { type: :string, enum: %w[top jungle mid adc support] }, + status: { type: :string, enum: %w[active inactive benched trial] }, + jersey_number: { type: :integer, nullable: true }, + country: { type: :string, nullable: true }, + solo_queue_tier: { type: :string, nullable: true }, + solo_queue_rank: { type: :string, nullable: true }, + solo_queue_lp: { type: :integer, nullable: true }, + current_rank: { type: :string }, + win_rate: { type: :number, format: :float }, + created_at: { type: :string, format: 'date-time' }, + updated_at: { type: :string, format: 'date-time' } + }, + required: %w[id summoner_name role status] + }, + Match: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + match_type: { type: :string, enum: %w[official scrim tournament] }, + game_start: { type: :string, format: 'date-time' }, + game_duration: { type: :integer }, + victory: { type: :boolean }, + opponent_name: { type: :string, nullable: true }, + our_score: { type: :integer, nullable: true }, + opponent_score: { type: :integer, nullable: true }, + result: { type: :string }, + created_at: { type: :string, format: 'date-time' }, + updated_at: { type: :string, format: 'date-time' } + }, + required: %w[id match_type] + }, + Pagination: { + type: :object, + properties: { + current_page: { type: :integer }, + per_page: { type: :integer }, + total_pages: { type: :integer }, + total_count: { type: :integer }, + has_next_page: { type: :boolean }, + has_prev_page: { type: :boolean } + } + } + } + }, + security: [ + { bearerAuth: [] } + ] + } + } + + # Specify the format of the output Swagger file + config.swagger_format = :yaml +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index e9df78b..3549674 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -13,15 +13,2015 @@ servers: - url: https://api.prostaff.gg description: Production server paths: + "/api/v1/analytics/performance": + get: + summary: Get team performance analytics + tags: + - Analytics + security: + - bearerAuth: [] + description: Returns comprehensive team and player performance metrics + parameters: + - name: start_date + in: query + required: false + description: Start date (YYYY-MM-DD) + schema: + type: string + - name: end_date + in: query + required: false + description: End date (YYYY-MM-DD) + schema: + type: string + - name: time_period + in: query + required: false + description: Predefined period (week, month, season) + schema: + type: string + - name: player_id + in: query + required: false + description: Player ID for individual stats + schema: + type: string + responses: + '200': + description: performance data retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + team_overview: + type: object + properties: + total_matches: + type: integer + wins: + type: integer + losses: + type: integer + win_rate: + type: number + format: float + avg_game_duration: + type: integer + avg_kda: + type: number + format: float + avg_kills_per_game: + type: number + format: float + avg_deaths_per_game: + type: number + format: float + avg_assists_per_game: + type: number + format: float + best_performers: + type: array + win_rate_trend: + type: array + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/analytics/team-comparison": + get: + summary: Compare team players performance + tags: + - Analytics + security: + - bearerAuth: [] + description: Provides side-by-side comparison of all team players + parameters: + - name: start_date + in: query + required: false + description: Start date (YYYY-MM-DD) + schema: + type: string + - name: end_date + in: query + required: false + description: End date (YYYY-MM-DD) + schema: + type: string + responses: + '200': + description: comparison data retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + players: + type: array + items: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + games_played: + type: integer + kda: + type: number + format: float + avg_damage: + type: integer + avg_gold: + type: integer + avg_cs: + type: number + format: float + avg_vision_score: + type: number + format: float + avg_performance_score: + type: number + format: float + multikills: + type: object + properties: + double: + type: integer + triple: + type: integer + quadra: + type: integer + penta: + type: integer + team_averages: + type: object + role_rankings: + type: object + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/analytics/champions/{player_id}": + parameters: + - name: player_id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Get player champion statistics + tags: + - Analytics + security: + - bearerAuth: [] + description: Returns champion pool and performance statistics for a specific + player + responses: + '200': + description: champion stats retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + champion_stats: + type: array + items: + type: object + properties: + champion: + type: string + games_played: + type: integer + win_rate: + type: number + format: float + avg_kda: + type: number + format: float + mastery_grade: + type: string + enum: + - S + - A + - B + - C + - D + top_champions: + type: array + champion_diversity: + type: object + properties: + total_champions: + type: integer + highly_played: + type: integer + average_games: + type: number + format: float + '404': + description: player not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/analytics/kda-trend/{player_id}": + parameters: + - name: player_id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Get player KDA trend over recent matches + tags: + - Analytics + security: + - bearerAuth: [] + description: Shows KDA performance trend for the last 50 matches + responses: + '200': + description: KDA trend retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + kda_by_match: + type: array + items: + type: object + properties: + match_id: + type: string + date: + type: string + format: date-time + kills: + type: integer + deaths: + type: integer + assists: + type: integer + kda: + type: number + format: float + champion: + type: string + victory: + type: boolean + averages: + type: object + properties: + last_10_games: + type: number + format: float + last_20_games: + type: number + format: float + overall: + type: number + format: float + '404': + description: player not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/analytics/laning/{player_id}": + parameters: + - name: player_id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Get player laning phase statistics + tags: + - Analytics + security: + - bearerAuth: [] + description: Returns CS and gold performance metrics for laning phase + responses: + '200': + description: laning stats retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + cs_performance: + type: object + properties: + avg_cs_total: + type: number + format: float + avg_cs_per_min: + type: number + format: float + best_cs_game: + type: integer + worst_cs_game: + type: integer + gold_performance: + type: object + properties: + avg_gold: + type: integer + best_gold_game: + type: integer + worst_gold_game: + type: integer + cs_by_match: + type: array + '404': + description: player not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/analytics/teamfights/{player_id}": + parameters: + - name: player_id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Get player teamfight performance + tags: + - Analytics + security: + - bearerAuth: [] + description: Returns damage dealt/taken and teamfight participation metrics + responses: + '200': + description: teamfight stats retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + damage_performance: + type: object + properties: + avg_damage_dealt: + type: integer + avg_damage_taken: + type: integer + best_damage_game: + type: integer + avg_damage_per_min: + type: integer + participation: + type: object + properties: + avg_kills: + type: number + format: float + avg_assists: + type: number + format: float + avg_deaths: + type: number + format: float + multikill_stats: + type: object + properties: + double_kills: + type: integer + triple_kills: + type: integer + quadra_kills: + type: integer + penta_kills: + type: integer + by_match: + type: array + '404': + description: player not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/analytics/vision/{player_id}": + parameters: + - name: player_id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Get player vision control statistics + tags: + - Analytics + security: + - bearerAuth: [] + description: Returns ward placement, vision score, and vision control metrics + responses: + '200': + description: vision stats retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + vision_stats: + type: object + properties: + avg_vision_score: + type: number + format: float + avg_wards_placed: + type: number + format: float + avg_wards_killed: + type: number + format: float + best_vision_game: + type: integer + total_wards_placed: + type: integer + total_wards_killed: + type: integer + vision_per_min: + type: number + format: float + by_match: + type: array + role_comparison: + type: object + properties: + player_avg: + type: number + format: float + role_avg: + type: number + format: float + percentile: + type: integer + '404': + description: player not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" "/api/v1/auth/register": post: - summary: Register new organization and admin user + summary: Register new organization and admin user + tags: + - Authentication + parameters: [] + responses: + '201': + description: registration successful + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + user: + "$ref": "#/components/schemas/User" + organization: + "$ref": "#/components/schemas/Organization" + access_token: + type: string + refresh_token: + type: string + expires_in: + type: integer + '422': + description: validation errors + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + organization: + type: object + properties: + name: + type: string + example: Team Alpha + region: + type: string + example: BR + tier: + type: string + enum: + - amateur + - semi_pro + - professional + example: semi_pro + required: + - name + - region + - tier + user: + type: object + properties: + email: + type: string + format: email + example: admin@teamalpha.gg + password: + type: string + format: password + example: password123 + full_name: + type: string + example: John Doe + timezone: + type: string + example: America/Sao_Paulo + language: + type: string + example: pt-BR + required: + - email + - password + - full_name + required: + - organization + - user + "/api/v1/auth/login": + post: + summary: Login user + tags: + - Authentication + parameters: [] + responses: + '200': + description: login successful + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + user: + "$ref": "#/components/schemas/User" + organization: + "$ref": "#/components/schemas/Organization" + access_token: + type: string + refresh_token: + type: string + expires_in: + type: integer + '401': + description: invalid credentials + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + example: admin@teamalpha.gg + password: + type: string + format: password + example: password123 + required: + - email + - password + "/api/v1/auth/refresh": + post: + summary: Refresh access token + tags: + - Authentication + parameters: [] + responses: + '200': + description: token refreshed successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + expires_in: + type: integer + '401': + description: invalid refresh token + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + example: eyJhbGciOiJIUzI1NiJ9... + required: + - refresh_token + "/api/v1/auth/me": + get: + summary: Get current user info + tags: + - Authentication + security: + - bearerAuth: [] + responses: + '200': + description: user info retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + user: + "$ref": "#/components/schemas/User" + organization: + "$ref": "#/components/schemas/Organization" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/auth/logout": + post: + summary: Logout user + tags: + - Authentication + security: + - bearerAuth: [] + responses: + '200': + description: logout successful + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + "/api/v1/auth/forgot-password": + post: + summary: Request password reset + tags: + - Authentication + parameters: [] + responses: + '200': + description: password reset email sent + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + example: user@example.com + required: + - email + "/api/v1/auth/reset-password": + post: + summary: Reset password with token + tags: + - Authentication + parameters: [] + responses: + '200': + description: password reset successful + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + '400': + description: invalid or expired token + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + token: + type: string + example: reset_token_here + password: + type: string + format: password + example: newpassword123 + password_confirmation: + type: string + format: password + example: newpassword123 + required: + - token + - password + - password_confirmation + "/api/v1/dashboard": + get: + summary: Get dashboard overview + tags: + - Dashboard + security: + - bearerAuth: [] + responses: + '200': + description: dashboard data retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + stats: + type: object + properties: + total_players: + type: integer + active_players: + type: integer + total_matches: + type: integer + wins: + type: integer + losses: + type: integer + win_rate: + type: number + format: float + recent_form: + type: string + example: WWLWW + avg_kda: + type: number + format: float + active_goals: + type: integer + completed_goals: + type: integer + upcoming_matches: + type: integer + recent_matches: + type: array + items: + "$ref": "#/components/schemas/Match" + upcoming_events: + type: array + active_goals: + type: array + roster_status: + type: object + properties: + by_role: + type: object + by_status: + type: object + contracts_expiring: + type: integer + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/dashboard/stats": + get: + summary: Get dashboard statistics + tags: + - Dashboard + security: + - bearerAuth: [] + responses: + '200': + description: stats retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + total_players: + type: integer + active_players: + type: integer + total_matches: + type: integer + wins: + type: integer + losses: + type: integer + win_rate: + type: number + format: float + recent_form: + type: string + example: WWLWW + avg_kda: + type: number + format: float + active_goals: + type: integer + completed_goals: + type: integer + upcoming_matches: + type: integer + "/api/v1/dashboard/activities": + get: + summary: Get recent activities + tags: + - Dashboard + security: + - bearerAuth: [] + responses: + '200': + description: activities retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + activities: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + action: + type: string + entity_type: + type: string + entity_id: + type: string + format: uuid + user: + type: string + timestamp: + type: string + format: date-time + changes: + type: object + nullable: true + count: + type: integer + "/api/v1/dashboard/schedule": + get: + summary: Get upcoming schedule + tags: + - Dashboard + security: + - bearerAuth: [] + responses: + '200': + description: schedule retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + events: + type: array + count: + type: integer + "/api/v1/matches": + get: + summary: List all matches + tags: + - Matches + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + description: Page number + schema: + type: integer + - name: per_page + in: query + required: false + description: Items per page + schema: + type: integer + - name: match_type + in: query + required: false + description: Filter by match type (official, scrim, tournament) + schema: + type: string + - name: result + in: query + required: false + description: Filter by result (victory, defeat) + schema: + type: string + - name: start_date + in: query + required: false + description: Start date for filtering (YYYY-MM-DD) + schema: + type: string + - name: end_date + in: query + required: false + description: End date for filtering (YYYY-MM-DD) + schema: + type: string + - name: days + in: query + required: false + description: Filter recent matches (e.g., 7, 30, 90 days) + schema: + type: integer + - name: opponent + in: query + required: false + description: Filter by opponent name + schema: + type: string + - name: tournament + in: query + required: false + description: Filter by tournament name + schema: + type: string + - name: sort_by + in: query + required: false + description: Sort field (game_start, game_duration, match_type, victory) + schema: + type: string + - name: sort_order + in: query + required: false + description: Sort order (asc, desc) + schema: + type: string + responses: + '200': + description: matches found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + matches: + type: array + items: + "$ref": "#/components/schemas/Match" + pagination: + "$ref": "#/components/schemas/Pagination" + summary: + type: object + properties: + total: + type: integer + victories: + type: integer + defeats: + type: integer + win_rate: + type: number + format: float + by_type: + type: object + avg_duration: + type: integer + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + post: + summary: Create a match + tags: + - Matches + security: + - bearerAuth: [] + parameters: [] + responses: + '201': + description: match created + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + match: + "$ref": "#/components/schemas/Match" + '422': + description: invalid request + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + match: + type: object + properties: + match_type: + type: string + enum: + - official + - scrim + - tournament + game_start: + type: string + format: date-time + game_end: + type: string + format: date-time + game_duration: + type: integer + description: Duration in seconds + opponent_name: + type: string + opponent_tag: + type: string + victory: + type: boolean + our_side: + type: string + enum: + - blue + - red + our_score: + type: integer + opponent_score: + type: integer + tournament_name: + type: string + stage: + type: string + patch_version: + type: string + vod_url: + type: string + notes: + type: string + required: + - match_type + - game_start + - victory + "/api/v1/matches/{id}": + parameters: + - name: id + in: path + description: Match ID + required: true + schema: + type: string + get: + summary: Show match details + tags: + - Matches + security: + - bearerAuth: [] + responses: + '200': + description: match found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + match: + "$ref": "#/components/schemas/Match" + player_stats: + type: array + items: + "$ref": "#/components/schemas/PlayerMatchStat" + team_composition: + type: object + mvp: + "$ref": "#/components/schemas/Player" + nullable: true + '404': + description: match not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + patch: + summary: Update a match + tags: + - Matches + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: match updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + match: + "$ref": "#/components/schemas/Match" + requestBody: + content: + application/json: + schema: + type: object + properties: + match: + type: object + properties: + match_type: + type: string + victory: + type: boolean + notes: + type: string + vod_url: + type: string + delete: + summary: Delete a match + tags: + - Matches + security: + - bearerAuth: [] + responses: + '200': + description: match deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + "/api/v1/matches/{id}/stats": + parameters: + - name: id + in: path + description: Match ID + required: true + schema: + type: string + get: + summary: Get match statistics + tags: + - Matches + security: + - bearerAuth: [] + responses: + '200': + description: statistics retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + match: + "$ref": "#/components/schemas/Match" + team_stats: + type: object + properties: + total_kills: + type: integer + total_deaths: + type: integer + total_assists: + type: integer + total_gold: + type: integer + total_damage: + type: integer + total_cs: + type: integer + total_vision_score: + type: integer + avg_kda: + type: number + format: float + "/api/v1/matches/import": + post: + summary: Import matches from Riot API + tags: + - Matches + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: import started + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + job_id: + type: string + player_id: + type: string + count: + type: integer + '400': + description: player missing PUUID + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + player_id: + type: string + description: Player ID to import matches for + count: + type: integer + description: Number of matches to import (1-100) + default: 20 + required: + - player_id + "/api/v1/players": + get: + summary: List all players + tags: + - Players + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + description: Page number + schema: + type: integer + - name: per_page + in: query + required: false + description: Items per page + schema: + type: integer + - name: role + in: query + required: false + description: Filter by role + schema: + type: string + - name: status + in: query + required: false + description: Filter by status + schema: + type: string + - name: search + in: query + required: false + description: Search by summoner name or real name + schema: + type: string + responses: + '200': + description: players found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + players: + type: array + items: + "$ref": "#/components/schemas/Player" + pagination: + "$ref": "#/components/schemas/Pagination" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + post: + summary: Create a player + tags: + - Players + security: + - bearerAuth: [] + parameters: [] + responses: + '201': + description: player created + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + '422': + description: invalid request + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + player: + type: object + properties: + summoner_name: + type: string + real_name: + type: string + role: + type: string + enum: + - top + - jungle + - mid + - adc + - support + status: + type: string + enum: + - active + - inactive + - benched + - trial + jersey_number: + type: integer + birth_date: + type: string + format: date + country: + type: string + required: + - summoner_name + - role + "/api/v1/players/{id}": + parameters: + - name: id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Show player details + tags: + - Players + security: + - bearerAuth: [] + responses: + '200': + description: player found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + '404': + description: player not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + patch: + summary: Update a player + tags: + - Players + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: player updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + requestBody: + content: + application/json: + schema: + type: object + properties: + player: + type: object + properties: + summoner_name: + type: string + real_name: + type: string + status: + type: string + delete: + summary: Delete a player + tags: + - Players + security: + - bearerAuth: [] + responses: + '200': + description: player deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + "/api/v1/players/{id}/stats": + parameters: + - name: id + in: path + description: Player ID + required: true + schema: + type: string + get: + summary: Get player statistics + tags: + - Players + security: + - bearerAuth: [] + responses: + '200': + description: statistics retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + player: + "$ref": "#/components/schemas/Player" + overall: + type: object + recent_form: + type: object + champion_pool: + type: array + performance_by_role: + type: array + "/api/v1/riot-data/champions": + get: + summary: Get champions ID map + tags: + - Riot Data + responses: + '200': + description: champions retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + champions: + type: object + count: + type: integer + '503': + description: service unavailable + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/champions/{champion_key}": + parameters: + - name: champion_key + in: path + description: Champion key (e.g., "266" for Aatrox) + required: true + schema: + type: string + get: + summary: Get champion details by key + tags: + - Riot Data + responses: + '200': + description: champion found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + champion: + type: object + '404': + description: champion not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/all-champions": + get: + summary: Get all champions details + tags: + - Riot Data + security: + - bearerAuth: [] + responses: + '200': + description: champions retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + champions: + type: array + items: + type: object + count: + type: integer + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/items": + get: + summary: Get all items + tags: + - Riot Data + responses: + '200': + description: items retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + items: + type: object + count: + type: integer + '503': + description: service unavailable + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/summoner-spells": + get: + summary: Get all summoner spells + tags: + - Riot Data + security: + - bearerAuth: [] + responses: + '200': + description: summoner spells retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + summoner_spells: + type: object + count: + type: integer + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/version": + get: + summary: Get current Data Dragon version + tags: + - Riot Data + responses: + '200': + description: version retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + version: + type: string + '503': + description: service unavailable + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/clear-cache": + post: + summary: Clear Data Dragon cache tags: - - Authentication + - Riot Data + security: + - bearerAuth: [] + responses: + '200': + description: cache cleared + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + message: + type: string + '403': + description: forbidden + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-data/update-cache": + post: + summary: Update Data Dragon cache + tags: + - Riot Data + security: + - bearerAuth: [] + responses: + '200': + description: cache updated + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + message: + type: string + version: + type: string + data: + type: object + properties: + champions: + type: integer + items: + type: integer + summoner_spells: + type: integer + '403': + description: forbidden + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/riot-integration/sync-status": + get: + summary: Get Riot API synchronization status + tags: + - Riot Integration + security: + - bearerAuth: [] + description: Returns statistics about player data synchronization with Riot + API + responses: + '200': + description: sync status retrieved + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + stats: + type: object + properties: + total_players: + type: integer + description: Total number of players in the organization + synced_players: + type: integer + description: Players successfully synced + pending_sync: + type: integer + description: Players pending synchronization + failed_sync: + type: integer + description: Players with failed sync + recently_synced: + type: integer + description: Players synced in the last 24 hours + needs_sync: + type: integer + description: Players that need to be synced + recent_syncs: + type: array + description: List of 10 most recently synced players + items: + type: object + properties: + id: + type: string + summoner_name: + type: string + last_sync_at: + type: string + format: date-time + sync_status: + type: string + enum: + - pending + - success + - error + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + "/api/v1/schedules": + get: + summary: List all schedules + tags: + - Schedules + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + description: Page number + schema: + type: integer + - name: per_page + in: query + required: false + description: Items per page + schema: + type: integer + - name: event_type + in: query + required: false + description: Filter by event type (match, scrim, practice, meeting, other) + schema: + type: string + - name: status + in: query + required: false + description: Filter by status (scheduled, ongoing, completed, cancelled) + schema: + type: string + - name: start_date + in: query + required: false + description: Start date for filtering (YYYY-MM-DD) + schema: + type: string + - name: end_date + in: query + required: false + description: End date for filtering (YYYY-MM-DD) + schema: + type: string + - name: upcoming + in: query + required: false + description: Filter upcoming events + schema: + type: boolean + - name: past + in: query + required: false + description: Filter past events + schema: + type: boolean + - name: today + in: query + required: false + description: Filter today's events + schema: + type: boolean + - name: this_week + in: query + required: false + description: Filter this week's events + schema: + type: boolean + - name: sort_order + in: query + required: false + description: Sort order (asc, desc) + schema: + type: string + responses: + '200': + description: schedules found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + schedules: + type: array + items: + "$ref": "#/components/schemas/Schedule" + pagination: + "$ref": "#/components/schemas/Pagination" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + post: + summary: Create a schedule + tags: + - Schedules + security: + - bearerAuth: [] parameters: [] responses: '201': - description: registration successful + description: schedule created content: application/json: schema: @@ -32,18 +2032,10 @@ paths: data: type: object properties: - user: - "$ref": "#/components/schemas/User" - organization: - "$ref": "#/components/schemas/Organization" - access_token: - type: string - refresh_token: - type: string - expires_in: - type: integer + schedule: + "$ref": "#/components/schemas/Schedule" '422': - description: validation errors + description: invalid request content: application/json: schema: @@ -54,62 +2046,158 @@ paths: schema: type: object properties: - organization: + schedule: type: object properties: - name: + event_type: type: string - example: Team Alpha - region: + enum: + - match + - scrim + - practice + - meeting + - other + title: type: string - example: BR - tier: + description: + type: string + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + location: + type: string + opponent_name: + type: string + status: type: string enum: - - amateur - - semi_pro - - professional - example: semi_pro + - scheduled + - ongoing + - completed + - cancelled + default: scheduled + match_id: + type: string + meeting_url: + type: string + all_day: + type: boolean + timezone: + type: string + color: + type: string + is_recurring: + type: boolean + recurrence_rule: + type: string + recurrence_end_date: + type: string + format: date + reminder_minutes: + type: integer + required_players: + type: array + items: + type: string + optional_players: + type: array + items: + type: string + tags: + type: array + items: + type: string required: - - name - - region - - tier - user: + - event_type + - title + - start_time + - end_time + "/api/v1/schedules/{id}": + parameters: + - name: id + in: path + description: Schedule ID + required: true + schema: + type: string + get: + summary: Show schedule details + tags: + - Schedules + security: + - bearerAuth: [] + responses: + '200': + description: schedule found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + schedule: + "$ref": "#/components/schemas/Schedule" + '404': + description: schedule not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + patch: + summary: Update a schedule + tags: + - Schedules + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: schedule updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + schedule: + "$ref": "#/components/schemas/Schedule" + requestBody: + content: + application/json: + schema: + type: object + properties: + schedule: type: object properties: - email: + title: type: string - format: email - example: admin@teamalpha.gg - password: + description: type: string - format: password - example: password123 - full_name: + status: type: string - example: John Doe - timezone: + location: type: string - example: America/Sao_Paulo - language: + meeting_url: type: string - example: pt-BR - required: - - email - - password - - full_name - required: - - organization - - user - "/api/v1/auth/login": - post: - summary: Login user + delete: + summary: Delete a schedule tags: - - Authentication - parameters: [] + - Schedules + security: + - bearerAuth: [] responses: '200': - description: login successful + description: schedule deleted content: application/json: schema: @@ -117,51 +2205,132 @@ paths: properties: message: type: string + "/api/v1/scouting/players": + get: + summary: List all scouting targets + tags: + - Scouting + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + description: Page number + schema: + type: integer + - name: per_page + in: query + required: false + description: Items per page + schema: + type: integer + - name: role + in: query + required: false + description: Filter by role (top, jungle, mid, adc, support) + schema: + type: string + - name: status + in: query + required: false + description: Filter by status (watching, contacted, negotiating, rejected, + signed) + schema: + type: string + - name: priority + in: query + required: false + description: Filter by priority (low, medium, high, critical) + schema: + type: string + - name: region + in: query + required: false + description: Filter by region + schema: + type: string + - name: active + in: query + required: false + description: Filter active targets only + schema: + type: boolean + - name: high_priority + in: query + required: false + description: Filter high priority targets only + schema: + type: boolean + - name: needs_review + in: query + required: false + description: Filter targets needing review + schema: + type: boolean + - name: assigned_to_id + in: query + required: false + description: Filter by assigned user + schema: + type: string + - name: search + in: query + required: false + description: Search by summoner name or real name + schema: + type: string + - name: sort_by + in: query + required: false + description: Sort field + schema: + type: string + - name: sort_order + in: query + required: false + description: Sort order (asc, desc) + schema: + type: string + responses: + '200': + description: scouting targets found + content: + application/json: + schema: + type: object + properties: data: type: object properties: - user: - "$ref": "#/components/schemas/User" - organization: - "$ref": "#/components/schemas/Organization" - access_token: - type: string - refresh_token: - type: string - expires_in: + players: + type: array + items: + "$ref": "#/components/schemas/ScoutingTarget" + total: + type: integer + page: + type: integer + per_page: + type: integer + total_pages: type: integer '401': - description: invalid credentials + description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - type: string - format: email - example: admin@teamalpha.gg - password: - type: string - format: password - example: password123 - required: - - email - - password - "/api/v1/auth/refresh": post: - summary: Refresh access token + summary: Create a scouting target tags: - - Authentication + - Scouting + security: + - bearerAuth: [] parameters: [] responses: - '200': - description: token refreshed successfully + '201': + description: scouting target created content: application/json: schema: @@ -172,14 +2341,10 @@ paths: data: type: object properties: - access_token: - type: string - refresh_token: - type: string - expires_in: - type: integer - '401': - description: invalid refresh token + scouting_target: + "$ref": "#/components/schemas/ScoutingTarget" + '422': + description: invalid request content: application/json: schema: @@ -190,21 +2355,98 @@ paths: schema: type: object properties: - refresh_token: - type: string - example: eyJhbGciOiJIUzI1NiJ9... - required: - - refresh_token - "/api/v1/auth/me": + scouting_target: + type: object + properties: + summoner_name: + type: string + real_name: + type: string + role: + type: string + enum: + - top + - jungle + - mid + - adc + - support + region: + type: string + enum: + - BR + - NA + - EUW + - KR + - EUNE + - LAN + - LAS + - OCE + - RU + - TR + - JP + nationality: + type: string + age: + type: integer + status: + type: string + enum: + - watching + - contacted + - negotiating + - rejected + - signed + default: watching + priority: + type: string + enum: + - low + - medium + - high + - critical + default: medium + current_team: + type: string + email: + type: string + format: email + phone: + type: string + discord_username: + type: string + twitter_handle: + type: string + scouting_notes: + type: string + contact_notes: + type: string + availability: + type: string + salary_expectations: + type: string + assigned_to_id: + type: string + required: + - summoner_name + - region + - role + "/api/v1/scouting/players/{id}": + parameters: + - name: id + in: path + description: Scouting Target ID + required: true + schema: + type: string get: - summary: Get current user info + summary: Show scouting target details tags: - - Authentication + - Scouting security: - bearerAuth: [] responses: '200': - description: user info retrieved + description: scouting target found content: application/json: schema: @@ -213,44 +2455,24 @@ paths: data: type: object properties: - user: - "$ref": "#/components/schemas/User" - organization: - "$ref": "#/components/schemas/Organization" - '401': - description: unauthorized + scouting_target: + "$ref": "#/components/schemas/ScoutingTarget" + '404': + description: scouting target not found content: application/json: schema: "$ref": "#/components/schemas/Error" - "/api/v1/auth/logout": - post: - summary: Logout user + patch: + summary: Update a scouting target tags: - - Authentication + - Scouting security: - bearerAuth: [] - responses: - '200': - description: logout successful - content: - application/json: - schema: - type: object - properties: - message: - type: string - data: - type: object - "/api/v1/auth/forgot-password": - post: - summary: Request password reset - tags: - - Authentication parameters: [] responses: '200': - description: password reset email sent + description: scouting target updated content: application/json: schema: @@ -260,27 +2482,35 @@ paths: type: string data: type: object + properties: + scouting_target: + "$ref": "#/components/schemas/ScoutingTarget" requestBody: content: application/json: schema: type: object properties: - email: - type: string - format: email - example: user@example.com - required: - - email - "/api/v1/auth/reset-password": - post: - summary: Reset password with token + scouting_target: + type: object + properties: + status: + type: string + priority: + type: string + scouting_notes: + type: string + contact_notes: + type: string + delete: + summary: Delete a scouting target tags: - - Authentication - parameters: [] + - Scouting + security: + - bearerAuth: [] responses: '200': - description: password reset successful + description: scouting target deleted content: application/json: schema: @@ -288,45 +2518,56 @@ paths: properties: message: type: string - data: - type: object - '400': - description: invalid or expired token + "/api/v1/scouting/regions": + get: + summary: Get scouting statistics by region + tags: + - Scouting + security: + - bearerAuth: [] + responses: + '200': + description: regional statistics retrieved content: application/json: schema: - "$ref": "#/components/schemas/Error" - requestBody: - content: - application/json: - schema: - type: object - properties: - token: - type: string - example: reset_token_here - password: - type: string - format: password - example: newpassword123 - password_confirmation: - type: string - format: password - example: newpassword123 - required: - - token - - password - - password_confirmation - "/api/v1/dashboard": + type: object + properties: + data: + type: object + properties: + regions: + type: array + items: + type: object + properties: + region: + type: string + total_targets: + type: integer + by_status: + type: object + by_priority: + type: object + avg_tier: + type: string + "/api/v1/scouting/watchlist": get: - summary: Get dashboard overview + summary: Get watchlist (active scouting targets) tags: - - Dashboard + - Scouting security: - bearerAuth: [] + parameters: + - name: assigned_to_me + in: query + required: false + description: Filter targets assigned to current user + schema: + type: boolean responses: '200': - description: dashboard data retrieved + description: watchlist retrieved content: application/json: schema: @@ -335,110 +2576,262 @@ paths: data: type: object properties: + watchlist: + type: array + items: + "$ref": "#/components/schemas/ScoutingTarget" stats: type: object properties: - total_players: - type: integer - active_players: - type: integer - total_matches: - type: integer - wins: - type: integer - losses: - type: integer - win_rate: - type: number - format: float - recent_form: - type: string - example: WWLWW - avg_kda: - type: number - format: float - active_goals: + total: type: integer - completed_goals: + needs_review: type: integer - upcoming_matches: + high_priority: type: integer - recent_matches: + "/api/v1/team-goals": + get: + summary: List all team goals + tags: + - Team Goals + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + description: Page number + schema: + type: integer + - name: per_page + in: query + required: false + description: Items per page + schema: + type: integer + - name: status + in: query + required: false + description: Filter by status (not_started, in_progress, completed, cancelled) + schema: + type: string + - name: category + in: query + required: false + description: Filter by category (performance, training, tournament, development, + team_building, other) + schema: + type: string + - name: player_id + in: query + required: false + description: Filter by player ID + schema: + type: string + - name: type + in: query + required: false + description: Filter by type (team, player) + schema: + type: string + - name: active + in: query + required: false + description: Filter active goals only + schema: + type: boolean + - name: overdue + in: query + required: false + description: Filter overdue goals only + schema: + type: boolean + - name: expiring_soon + in: query + required: false + description: Filter goals expiring soon + schema: + type: boolean + - name: expiring_days + in: query + required: false + description: 'Days threshold for expiring soon (default: 7)' + schema: + type: integer + - name: assigned_to_id + in: query + required: false + description: Filter by assigned user ID + schema: + type: string + - name: sort_by + in: query + required: false + description: Sort field (created_at, updated_at, title, status, category, + start_date, end_date, progress) + schema: + type: string + - name: sort_order + in: query + required: false + description: Sort order (asc, desc) + schema: + type: string + responses: + '200': + description: team goals found + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + goals: type: array items: - "$ref": "#/components/schemas/Match" - upcoming_events: - type: array - active_goals: - type: array - roster_status: + "$ref": "#/components/schemas/TeamGoal" + pagination: + "$ref": "#/components/schemas/Pagination" + summary: type: object properties: - by_role: - type: object + total: + type: integer by_status: type: object - contracts_expiring: + by_category: + type: object + active_count: + type: integer + completed_count: type: integer + overdue_count: + type: integer + avg_progress: + type: number + format: float '401': description: unauthorized content: application/json: schema: "$ref": "#/components/schemas/Error" - "/api/v1/dashboard/stats": - get: - summary: Get dashboard statistics + post: + summary: Create a team goal tags: - - Dashboard + - Team Goals security: - bearerAuth: [] + parameters: [] responses: - '200': - description: stats retrieved + '201': + description: team goal created content: application/json: schema: type: object properties: + message: + type: string data: type: object properties: - total_players: - type: integer - active_players: - type: integer - total_matches: - type: integer - wins: - type: integer - losses: - type: integer - win_rate: - type: number - format: float - recent_form: - type: string - example: WWLWW - avg_kda: - type: number - format: float - active_goals: - type: integer - completed_goals: - type: integer - upcoming_matches: - type: integer - "/api/v1/dashboard/activities": + goal: + "$ref": "#/components/schemas/TeamGoal" + '422': + description: invalid request + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + requestBody: + content: + application/json: + schema: + type: object + properties: + team_goal: + type: object + properties: + title: + type: string + description: + type: string + category: + type: string + enum: + - performance + - training + - tournament + - development + - team_building + - other + metric_type: + type: string + enum: + - percentage + - number + - kda + - win_rate + - rank + - other + target_value: + type: number + format: float + current_value: + type: number + format: float + start_date: + type: string + format: date + end_date: + type: string + format: date + status: + type: string + enum: + - not_started + - in_progress + - completed + - cancelled + default: not_started + progress: + type: integer + description: Progress percentage (0-100) + notes: + type: string + player_id: + type: string + description: Player ID if this is a player-specific goal + assigned_to_id: + type: string + description: User ID responsible for tracking this goal + required: + - title + - category + - metric_type + - target_value + - start_date + - end_date + "/api/v1/team-goals/{id}": + parameters: + - name: id + in: path + description: Team Goal ID + required: true + schema: + type: string get: - summary: Get recent activities + summary: Show team goal details tags: - - Dashboard + - Team Goals security: - bearerAuth: [] responses: '200': - description: activities retrieved + description: team goal found content: application/json: schema: @@ -447,58 +2840,79 @@ paths: data: type: object properties: - activities: - type: array - items: - type: object - properties: - id: - type: string - format: uuid - action: - type: string - entity_type: - type: string - entity_id: - type: string - format: uuid - user: - type: string - timestamp: - type: string - format: date-time - changes: - type: object - nullable: true - count: - type: integer - "/api/v1/dashboard/schedule": - get: - summary: Get upcoming schedule + goal: + "$ref": "#/components/schemas/TeamGoal" + '404': + description: team goal not found + content: + application/json: + schema: + "$ref": "#/components/schemas/Error" + patch: + summary: Update a team goal tags: - - Dashboard + - Team Goals security: - bearerAuth: [] + parameters: [] responses: '200': - description: schedule retrieved + description: team goal updated content: application/json: schema: type: object properties: + message: + type: string data: type: object properties: - events: - type: array - count: - type: integer - "/api/v1/players": + goal: + "$ref": "#/components/schemas/TeamGoal" + requestBody: + content: + application/json: + schema: + type: object + properties: + team_goal: + type: object + properties: + title: + type: string + description: + type: string + status: + type: string + current_value: + type: number + format: float + progress: + type: integer + notes: + type: string + delete: + summary: Delete a team goal + tags: + - Team Goals + security: + - bearerAuth: [] + responses: + '200': + description: team goal deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + "/api/v1/vod-reviews": get: - summary: List all players + summary: List all VOD reviews tags: - - Players + - VOD Reviews security: - bearerAuth: [] parameters: @@ -514,27 +2928,27 @@ paths: description: Items per page schema: type: integer - - name: role + - name: match_id in: query required: false - description: Filter by role + description: Filter by match ID schema: type: string - - name: status + - name: reviewed_by_id in: query required: false - description: Filter by status + description: Filter by reviewer ID schema: type: string - - name: search + - name: status in: query required: false - description: Search by summoner name or real name + description: Filter by status (draft, published, archived) schema: type: string responses: '200': - description: players found + description: VOD reviews found content: application/json: schema: @@ -543,10 +2957,10 @@ paths: data: type: object properties: - players: + vod_reviews: type: array items: - "$ref": "#/components/schemas/Player" + "$ref": "#/components/schemas/VodReview" pagination: "$ref": "#/components/schemas/Pagination" '401': @@ -556,15 +2970,15 @@ paths: schema: "$ref": "#/components/schemas/Error" post: - summary: Create a player + summary: Create a VOD review tags: - - Players + - VOD Reviews security: - bearerAuth: [] parameters: [] responses: '201': - description: player created + description: VOD review created content: application/json: schema: @@ -575,8 +2989,8 @@ paths: data: type: object properties: - player: - "$ref": "#/components/schemas/Player" + vod_review: + "$ref": "#/components/schemas/VodReview" '422': description: invalid request content: @@ -589,55 +3003,55 @@ paths: schema: type: object properties: - player: + vod_review: type: object properties: - summoner_name: + match_id: type: string - real_name: + title: type: string - role: + vod_url: type: string - enum: - - top - - jungle - - mid - - adc - - support - status: + vod_platform: type: string enum: - - active - - inactive - - benched - - trial - jersey_number: - type: integer - birth_date: + - youtube + - twitch + - gdrive + - other + summary: type: string - format: date - country: + status: type: string + enum: + - draft + - published + - archived + default: draft + tags: + type: array + items: + type: string required: - - summoner_name - - role - "/api/v1/players/{id}": + - title + - vod_url + "/api/v1/vod-reviews/{id}": parameters: - name: id in: path - description: Player ID + description: VOD Review ID required: true schema: type: string get: - summary: Show player details + summary: Show VOD review details tags: - - Players + - VOD Reviews security: - bearerAuth: [] responses: '200': - description: player found + description: VOD review found content: application/json: schema: @@ -646,24 +3060,28 @@ paths: data: type: object properties: - player: - "$ref": "#/components/schemas/Player" + vod_review: + "$ref": "#/components/schemas/VodReview" + timestamps: + type: array + items: + "$ref": "#/components/schemas/VodTimestamp" '404': - description: player not found + description: VOD review not found content: application/json: schema: "$ref": "#/components/schemas/Error" patch: - summary: Update a player + summary: Update a VOD review tags: - - Players + - VOD Reviews security: - bearerAuth: [] parameters: [] responses: '200': - description: player updated + description: VOD review updated content: application/json: schema: @@ -674,32 +3092,32 @@ paths: data: type: object properties: - player: - "$ref": "#/components/schemas/Player" + vod_review: + "$ref": "#/components/schemas/VodReview" requestBody: content: application/json: schema: type: object properties: - player: + vod_review: type: object properties: - summoner_name: + title: type: string - real_name: + summary: type: string status: type: string delete: - summary: Delete a player + summary: Delete a VOD review tags: - - Players + - VOD Reviews security: - bearerAuth: [] responses: '200': - description: player deleted + description: VOD review deleted content: application/json: schema: @@ -707,23 +3125,37 @@ paths: properties: message: type: string - "/api/v1/players/{id}/stats": + "/api/v1/vod-reviews/{vod_review_id}/timestamps": parameters: - - name: id + - name: vod_review_id in: path - description: Player ID + description: VOD Review ID required: true schema: type: string get: - summary: Get player statistics + summary: List timestamps for a VOD review tags: - - Players + - VOD Reviews security: - bearerAuth: [] + parameters: + - name: category + in: query + required: false + description: Filter by category (mistake, good_play, objective, teamfight, + other) + schema: + type: string + - name: importance + in: query + required: false + description: Filter by importance (low, medium, high, critical) + schema: + type: string responses: '200': - description: statistics retrieved + description: timestamps found content: application/json: schema: @@ -732,16 +3164,142 @@ paths: data: type: object properties: - player: - "$ref": "#/components/schemas/Player" - overall: - type: object - recent_form: - type: object - champion_pool: - type: array - performance_by_role: + timestamps: type: array + items: + "$ref": "#/components/schemas/VodTimestamp" + post: + summary: Create a timestamp + tags: + - VOD Reviews + security: + - bearerAuth: [] + parameters: [] + responses: + '201': + description: timestamp created + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + timestamp: + "$ref": "#/components/schemas/VodTimestamp" + requestBody: + content: + application/json: + schema: + type: object + properties: + vod_timestamp: + type: object + properties: + timestamp_seconds: + type: integer + description: Timestamp in seconds + title: + type: string + description: + type: string + category: + type: string + enum: + - mistake + - good_play + - objective + - teamfight + - other + importance: + type: string + enum: + - low + - medium + - high + - critical + target_type: + type: string + enum: + - team + - player + description: Who this timestamp is about + target_player_id: + type: string + description: Player ID if target_type is player + tags: + type: array + items: + type: string + required: + - timestamp_seconds + - title + - category + - importance + "/api/v1/vod-timestamps/{id}": + parameters: + - name: id + in: path + description: VOD Timestamp ID + required: true + schema: + type: string + patch: + summary: Update a timestamp + tags: + - VOD Reviews + security: + - bearerAuth: [] + parameters: [] + responses: + '200': + description: timestamp updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + timestamp: + "$ref": "#/components/schemas/VodTimestamp" + requestBody: + content: + application/json: + schema: + type: object + properties: + vod_timestamp: + type: object + properties: + title: + type: string + description: + type: string + importance: + type: string + delete: + summary: Delete a timestamp + tags: + - VOD Reviews + security: + - bearerAuth: [] + responses: + '200': + description: timestamp deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string components: securitySchemes: bearerAuth: