From 6cf11e6517b4e8198dde59f2e8d6310e6949966b Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 9 Feb 2025 22:50:18 -0800 Subject: [PATCH] Jedmund/fix image embeds 4 (#177) * Update gitignore There is a mystery postgres folder and we are going to ignore it * Add migrations * Update preview state default to pending * Adds indexes * Adds PgHero and PgStatements * Update .gitignore * Update Gemfile Production: - `newrelic_rpm` Development: - `pg_query` - `prosopite` * Configure Sidekiq Create job for cleaning up party previews * Configure Prosopite and remove CacheFreeLogger * Enable query logging * Update api_controller.rb Add N+1 detectioin via Prosopite in development/test environments * Refactor canonical object blueprints * Refactor grid object blueprints * Remove N+1 from grid object models Reimplementing `character` `summon` and `weapon` was making N+1s which made queries really slow * Add counter caches to party * Add preview generation helpers The Party model can respond to queries about its preview state with the following models: - `schedule_preview_generation` - `preview_content_changed?` - `preview_expired?` - `should_generate_preview?` - `ready_for_preview?` - `needs_preview_generation?` - `preview_relevant_attributes` Removes the following methods: - `schedule_preview_regeneration` - `preview_relevant_changes?` * Add cache to is_favorited(user) * Refactored PartyBlueprint to minimize N+1s * Remove preview expiry constants These are defined in the Coordinator instead * Add method comments * Create parties_controller.rbs * Update logic and logs * Updates excluded methods and calculate_count * Use `includes` instead of `joins` * Use a less-insane way of counting * Adds a helper method for party privacy * Update filter condition helpers Just minor refactoring * Fix old view name in PartyBlueprint * Refactor parties#create * Remove redundant return * Update parties_controller.rbs * Update parties#index * Update parties_controller.rb Updates apply_includes and apply_excludes, along with modifying id_to_table and build_query * Update parties_controller.rb Adds the rest of the changes, too tired to write them all out. Some preview generation, some filtering * Refactor parties#index and parties#favorites These are mostly the same methods, so we remove common code into build_parties_query and render_paginated_parties * Alias table name to object to maintain API consistency * Maintain API consistency with raid blueprint * Optimize party loading by adding eager loading to `set_from_slug` - Refactored `set_from_slug` to use `includes` for eager loading associated models: - `user`, `job`, `raid` (with `group`) - `characters` (with `character` and `awakening`) - `weapons` (with `weapon`, `awakenings`, `weapon_key1`, `weapon_key2`, `weapon_key3`) - `summons` (with `summon`) - `guidebooks` (`guidebook1`, `guidebook2`, `guidebook3`) - `source_party`, `remixes`, `skills`, and `accessory` - This change improves query efficiency by reducing N+1 queries and ensures all relevant associations are preloaded. - Removed redundant favorite check as it was not necessary in this context. * Refactor grid blueprints - **GridCharacterBlueprint:** - Removed `:minimal` view restriction on `party` association. - Improved nil checks for `ring1`, `ring2`, and `earring` to prevent errors. - Converted string values in `awakening_level`, `over_mastery`, and `aetherial_mastery` fields to integers for consistency. - Ensured `over_mastery` and `aetherial_mastery` only include valid entries, filtering out blank or zero-modifier values. - **GridWeaponBlueprint:** - Removed `:minimal` view restriction on `party` association. - Ensured `weapon` association exists before accessing `ax`, `series`, or `awakening`. - Improved conditional checks for `weapon_keys` to prevent errors when `weapon` or `series` is nil. - Converted `awakening_level` field to integer for consistency. - **GridCharacterBlueprint:** - Removed `:minimal` view restriction on `party` association. * Update raid blueprints - Show flat representation of raid group in RaidBlueprint's nested view - Show nested representation of raid in RaidGroupBlueprint's full view * Move n+1 detection to around_action hook * Improve handling mastery bonuses - Improved handling of nested attributes: - Replaced old mastery structure with new `rings` and `awakening` assignments. - Added `new_rings` and `new_awakening` virtual attributes for easier updates. - Updated `assign_attributes` to exclude `rings` and `awakening` to prevent conflicts. - Enhanced parameter transformation: - Introduced `transform_character_params` to process `rings`, `awakening`, and `earring` more reliably. - Ensured proper type conversion (`to_i`) for numeric values in `uncap_level`, `transcendence_step`, and `awakening_level`. - Improved error handling for missing values by setting defaults where needed. - Optimized database queries: - Added `.includes(:awakening)` to `set` to prevent N+1 query issues. - Updated strong parameters: - Changed `rings` from individual keys (`ring1`, `ring2`, etc.) to a structured array format. - Refactored permitted attributes to align with the new nested structure. * Eager-load jobs when querying job skills * Eager load raids/groups when querying * Update users_controller.rb More efficient way of denoting favorited parties. * Update awakening.rb - Removes explicitly defined associations and adds ActiveRecord associations instead * Update party.rb - Removes favorited accessor - Renames derivative_parties to remixes and adds in-built sort * Update weapon_awakening.rb - Removes redefined explicit associations * Update grid_character.rb - Adds code transforming incoming ring and awakening values into something the db understands * Update character.rb Add explicit Awakenings enum * Update coordinator.rb Adds 'queued' as a state for generation --- .gitignore | 6 + Gemfile | 9 +- Gemfile.lock | 96 ++-- app/blueprints/api/v1/character_blueprint.rb | 101 ++-- .../api/v1/grid_character_blueprint.rb | 89 +-- .../api/v1/grid_summon_blueprint.rb | 17 +- .../api/v1/grid_weapon_blueprint.rb | 58 +- app/blueprints/api/v1/party_blueprint.rb | 168 +++--- app/blueprints/api/v1/raid_blueprint.rb | 2 + app/blueprints/api/v1/raid_group_blueprint.rb | 2 +- app/blueprints/api/v1/summon_blueprint.rb | 60 +- app/blueprints/api/v1/weapon_blueprint.rb | 55 +- app/controllers/api/v1/api_controller.rb | 14 +- .../api/v1/grid_characters_controller.rb | 84 ++- .../api/v1/job_skills_controller.rb | 6 +- app/controllers/api/v1/parties_controller.rb | 529 ++++++++++++++---- app/controllers/api/v1/raids_controller.rb | 4 +- app/controllers/api/v1/users_controller.rb | 26 +- app/models/awakening.rb | 9 +- app/models/character.rb | 7 + app/models/grid_character.rb | 33 +- app/models/grid_summon.rb | 7 +- app/models/grid_weapon.rb | 8 +- app/models/party.rb | 84 ++- app/models/weapon_awakening.rb | 8 - app/services/preview_service/coordinator.rb | 105 ++-- config/application.rb | 5 + config/environments/development.rb | 4 + config/initializers/active_record_logger.rb | 20 +- config/initializers/sidekiq.rb | 14 +- config/sidekiq.yml | 5 + ...15_set_preview_state_default_to_pending.rb | 7 + ...20224953_add_missing_indexes_to_parties.rb | 12 + ...154_add_missing_indexes_to_grid_objects.rb | 8 + ...0250201041406_create_pghero_query_stats.rb | 15 + .../20250201120655_enable_pg_statements.rb | 9 + .../20250201120842_remove_unused_index.rb | 5 + ...170037_add_optimized_indexes_to_parties.rb | 8 + db/schema.rb | 31 +- sig/api/v1/parties_controller.rbs | 111 ++++ 40 files changed, 1301 insertions(+), 540 deletions(-) create mode 100644 config/sidekiq.yml create mode 100644 db/migrate/20250120214715_set_preview_state_default_to_pending.rb create mode 100644 db/migrate/20250120224953_add_missing_indexes_to_parties.rb create mode 100644 db/migrate/20250201030154_add_missing_indexes_to_grid_objects.rb create mode 100644 db/migrate/20250201041406_create_pghero_query_stats.rb create mode 100644 db/migrate/20250201120655_enable_pg_statements.rb create mode 100644 db/migrate/20250201120842_remove_unused_index.rb create mode 100644 db/migrate/20250201170037_add_optimized_indexes_to_parties.rb create mode 100644 sig/api/v1/parties_controller.rbs diff --git a/.gitignore b/.gitignore index 7f0b053..d94a2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ # Ignore bundler config. /.bundle +# Ignore mystery Postgres folder +/postgres + # Ignore the default SQLite database. /db/*.sqlite3 /db/*.sqlite3-journal @@ -46,3 +49,6 @@ config/application.yml .vscode/* /config/credentials/production.key + +# Ignore AI Codebase-generated files +codebase.md diff --git a/Gemfile b/Gemfile index 42f9853..e49d1ec 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,6 @@ source 'https://rubygems.org' ruby '3.3.7' gem 'bootsnap' -gem 'pg' gem 'rack-cors' gem 'rails' gem 'sprockets-rails' @@ -10,6 +9,9 @@ gem 'sprockets-rails' # A Ruby Web Server Built For Concurrency gem 'puma' +# Pg is the Ruby interface to the PostgreSQL RDBMS +gem 'pg' + # A sophisticated and secure hash algorithm for # hashing passwords. gem 'bcrypt' @@ -71,6 +73,9 @@ gem 'httparty' # StringScanner provides for lexical scanning operations on a String. gem 'strscan' +# New Relic Ruby Agent +gem 'newrelic_rpm' + group :doc do gem 'apipie-rails' gem 'sdoc' @@ -88,6 +93,8 @@ end group :development do gem 'listen' + gem 'pg_query' + gem 'prosopite' gem 'solargraph' gem 'spring' gem 'spring-commands-rspec' diff --git a/Gemfile.lock b/Gemfile.lock index bac6941..5acdef0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,8 +86,8 @@ GEM awesome_nested_set (3.8.0) activerecord (>= 4.0.0, < 8.1) aws-eventstream (1.3.0) - aws-partitions (1.1038.0) - aws-sdk-core (3.216.0) + aws-partitions (1.1044.0) + aws-sdk-core (3.217.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -95,7 +95,7 @@ GEM aws-sdk-kms (1.97.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.178.0) + aws-sdk-s3 (1.179.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -135,14 +135,13 @@ GEM dotenv (= 3.1.7) railties (>= 6.1) drb (2.2.1) - e2mmap (0.1.0) email_validator (2.2.4) activemodel erubi (1.13.1) et-orbi (1.2.11) tzinfo - factory_bot (6.5.0) - activesupport (>= 5.0.0) + factory_bot (6.5.1) + activesupport (>= 6.1.0) factory_bot_rails (6.4.4) factory_bot (~> 6.5) railties (>= 5.0.0) @@ -166,14 +165,30 @@ GEM gemoji (>= 2.1.0) globalid (1.2.1) activesupport (>= 6.1) + google-protobuf (4.29.3) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-x86_64-linux) + bigdecimal + rake (>= 13) httparty (0.22.0) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.8.0) - irb (1.14.3) + irb (1.15.1) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jaro_winkler (1.6.0) @@ -183,7 +198,7 @@ GEM rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.4) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -213,46 +228,54 @@ GEM timeout net-smtp (0.5.0) net-protocol + newrelic_rpm (9.17.0) nio4r (2.7.4) - nokogiri (1.18.1-aarch64-linux-gnu) + nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-aarch64-linux-musl) + nokogiri (1.18.2-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.1-arm-linux-gnu) + nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-arm-linux-musl) + nokogiri (1.18.2-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.1-arm64-darwin) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-darwin) + nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-gnu) + nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.1-x86_64-linux-musl) + nokogiri (1.18.2-x86_64-linux-musl) racc (~> 1.4) + observer (0.1.2) oj (3.16.9) bigdecimal (>= 3.0) ostruct (>= 0.2) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc pg (1.5.9) + pg_query (6.0.0) + google-protobuf (>= 3.25.3) pg_search (2.3.7) activerecord (>= 6.1) activesupport (>= 6.1) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prosopite (1.4.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.2.2) + psych (5.2.3) date stringio - puma (6.5.0) + puma (6.6.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.8) + rack (3.1.9) rack-cors (2.0.2) rack (>= 2.0.0) rack-session (2.1.0) @@ -296,8 +319,9 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (2.8.4) - rdoc (6.10.0) + rbs (3.8.1) + logger + rdoc (6.11.0) psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -309,7 +333,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - reverse_markdown (2.1.1) + reverse_markdown (3.0.0) nokogiri rexml (3.4.0) rspec (3.13.0) @@ -335,17 +359,17 @@ GEM rspec-support (3.13.2) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.70.0) + rubocop (1.71.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) ruby-progressbar (1.13.0) rufus-scheduler (3.9.2) @@ -355,7 +379,8 @@ GEM securerandom (0.4.1) shoulda-matchers (6.4.0) activesupport (>= 5.2.0) - sidekiq (7.3.7) + sidekiq (7.3.8) + base64 connection_pool (>= 2.3.0) logger rack (>= 2.2.4) @@ -366,18 +391,20 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - solargraph (0.50.0) + solargraph (0.51.1) backport (~> 1.2) benchmark bundler (~> 2.0) diff-lcs (~> 1.4) - e2mmap - jaro_winkler (~> 1.5) + jaro_winkler (~> 1.6) kramdown (~> 2.3) kramdown-parser-gfm (~> 1.1) + logger (~> 1.6) + observer (~> 0.1) + ostruct (~> 0.6) parser (~> 3.0) - rbs (~> 2.0) - reverse_markdown (~> 2.0) + rbs (~> 3.0) + reverse_markdown (>= 2.0, < 4) rubocop (~> 1.38) thor (~> 1.0) tilt (~> 2.0) @@ -446,9 +473,12 @@ DEPENDENCIES httparty listen mini_magick + newrelic_rpm oj pg + pg_query pg_search + prosopite pry puma rack-cors diff --git a/app/blueprints/api/v1/character_blueprint.rb b/app/blueprints/api/v1/character_blueprint.rb index 1857ac1..6d8f30e 100644 --- a/app/blueprints/api/v1/character_blueprint.rb +++ b/app/blueprints/api/v1/character_blueprint.rb @@ -3,72 +3,77 @@ module Api module V1 class CharacterBlueprint < ApiBlueprint - field :name do |w| + field :name do |c| { - en: w.name_en, - ja: w.name_jp + en: c.name_en, + ja: c.name_jp } end fields :granblue_id, :character_id, :rarity, :element, :gender, :special - field :uncap do |w| + field :uncap do |c| { - flb: w.flb, - ulb: w.ulb + flb: c.flb, + ulb: c.ulb } end - field :hp do |w| - { - min_hp: w.min_hp, - max_hp: w.max_hp, - max_hp_flb: w.max_hp_flb - } + field :race do |c| + [c.race1, c.race2].compact end - field :atk do |w| - { - min_atk: w.min_atk, - max_atk: w.max_atk, - max_atk_flb: w.max_atk_flb - } + field :proficiency do |c| + [c.proficiency1, c.proficiency2].compact end - field :race do |w| - [ - w.race1, - w.race2 - ] - end + view :full do + include_view :stats + include_view :rates + include_view :dates - field :proficiency do |w| - [ - w.proficiency1, - w.proficiency2 - ] - end - - field :data do |w| - { - base_da: w.base_da, - base_ta: w.base_ta - } - end - - field :ougi_ratio do |w| - { - ougi_ratio: w.ougi_ratio, - ougi_ratio_flb: w.ougi_ratio_flb - } - end - - field :awakenings do - Awakening.where(object_type: 'Character').map do |a| - AwakeningBlueprint.render_as_hash(a) + field :awakenings do + Character::AWAKENINGS.map do |awakening| + AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening)) + end end end + + view :stats do + field :hp do |c| + { + min_hp: c.min_hp, + max_hp: c.max_hp, + max_hp_flb: c.max_hp_flb + } + end + + field :atk do |c| + { + min_atk: c.min_atk, + max_atk: c.max_atk, + max_atk_flb: c.max_atk_flb + } + end + end + + view :rates do + fields :base_da, :base_ta + + field :ougi_ratio do |c| + { + ougi_ratio: c.ougi_ratio, + ougi_ratio_flb: c.ougi_ratio_flb + } + end + end + + view :dates do + field :release_date + field :flb_date + field :ulb_date + end end end end diff --git a/app/blueprints/api/v1/grid_character_blueprint.rb b/app/blueprints/api/v1/grid_character_blueprint.rb index 4f9d656..766e6b4 100644 --- a/app/blueprints/api/v1/grid_character_blueprint.rb +++ b/app/blueprints/api/v1/grid_character_blueprint.rb @@ -3,57 +3,64 @@ module Api module V1 class GridCharacterBlueprint < ApiBlueprint - view :uncap do - association :party, blueprint: PartyBlueprint, view: :minimal - fields :position, :uncap_level + fields :position, :uncap_level, :perpetuity + + field :transcendence_step, if: ->(_field, gc, _options) { gc.character&.ulb } do |gc| + gc.transcendence_step end - view :nested do - fields :position, :uncap_level, :perpetuity - - field :transcendence_step, if: lambda { |_fn, obj, _opt| - obj.character.ulb - } do |c| - c.transcendence_step - end - - field :awakening do |c| - { - type: AwakeningBlueprint.render_as_hash(c.awakening), - level: c.awakening_level - } - end - - field :over_mastery, if: lambda { |_fn, obj, _opt| - !obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil? - } do |c| - rings = [] - - rings.push(c.ring1) unless c.ring1['modifier'].nil? - rings.push(c.ring2) unless c.ring2['modifier'].nil? - rings.push(c.ring3) unless c.ring3['modifier'].nil? - rings.push(c.ring4) unless c.ring4['modifier'].nil? - - rings - end - - field :aetherial_mastery, if: lambda { |_fn, obj, _opt| - !obj.earring['modifier'].nil? - } do |c| - c.earring - end - + view :preview do association :character, name: :object, blueprint: CharacterBlueprint end - view :full do - include_view :nested - association :party, blueprint: PartyBlueprint, view: :minimal + view :nested do + include_view :mastery_bonuses + association :character, name: :object, blueprint: CharacterBlueprint, view: :full + end + + view :uncap do + association :party, blueprint: PartyBlueprint + fields :position, :uncap_level end view :destroyed do fields :position, :created_at, :updated_at end + + view :mastery_bonuses do + field :awakening, if: ->(_field_name, gc, _options) { gc.association(:awakening).loaded? } do |gc| + { + type: AwakeningBlueprint.render_as_hash(gc.awakening), + level: gc.awakening_level.to_i + } + end + + field :over_mastery, if: lambda { |_fn, obj, _opt| + obj.ring1.present? && obj.ring2.present? && !obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil? + } do |c| + mapped_rings = [c.ring1, c.ring2, c.ring3, c.ring4].each_with_object([]) do |ring, arr| + # Skip if the ring is nil or its modifier is blank. + next if ring.blank? || ring['modifier'].blank? + + # Convert the string values to numbers. + mod = ring['modifier'].to_i + + # Only include if modifier is non-zero. + next if mod.zero? + + arr << { modifier: mod, strength: ring['strength'].to_i } + end + + mapped_rings + end + + field :aetherial_mastery, if: ->(_fn, obj, _opt) { obj.earring.present? && !obj.earring['modifier'].nil? } do |gc, _options| + { + modifier: gc.earring['modifier'].to_i, + strength: gc.earring['strength'].to_i + } + end + end end end end diff --git a/app/blueprints/api/v1/grid_summon_blueprint.rb b/app/blueprints/api/v1/grid_summon_blueprint.rb index f866602..a4c3df1 100644 --- a/app/blueprints/api/v1/grid_summon_blueprint.rb +++ b/app/blueprints/api/v1/grid_summon_blueprint.rb @@ -3,19 +3,24 @@ module Api module V1 class GridSummonBlueprint < ApiBlueprint - view :uncap do - association :party, blueprint: PartyBlueprint, view: :minimal - fields :position, :uncap_level, :transcendence_step + fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step + + view :preview do + association :summon, name: :object, blueprint: SummonBlueprint end view :nested do - fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step - association :summon, name: :object, blueprint: SummonBlueprint + association :summon, name: :object, blueprint: SummonBlueprint, view: :full end view :full do include_view :nested - association :party, blueprint: PartyBlueprint, view: :minimal + association :party, blueprint: PartyBlueprint + end + + view :uncap do + association :party, blueprint: PartyBlueprint + fields :position, :uncap_level, :transcendence_step end view :destroyed do diff --git a/app/blueprints/api/v1/grid_weapon_blueprint.rb b/app/blueprints/api/v1/grid_weapon_blueprint.rb index 67f1f55..1c82935 100644 --- a/app/blueprints/api/v1/grid_weapon_blueprint.rb +++ b/app/blueprints/api/v1/grid_weapon_blueprint.rb @@ -3,45 +3,47 @@ module Api module V1 class GridWeaponBlueprint < ApiBlueprint - view :uncap do - association :party, blueprint: PartyBlueprint, view: :minimal - fields :position, :uncap_level + fields :mainhand, :position, :uncap_level, :transcendence_step, :element + + view :preview do + association :weapon, name: :object, blueprint: WeaponBlueprint end view :nested do - fields :mainhand, :position, :uncap_level, :transcendence_step, :element - association :weapon, name: :object, blueprint: WeaponBlueprint + field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w| + [ + { modifier: w.ax_modifier1, strength: w.ax_strength1 }, + { modifier: w.ax_modifier2, strength: w.ax_strength2 } + ] + end + + field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w| + { + type: AwakeningBlueprint.render_as_hash(w.awakening), + level: w.awakening_level + } + end + + association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full, + if: ->(_field_name, w, _options) { w.weapon.present? } association :weapon_keys, blueprint: WeaponKeyBlueprint, - if: lambda { |_field_name, w, _options| - [2, 3, 17, 24, 34].include?(w.weapon.series) + if: ->(_field_name, w, _options) { + w.weapon.present? && + w.weapon.series.present? && + [2, 3, 17, 24, 34].include?(w.weapon.series) } - - field :ax, if: ->(_field_name, w, _options) { w.weapon.ax } do |w| - [ - { - modifier: w.ax_modifier1, - strength: w.ax_strength1 - }, - { - modifier: w.ax_modifier2, - strength: w.ax_strength2 - } - ] - end - end - - field :awakening, if: ->(_field_name, w, _options) { w.awakening_id } do |w| - { - type: AwakeningBlueprint.render_as_hash(w.awakening), - level: w.awakening_level - } end view :full do include_view :nested - association :party, blueprint: PartyBlueprint, view: :minimal + association :party, blueprint: PartyBlueprint + end + + view :uncap do + association :party, blueprint: PartyBlueprint + fields :position, :uncap_level end view :destroyed do diff --git a/app/blueprints/api/v1/party_blueprint.rb b/app/blueprints/api/v1/party_blueprint.rb index e6a77b5..845c231 100644 --- a/app/blueprints/api/v1/party_blueprint.rb +++ b/app/blueprints/api/v1/party_blueprint.rb @@ -3,105 +3,131 @@ module Api module V1 class PartyBlueprint < ApiBlueprint - view :weapons do + # Base fields that are always needed + fields :local_id, :description, :shortcode, :visibility, + :name, :element, :extra, :charge_attack, + :button_count, :turn_count, :chain_count, :clear_time, + :full_auto, :auto_guard, :auto_summon, + :created_at, :updated_at + + fields :local_id, :description, :charge_attack, + :button_count, :turn_count, :chain_count, + :master_level, :ultimate_mastery + + # Party associations + association :user, + blueprint: UserBlueprint, + view: :minimal + + association :job, + blueprint: JobBlueprint + + association :raid, + blueprint: RaidBlueprint, + view: :nested + + # Metadata associations + field :favorited do |party, options| + party.is_favorited(options[:current_user]) + end + + # For collection views + view :preview do + include_view :preview_objects # Characters, Weapons, Summons + include_view :preview_metadata # Object counts + end + + # For object views + view :full do + # Primary object associations + include_view :nested_objects # Characters, Weapons, Summons + include_view :nested_metadata # Remixes, Source party + include_view :job_metadata # Accessory, Skills, Guidebooks + end + + # Primary object associations + view :preview_objects do + association :characters, + blueprint: GridCharacterBlueprint, + view: :preview + + association :weapons, + blueprint: GridWeaponBlueprint, + view: :preview + + association :summons, + blueprint: GridSummonBlueprint, + view: :preview + end + + view :nested_objects do + association :characters, + blueprint: GridCharacterBlueprint, + view: :nested + association :weapons, blueprint: GridWeaponBlueprint, view: :nested - end - view :summons do association :summons, blueprint: GridSummonBlueprint, view: :nested end - view :characters do - association :characters, - blueprint: GridCharacterBlueprint, - view: :nested - end - - view :job_skills do - field :job_skills do |job| + # Metadata views + view :preview_metadata do + field :counts do |party| { - '0' => !job.skill0.nil? ? JobSkillBlueprint.render_as_hash(job.skill0) : nil, - '1' => !job.skill1.nil? ? JobSkillBlueprint.render_as_hash(job.skill1) : nil, - '2' => !job.skill2.nil? ? JobSkillBlueprint.render_as_hash(job.skill2) : nil, - '3' => !job.skill3.nil? ? JobSkillBlueprint.render_as_hash(job.skill3) : nil + weapons: party.weapons_count, + characters: party.characters_count, + summons: party.summons_count } end end - view :minimal do - fields :name, :element, :shortcode, :favorited, :remix, - :extra, :full_auto, :clear_time, :auto_guard, :auto_summon, - :visibility, :created_at, :updated_at - - field :guidebooks do |p| - { - '1' => !p.guidebook1.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook1) : nil, - '2' => !p.guidebook2.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook2) : nil, - '3' => !p.guidebook3.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook3) : nil - } - end - - association :raid, - blueprint: RaidBlueprint, - view: :full - - association :job, - blueprint: JobBlueprint - - association :user, - blueprint: UserBlueprint, - view: :minimal - end - - view :jobs do - association :job, - blueprint: JobBlueprint - include_view :job_skills - end - - view :preview do - include_view :minimal - include_view :characters - include_view :weapons - include_view :summons - end - - view :full do - include_view :preview - include_view :summons - include_view :characters - include_view :job_skills - - fields :local_id, :description, :charge_attack, - :button_count, :turn_count, :chain_count, - :master_level, :ultimate_mastery - - association :accessory, - blueprint: JobAccessoryBlueprint - + view :nested_metadata do association :source_party, blueprint: PartyBlueprint, - view: :minimal + view: :minimal, + if: ->(_field_name, party, _options) { party.source_party_id.present? } - # TODO: This should probably be paginated + # Re-added remixes association association :remixes, blueprint: PartyBlueprint, - view: :collection + view: :preview end - view :collection do - include_view :preview + # Job-related views + view :job_metadata do + field :job_skills, cache: true do |party| + { + '0' => party.skill0 ? JobSkillBlueprint.render_as_hash(party.skill0) : nil, + '1' => party.skill1 ? JobSkillBlueprint.render_as_hash(party.skill1) : nil, + '2' => party.skill2 ? JobSkillBlueprint.render_as_hash(party.skill2) : nil, + '3' => party.skill3 ? JobSkillBlueprint.render_as_hash(party.skill3) : nil + } + end + + field :guidebooks, cache: true do |party| + { + '1' => party.guidebook1 ? GuidebookBlueprint.render_as_hash(party.guidebook1) : nil, + '2' => party.guidebook2 ? GuidebookBlueprint.render_as_hash(party.guidebook2) : nil, + '3' => party.guidebook3 ? GuidebookBlueprint.render_as_hash(party.guidebook3) : nil + } + end + + association :accessory, + blueprint: JobAccessoryBlueprint, + if: ->(_field_name, party, _options) { party.accessory_id.present? } end + # Created view view :created do include_view :full fields :edit_key end + # Destroyed view view :destroyed do fields :name, :description, :created_at, :updated_at end diff --git a/app/blueprints/api/v1/raid_blueprint.rb b/app/blueprints/api/v1/raid_blueprint.rb index be26e32..af688d1 100644 --- a/app/blueprints/api/v1/raid_blueprint.rb +++ b/app/blueprints/api/v1/raid_blueprint.rb @@ -12,6 +12,8 @@ module Api end fields :slug, :level, :element + + association :group, blueprint: RaidGroupBlueprint, view: :flat end view :full do diff --git a/app/blueprints/api/v1/raid_group_blueprint.rb b/app/blueprints/api/v1/raid_group_blueprint.rb index c65a0fa..66f1320 100644 --- a/app/blueprints/api/v1/raid_group_blueprint.rb +++ b/app/blueprints/api/v1/raid_group_blueprint.rb @@ -16,7 +16,7 @@ module Api view :full do include_view :flat - association :raids, blueprint: RaidBlueprint, view: :full + association :raids, blueprint: RaidBlueprint, view: :nested end end end diff --git a/app/blueprints/api/v1/summon_blueprint.rb b/app/blueprints/api/v1/summon_blueprint.rb index 1b8dfa7..6b7c824 100644 --- a/app/blueprints/api/v1/summon_blueprint.rb +++ b/app/blueprints/api/v1/summon_blueprint.rb @@ -3,41 +3,55 @@ module Api module V1 class SummonBlueprint < ApiBlueprint - field :name do |w| + field :name do |s| { - en: w.name_en, - ja: w.name_jp + en: s.name_en, + ja: s.name_jp } end fields :granblue_id, :element, :rarity, :max_level - field :uncap do |w| + field :uncap do |s| { - flb: w.flb, - ulb: w.ulb, - transcendence: w.transcendence + flb: s.flb, + ulb: s.ulb, + transcendence: s.transcendence } end - field :hp do |w| - { - min_hp: w.min_hp, - max_hp: w.max_hp, - max_hp_flb: w.max_hp_flb, - max_hp_ulb: w.max_hp_ulb, - max_hp_xlb: w.max_hp_xlb - } + view :stats do + field :hp do |s| + { + min_hp: s.min_hp, + max_hp: s.max_hp, + max_hp_flb: s.max_hp_flb, + max_hp_ulb: s.max_hp_ulb, + max_hp_xlb: s.max_hp_xlb + } + end + + field :atk do |s| + { + min_atk: s.min_atk, + max_atk: s.max_atk, + max_atk_flb: s.max_atk_flb, + max_atk_ulb: s.max_atk_ulb, + max_atk_xlb: s.max_atk_xlb + } + end end - field :atk do |w| - { - min_atk: w.min_atk, - max_atk: w.max_atk, - max_atk_flb: w.max_atk_flb, - max_atk_ulb: w.max_atk_ulb, - max_atk_xlb: w.max_atk_xlb - } + view :dates do + field :release_date + field :flb_date + field :ulb_date + field :transcendence_date + end + + view :full do + include_view :stats + include_view :dates end end end diff --git a/app/blueprints/api/v1/weapon_blueprint.rb b/app/blueprints/api/v1/weapon_blueprint.rb index 70fd536..7075680 100644 --- a/app/blueprints/api/v1/weapon_blueprint.rb +++ b/app/blueprints/api/v1/weapon_blueprint.rb @@ -10,10 +10,12 @@ module Api } end + # Primary information fields :granblue_id, :element, :proficiency, :max_level, :max_skill_level, :max_awakening_level, :limit, :rarity, :series, :ax, :ax_type + # Uncap information field :uncap do |w| { flb: w.flb, @@ -22,28 +24,39 @@ module Api } end - field :hp do |w| - { - min_hp: w.min_hp, - max_hp: w.max_hp, - max_hp_flb: w.max_hp_flb, - max_hp_ulb: w.max_hp_ulb - } - end - - field :atk do |w| - { - min_atk: w.min_atk, - max_atk: w.max_atk, - max_atk_flb: w.max_atk_flb, - max_atk_ulb: w.max_atk_ulb - } - end - - field :awakenings, if: ->(_fn, obj, _opt) { obj.awakenings.length.positive? } do |w| - w.awakenings.map do |a| - AwakeningBlueprint.render_as_hash(a) + view :stats do + field :hp do |w| + { + min_hp: w.min_hp, + max_hp: w.max_hp, + max_hp_flb: w.max_hp_flb, + max_hp_ulb: w.max_hp_ulb + } end + + field :atk do |w| + { + min_atk: w.min_atk, + max_atk: w.max_atk, + max_atk_flb: w.max_atk_flb, + max_atk_ulb: w.max_atk_ulb + } + end + end + + view :dates do + field :release_date + field :flb_date + field :ulb_date + field :transcendence_date + end + + view :full do + include_view :stats + include_view :dates + association :awakenings, + blueprint: AwakeningBlueprint, + if: ->(_field_name, weapon, _options) { weapon.awakenings.any? } end end end diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index ef730da..887eeaa 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -31,6 +31,7 @@ module Api ##### Hooks before_action :current_user before_action :default_content_type + around_action :n_plus_one_detection, unless: -> { Rails.env.production? } ##### Responders respond_to :json @@ -104,9 +105,9 @@ module Api def render_not_found_response(object) render json: ErrorBlueprint.render(nil, error: { - message: "#{object.capitalize} could not be found", - code: 'not_found' - }), status: :not_found + message: "#{object.capitalize} could not be found", + code: 'not_found' + }), status: :not_found end def render_unauthorized_response @@ -119,6 +120,13 @@ module Api def restrict_access raise UnauthorizedError unless current_user end + + def n_plus_one_detection + Prosopite.scan + yield + ensure + Prosopite.finish + end end end end diff --git a/app/controllers/api/v1/grid_characters_controller.rb b/app/controllers/api/v1/grid_characters_controller.rb index 8c10cdc..6741719 100644 --- a/app/controllers/api/v1/grid_characters_controller.rb +++ b/app/controllers/api/v1/grid_characters_controller.rb @@ -39,17 +39,22 @@ module Api end def update - mastery = {} - %i[ring1 ring2 ring3 ring4 earring awakening].each do |key| - value = character_params.to_h[key] - mastery[key] = value unless value.nil? + permitted = character_params.to_h.deep_symbolize_keys + puts "Permitted:" + ap permitted + + # For the new nested structure, assign them to the virtual attributes: + @character.new_rings = permitted[:rings] if permitted[:rings].present? + @character.new_awakening = permitted[:awakening] if permitted[:awakening].present? + + # For the rest of the attributes, you can assign them normally. + @character.assign_attributes(permitted.except(:rings, :awakening)) + + if @character.save + render json: GridCharacterBlueprint.render(@character, view: :nested) + else + render_validation_error_response(@character) end - - @character.attributes = character_params.merge(mastery) - - return render json: GridCharacterBlueprint.render(@character, view: :full) if @character.save - - render_validation_error_response(@character) end def resolve @@ -123,7 +128,7 @@ module Api end def set - @character = GridCharacter.find(params[:id]) + @character = GridCharacter.includes(:awakening).find(params[:id]) end def find_incoming_character @@ -143,14 +148,59 @@ module Api render_unauthorized_response if unauthorized_create || unauthorized_update end + def transform_character_params(raw_params) + # Convert to a symbolized hash for convenience. + raw = raw_params.deep_symbolize_keys + + # Only update keys that were provided. + transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity) + transformed[:uncap_level] = raw[:uncap_level].to_i if raw[:uncap_level].present? + transformed[:transcendence_step] = raw[:transcendence_step].to_i if raw[:transcendence_step].present? + + # Process rings if provided. + transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present? + + # Process earring if provided. + transformed[:earring] = raw[:earring] if raw[:earring].present? + + # Process awakening if provided. + if raw[:awakening].present? + transformed[:awakening_id] = raw[:awakening][:id] + # Default to 1 if level is missing (to satisfy validations) + transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level].to_i : 1 + end + + transformed + end + + def transform_rings(rings) + default_ring = { modifier: nil, strength: nil } + # Ensure rings is an array of hashes. + rings_array = Array(rings).map(&:to_h) + # Pad the array to exactly four rings if needed. + rings_array.fill(default_ring, rings_array.size...4) + { + ring1: rings_array[0], + ring2: rings_array[1], + ring3: rings_array[2], + ring4: rings_array[3] + } + end + # Specify whitelisted properties that can be modified. def character_params - params.require(:character).permit(:id, :party_id, :character_id, :position, - :uncap_level, :transcendence_step, :perpetuity, - :awakening_id, :awakening_level, - ring1: %i[modifier strength], ring2: %i[modifier strength], - ring3: %i[modifier strength], ring4: %i[modifier strength], - earring: %i[modifier strength]) + params.require(:character).permit( + :id, + :party_id, + :character_id, + :position, + :uncap_level, + :transcendence_step, + :perpetuity, + awakening: %i[id level], + rings: %i[modifier strength], + earring: %i[modifier strength] + ) end def resolve_params diff --git a/app/controllers/api/v1/job_skills_controller.rb b/app/controllers/api/v1/job_skills_controller.rb index 8b466a1..f98ddf8 100644 --- a/app/controllers/api/v1/job_skills_controller.rb +++ b/app/controllers/api/v1/job_skills_controller.rb @@ -4,11 +4,13 @@ module Api module V1 class JobSkillsController < Api::V1::ApiController def all - render json: JobSkillBlueprint.render(JobSkill.all) + render json: JobSkillBlueprint.render(JobSkill.includes(:job).all) end def job - @skills = JobSkill.where('job_id != ? AND emp = ?', params[:id], true) + @skills = JobSkill.includes(:job) + .where.not(job_id: params[:id]) + .where(emp: true) render json: JobSkillBlueprint.render(@skills) end end diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index 0951a10..4221116 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -2,40 +2,65 @@ module Api module V1 + # Controller for managing party-related operations in the API + # @api public class PartiesController < Api::V1::ApiController before_action :set_from_slug, except: %w[create destroy update index favorites] before_action :set, only: %w[update destroy] before_action :authorize, only: %w[update destroy] + # == Constants + + # Maximum number of characters allowed in a party MAX_CHARACTERS = 5 + + # Maximum number of summons allowed in a party MAX_SUMMONS = 8 + + # Maximum number of weapons allowed in a party MAX_WEAPONS = 13 + # Default minimum number of characters required for filtering DEFAULT_MIN_CHARACTERS = 3 + + # Default minimum number of summons required for filtering DEFAULT_MIN_SUMMONS = 2 + + # Default minimum number of weapons required for filtering DEFAULT_MIN_WEAPONS = 5 + # Default maximum clear time in seconds DEFAULT_MAX_CLEAR_TIME = 5400 + # == Primary CRUD Actions + + # Creates a new party with optional user association + # @return [void] def create - party = Party.new + # Build the party with the provided parameters and assign the user + party = Party.new(party_params) party.user = current_user if current_user - party.attributes = party_params if party_params - if party_params && party_params[:raid_id] - raid = Raid.find_by(id: party_params[:raid_id]) - party.extra = raid.group.extra + # If a raid_id is given, look it up and assign the extra flag from its group. + if party_params && party_params[:raid_id].present? + if (raid = Raid.find_by(id: party_params[:raid_id])) + party.extra = raid.group.extra + end end - if party.save! - return render json: PartyBlueprint.render(party, view: :created, root: :party), - status: :created + # Save and render the party, triggering preview generation if the party is ready + if party.save + party.schedule_preview_generation if party.ready_for_preview? + render json: PartyBlueprint.render(party, view: :created, root: :party), + status: :created + else + render_validation_error_response(party) end - - render_validation_error_response(@party) end + # Shows a specific party if the user has permission to view it + # @return [void] def show # If a party is private, check that the user is the owner or an admin if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode) @@ -47,6 +72,8 @@ module Api render_not_found_response('project') end + # Updates an existing party's attributes + # @return [void] def update @party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id) @@ -62,10 +89,16 @@ module Api render_validation_error_response(@party) end + # Deletes a party if the user has permission + # @return [void] def destroy - return render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy + render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy end + # == Extended Party Actions + + # Creates a copy of an existing party with attribution + # @return [void] def remix new_party = @party.amoeba_dup new_party.attributes = { @@ -78,6 +111,8 @@ module Api new_party.local_id = party_params[:local_id] unless party_params.nil? if new_party.save + # Remixed parties should have content, so generate preview + new_party.schedule_preview_generation render json: PartyBlueprint.render(new_party, view: :created, root: :party), status: :created else @@ -85,37 +120,30 @@ module Api end end + # Lists parties based on various filter criteria + # @return [void] def index - conditions = build_filters - - query = build_query(conditions) - query = apply_includes(query, params[:includes]) if params[:includes].present? - query = apply_excludes(query, params[:excludes]) if params[:excludes].present? - - @parties = fetch_parties(query) - count = calculate_count(query) - total_pages = calculate_total_pages(count) - - render_party_json(@parties, count, total_pages) + query = build_parties_query + @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) + render_paginated_parties(@parties) end + # Lists parties favorited by the current user + # @return [void] def favorites raise Api::V1::UnauthorizedError unless current_user - conditions = build_filters - conditions[:favorites] = { user_id: current_user.id } - - query = build_query(conditions, favorites: true) - query = apply_includes(query, params[:includes]) if params[:includes].present? - query = apply_excludes(query, params[:excludes]) if params[:excludes].present? - - @parties = fetch_parties(query) - count = calculate_count(query) - total_pages = calculate_total_pages(count) - - render_party_json(@parties, count, total_pages) + query = build_parties_query(favorites: true) + @parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) + # Mark each party as favorited (if needed) + @parties.each { |party| party.favorited = true } + render_paginated_parties(@parties) end + # == Preview Management + + # Serves the party's preview image + # @return [void] def preview coordinator = PreviewService::Coordinator.new(@party) @@ -154,6 +182,19 @@ module Api end end + # Returns the current status of a party's preview + # @return [void] + def preview_status + party = Party.find_by!(shortcode: params[:id]) + render json: { + state: party.preview_state, + generated_at: party.preview_generated_at, + ready_for_preview: party.ready_for_preview? + } + end + + # Forces regeneration of a party's preview image + # @return [void] def regenerate_preview party = Party.find_by!(shortcode: params[:id]) @@ -173,12 +214,67 @@ module Api private + # Builds the base query for parties, optionally including favorites-specific conditions. + def build_parties_query(favorites: false) + query = Party.includes( + { raid: :group }, + :job, + :user, + :skill0, + :skill1, + :skill2, + :skill3, + :guidebook1, + :guidebook2, + :guidebook3, + { characters: :character }, + { weapons: :weapon }, + { summons: :summon } + ) + # Add favorites join and condition if favorites is true. + if favorites + query = query.joins(:favorites) + .where(favorites: { user_id: current_user.id }) + .distinct + query = query.order(created_at: :desc) + else + query = query.order(visibility: :asc, created_at: :desc) + end + + query = apply_filters(query) + query = apply_privacy_settings(query) + query = apply_includes(query, params[:includes]) if params[:includes].present? + query = apply_excludes(query, params[:excludes]) if params[:excludes].present? + query + end + + # Renders the paginated parties with blueprint and meta data. + def render_paginated_parties(parties) + render json: PartyBlueprint.render( + parties, + view: :preview, + root: :results, + meta: { + count: parties.total_entries, + total_pages: parties.total_pages, + per_page: COLLECTION_PER_PAGE + }, + current_user: current_user + ) + end + + # == Authorization Helpers + + # Checks if the current user is authorized to modify the party + # @return [void] def authorize return unless not_owner && !admin_mode render_unauthorized_response end + # Determines if the current user is not the owner of the party + # @return [Boolean] def not_owner if @party.user # party has a user and current_user does not match @@ -197,54 +293,139 @@ module Api false end - def build_filters - params = request.params + # == Preview Generation - start_time = build_start_time(params['recency']) - - min_characters_count = build_count(params['characters_count'], DEFAULT_MIN_CHARACTERS) - min_summons_count = build_count(params['summons_count'], DEFAULT_MIN_SUMMONS) - min_weapons_count = build_count(params['weapons_count'], DEFAULT_MIN_WEAPONS) - max_clear_time = build_max_clear_time(params['max_clear_time']) - - { - element: build_element(params['element']), - raid: params['raid'], - created_at: params['recency'].present? ? start_time..DateTime.current : nil, - full_auto: build_option(params['full_auto']), - auto_guard: build_option(params['auto_guard']), - charge_attack: build_option(params['charge_attack']), - characters_count: min_characters_count..MAX_CHARACTERS, - summons_count: min_summons_count..MAX_SUMMONS, - weapons_count: min_weapons_count..MAX_WEAPONS - }.delete_if { |_k, v| v.nil? } + # Schedules a background job to generate the party preview + # @return [void] + def schedule_preview_generation + GeneratePartyPreviewJob.perform_later(id) end + # == Query Building Helpers + + def apply_filters(query) + conditions = build_filters + + # Use the compound indexes effectively + query = query.where(conditions) + .where(name_quality) if params[:name_quality].present? + + # Use the counters index + query = query.where( + weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS, + characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS, + summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS + ) + + query + end + + def apply_privacy_settings(query) + return query if admin_mode + + if params[:favorites].present? + query.where('visibility < 3') + else + query.where(visibility: 1) + end + end + + # Builds filter conditions from request parameters + # @return [Hash] conditions for the query + def build_filters + { + element: params[:element].present? ? params[:element].to_i : nil, + raid_id: params[:raid], + created_at: build_date_range, + full_auto: build_option(params[:full_auto]), + auto_guard: build_option(params[:auto_guard]), + charge_attack: build_option(params[:charge_attack]), + characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS, + summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS, + weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS + }.compact + end + + def build_date_range + return nil unless params[:recency].present? + + start_time = DateTime.current - params[:recency].to_i.seconds + start_time.beginning_of_day..DateTime.current + end + + # Paginates the given query of parties and marks favorites for the current user + # + # @param query [ActiveRecord::Relation] The base query containing parties + # @param page [Integer, nil] The page number for pagination (defaults to `params[:page]`) + # @param per_page [Integer] The number of records per page (defaults to `COLLECTION_PER_PAGE`) + # @return [ActiveRecord::Relation] The paginated and processed list of parties + # + # This method orders parties by creation date in descending order, applies pagination, + # and marks each party as favorited if the current user has favorited it. + def paginate_parties(query, page: nil, per_page: COLLECTION_PER_PAGE) + query.order(created_at: :desc) + .paginate(page: page || params[:page], per_page: per_page) + .tap do |parties| + if current_user + parties.each { |party| party.favorited = party.is_favorited(current_user) } + end + end + end + + # == Parameter Processing Helpers + + # Converts start time parameter for filtering + # @param recency [String, nil] time period in seconds + # @return [DateTime, nil] calculated start time def build_start_time(recency) return unless recency.present? (DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day end + # Builds count parameter with default fallback + # @param value [String, nil] count value + # @param default [Integer] default value + # @return [Integer] processed count def build_count(value, default) value.blank? ? default : value.to_i end + # Processes maximum clear time parameter + # @param value [String, nil] clear time value in seconds + # @return [Integer] processed maximum clear time def build_max_clear_time(value) value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i end + # Processes element parameter + # @param element [String, nil] element identifier + # @return [Integer, nil] processed element value def build_element(element) element.to_i unless element.blank? end + # Processes boolean option parameters + # @param value [String, nil] option value + # @return [Integer, nil] processed option value def build_option(value) value.to_i unless value.blank? || value.to_i == -1 end + # == Query Building Helpers + + # Constructs the main query for party filtering + # @param conditions [Hash] filter conditions + # @param favorites [Boolean] whether to include favorites + # @return [ActiveRecord::Relation] constructed query def build_query(conditions, favorites: false) query = Party.distinct - .joins(weapons: [:object], summons: [:object], characters: [:object]) + # joins vs includes? -> reduces n+1s + .preload( + weapons: { object: %i[name_en name_jp granblue_id element] }, + summons: { object: %i[name_en name_jp granblue_id element] }, + characters: { object: %i[name_en name_jp granblue_id element] } + ) .group('parties.id') .where(conditions) .where(privacy(favorites: favorites)) @@ -252,104 +433,158 @@ module Api .where(user_quality) .where(original) - query = query.joins(:favorites) if favorites + query = query.includes(:favorites) if favorites query end - def includes(id) - "(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')" - end - - def excludes(id) - "(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')" - end - + # Applies the include conditions to query + # @param query [ActiveRecord::Relation] base query + # @param includes [String] comma-separated list of IDs to include + # @return [ActiveRecord::Relation] modified query def apply_includes(query, includes) - included = includes.split(',') - includes_condition = included.map { |id| includes(id) }.join(' AND ') - query.where(includes_condition) + return query unless includes.present? + + includes.split(',').each do |id| + grid_table, object_table = grid_table_and_object_table(id) + next unless grid_table && object_table + + # Build a subquery that joins the grid table to the object table. + condition = <<-SQL.squish + EXISTS ( + SELECT 1 + FROM #{grid_table} + JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id + WHERE #{object_table}.granblue_id = ? + AND #{grid_table}.party_id = parties.id + ) + SQL + + query = query.where(condition, id) + end + + query end - def apply_excludes(query, _excludes) - characters_subquery = excluded_characters.select(1).arel - summons_subquery = excluded_summons.select(1).arel - weapons_subquery = excluded_weapons.select(1).arel + # Applies the exclude conditions to query + # @param query [ActiveRecord::Relation] base query + # @return [ActiveRecord::Relation] modified query + def apply_excludes(query, excludes) + return query unless excludes.present? - query.where(characters_subquery.exists.not) - .where(weapons_subquery.exists.not) - .where(summons_subquery.exists.not) + excludes.split(',').each do |id| + grid_table, object_table = grid_table_and_object_table(id) + next unless grid_table && object_table + + condition = <<-SQL.squish + NOT EXISTS ( + SELECT 1 + FROM #{grid_table} + JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id + WHERE #{object_table}.granblue_id = ? + AND #{grid_table}.party_id = parties.id + ) + SQL + + query = query.where(condition, id) + end + + query end + # == Query Filtering Helpers + + # Generates subquery for excluded characters + # @return [ActiveRecord::Relation, nil] exclusion query def excluded_characters return unless params[:excludes] excluded = params[:excludes].split(',').filter { |id| id[0] == '3' } - GridCharacter.joins(:object) + GridCharacter.includes(:object) .where(characters: { granblue_id: excluded }) .where('grid_characters.party_id = parties.id') end + # Generates subquery for excluded summons + # @return [ActiveRecord::Relation, nil] exclusion query def excluded_summons return unless params[:excludes] excluded = params[:excludes].split(',').filter { |id| id[0] == '2' } - GridSummon.joins(:object) + GridSummon.includes(:object) .where(summons: { granblue_id: excluded }) .where('grid_summons.party_id = parties.id') end + # Generates subquery for excluded weapons + # @return [ActiveRecord::Relation, nil] exclusion query def excluded_weapons return unless params[:excludes] excluded = params[:excludes].split(',').filter { |id| id[0] == '1' } - GridWeapon.joins(:object) + GridWeapon.includes(:object) .where(weapons: { granblue_id: excluded }) .where('grid_weapons.party_id = parties.id') end + # == Query Processing + + # Fetches and processes parties query with pagination + # @param query [ActiveRecord::Relation] base query + # @return [ActiveRecord::Relation] processed and paginated parties def fetch_parties(query) query.order(created_at: :desc) - .paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE) + .paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) .each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false } end + # Calculates total count for pagination + # @param query [ActiveRecord::Relation] current query + # @return [Integer] total count def calculate_count(query) - query.count.values.sum + # query.count.values.sum + query.count end + # Calculates total pages for pagination + # @param count [Integer] total record count + # @return [Integer] total pages def calculate_total_pages(count) - count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1 + # count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1 + (count.to_f / COLLECTION_PER_PAGE).ceil end - def render_party_json(parties, count, total_pages) - render json: PartyBlueprint.render(parties, - view: :collection, - root: :results, - meta: { - count: count, - total_pages: total_pages, - per_page: COLLECTION_PER_PAGE - }) + # == Include/Exclude Processing + + # Generates SQL for including specific items + # @param id [String] item identifier + # @return [String] SQL condition + def includes(id) + "(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')" end - def privacy(favorites: false) - return if admin_mode - - if favorites - 'visibility < 3' - else - 'visibility = 1' - end + # Generates SQL for excluding specific items + # @param id [String] item identifier + # @return [String] SQL condition + def excludes(id) + "(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')" end + # == Filter Condition Helpers + + # Generates user quality condition + # @return [String, nil] SQL condition for user quality def user_quality - return if request.params[:user_quality].blank? || request.params[:user_quality] == 'false' + return if params[:user_quality].blank? || params[:user_quality] == 'false' 'user_id IS NOT NULL' end + # Generates name quality condition + # @return [String, nil] SQL condition for name quality def name_quality + return if params[:name_quality].blank? || params[:name_quality] == 'false' + low_quality = [ 'Untitled', 'Remix of Untitled', @@ -364,33 +599,54 @@ module Api '無題のリミックスのリミックスのリミックスのリミックス', '無題のリミックスのリミックスのリミックスのリミックスのリミックス' ] - joined_names = low_quality.map { |name| "'#{name}'" }.join(',') - - return if request.params[:name_quality].blank? || request.params[:name_quality] == 'false' - "name NOT IN (#{joined_names})" end + # Generates original party condition + # @return [String, nil] SQL condition for original parties def original - return if request.params['original'].blank? || request.params['original'] == 'false' + return if params['original'].blank? || params['original'] == 'false' 'source_party_id IS NULL' end - def id_to_table(id) - case id[0] - when '3' - table = 'characters' - when '2' - table = 'summons' - when '1' - table = 'weapons' - end + # == Filter Condition Helpers - table + # Generates privacy condition based on favorites + # @param favorites [Boolean] whether viewing favorites + # @return [String, nil] SQL condition + def privacy(favorites: false) + return if admin_mode + + if favorites + 'visibility < 3' + else + 'visibility = 1' + end end + # == Utility Methods + + # Maps ID prefixes to table names + # @param id [String] item identifier + # @return [Array(String, String)] corresponding table name + def grid_table_and_object_table(id) + case id[0] + when '3' + %w[grid_characters characters] + when '2' + %w[grid_summons summons] + when '1' + %w[grid_weapons weapons] + else + [nil, nil] + end + end + + # Generates name for remixed party + # @param name [String] original party name + # @return [String] generated remix name def remixed_name(name) blanked_name = { en: name.blank? ? 'Untitled team' : name, @@ -409,19 +665,54 @@ module Api end end + # == Party Loading + + # Loads party by shortcode for routes using :id + # @return [void] def set_from_slug - @party = Party.where('shortcode = ?', params[:id]).first - if @party - @party.favorited = current_user && @party ? @party.is_favorited(current_user) : false - else - render_not_found_response('party') - end + @party = Party.includes( + :user, + :job, + { raid: :group }, + { characters: [:character, :awakening] }, + { + weapons: { + # Eager load the associated weapon and its awakenings. + weapon: [:awakenings], + # Eager load the grid weapon’s own awakening (if applicable). + awakening: {}, + # Eager load any weapon key associations. + weapon_key1: {}, + weapon_key2: {}, + weapon_key3: {} + } + }, + { summons: :summon }, + :guidebook1, + :guidebook2, + :guidebook3, + :source_party, + :remixes, + :skill0, + :skill1, + :skill2, + :skill3, + :accessory + ).find_by(shortcode: params[:id]) + + render_not_found_response('party') unless @party end + # Loads party by ID for update/destroy actions + # @return [void] def set @party = Party.where('id = ?', params[:id]).first end + # == Parameter Sanitization + + # Sanitizes and permits party parameters + # @return [Hash, nil] permitted parameters def party_params return unless params[:party].present? diff --git a/app/controllers/api/v1/raids_controller.rb b/app/controllers/api/v1/raids_controller.rb index c403288..c08326d 100644 --- a/app/controllers/api/v1/raids_controller.rb +++ b/app/controllers/api/v1/raids_controller.rb @@ -4,7 +4,7 @@ module Api module V1 class RaidsController < Api::V1::ApiController def all - render json: RaidBlueprint.render(Raid.all, view: :full) + render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested) end def show @@ -13,7 +13,7 @@ module Api end def groups - render json: RaidGroupBlueprint.render(RaidGroup.all, view: :full) + render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full) end end end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index ed923bb..6e6cec2 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -58,17 +58,19 @@ module Api conditions = build_conditions conditions[:user_id] = @user.id - parties = Party - .where(conditions) - .where(name_quality) - .where(user_quality) - .where(original) - .where(privacy) - .order(created_at: :desc) - .paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE) - .each do |party| - party.favorited = current_user ? party.is_favorited(current_user) : false - end + favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited" + parties = Party.where(conditions) + .where(name_quality) + .where(user_quality) + .where(original) + .where(privacy) + .includes(:favorites) + .select(Party.arel_table[Arel.star]) + .select( + Arel.sql(favorites_query) + ) + .order(created_at: :desc) + .paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE) count = Party.where(conditions).count @@ -101,7 +103,7 @@ module Api unless params['recency'].blank? start_time = (DateTime.current - params['recency'].to_i.seconds) - .to_datetime.beginning_of_day + .to_datetime.beginning_of_day end min_characters_count = params['characters_count'].blank? ? DEFAULT_MIN_CHARACTERS : params['characters_count'].to_i diff --git a/app/models/awakening.rb b/app/models/awakening.rb index 9970535..0273c67 100644 --- a/app/models/awakening.rb +++ b/app/models/awakening.rb @@ -1,13 +1,8 @@ # frozen_string_literal: true class Awakening < ApplicationRecord - def weapon_awakenings - WeaponAwakening.where(awakening_id: id) - end - - def weapons - weapon_awakenings.map(&:weapon) - end + has_many :weapon_awakenings, foreign_key: :awakening_id + has_many :weapons, through: :weapon_awakenings def awakening AwakeningBlueprint diff --git a/app/models/character.rb b/app/models/character.rb index c2000a8..cd74ab1 100644 --- a/app/models/character.rb +++ b/app/models/character.rb @@ -34,6 +34,13 @@ class Character < ApplicationRecord } } + AWAKENINGS = [ + { slug: 'character-balanced', name_en: 'Balanced', name_jp: 'バランス', order: 0 }, + { slug: 'character-atk', name_en: 'Attack', name_jp: '攻撃', order: 1 }, + { slug: 'character-def', name_en: 'Defense', name_jp: '防御', order: 2 }, + { slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 } + ].freeze + def blueprint CharacterBlueprint end diff --git a/app/models/grid_character.rb b/app/models/grid_character.rb index 6fc5783..36e93e1 100644 --- a/app/models/grid_character.rb +++ b/app/models/grid_character.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class GridCharacter < ApplicationRecord - has_one :object, class_name: 'Character', foreign_key: :id, primary_key: :character_id + belongs_to :character, foreign_key: :character_id, primary_key: :id belongs_to :awakening, optional: true belongs_to :party, @@ -15,6 +15,12 @@ class GridCharacter < ApplicationRecord validate :validate_aetherial_mastery_value, on: :update validate :over_mastery_attack_matches_hp, on: :update + # Virtual attribute for the new rings structure + attr_accessor :new_rings + + # Virtual attribute for the new awakening structure + attr_accessor :new_awakening + ##### Amoeba configuration amoeba do set ring1: { modifier: nil, strength: nil } @@ -25,6 +31,9 @@ class GridCharacter < ApplicationRecord set perpetuity: false end + before_validation :apply_new_rings, if: -> { new_rings.present? } + before_validation :apply_new_awakening, if: -> { new_awakening.present? } + # Add awakening before the model saves before_save :add_awakening @@ -78,10 +87,6 @@ class GridCharacter < ApplicationRecord 'aetherial_mastery') end - def character - Character.find(character_id) - end - def blueprint GridCharacterBlueprint end @@ -94,6 +99,24 @@ class GridCharacter < ApplicationRecord self.awakening = Awakening.where(slug: 'character-balanced').sole end + def apply_new_rings + # Expect new_rings to be an array of hashes, e.g., + # [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}] + default_ring = { "modifier" => nil, "strength" => nil } + rings_array = Array(new_rings).map(&:to_h) + # Pad with defaults so there are exactly four rings + rings_array.fill(default_ring, rings_array.size...4) + self.ring1 = rings_array[0] + self.ring2 = rings_array[1] + self.ring3 = rings_array[2] + self.ring4 = rings_array[3] + end + + def apply_new_awakening + self.awakening_id = new_awakening[:id] + self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1 + end + def check_value(property, type) # Input format # { ring1: { atk: 300 } } diff --git a/app/models/grid_summon.rb b/app/models/grid_summon.rb index f2a1afc..5b1ed59 100644 --- a/app/models/grid_summon.rb +++ b/app/models/grid_summon.rb @@ -1,19 +1,16 @@ # frozen_string_literal: true class GridSummon < ApplicationRecord + belongs_to :summon, foreign_key: :summon_id, primary_key: :id + belongs_to :party, counter_cache: :summons_count, inverse_of: :summons validates_presence_of :party - has_one :object, class_name: 'Summon', foreign_key: :id, primary_key: :summon_id validate :compatible_with_position, on: :create validate :no_conflicts, on: :create - def summon - Summon.find(summon_id) - end - def blueprint GridSummonBlueprint end diff --git a/app/models/grid_weapon.rb b/app/models/grid_weapon.rb index afbb8ea..615ed97 100644 --- a/app/models/grid_weapon.rb +++ b/app/models/grid_weapon.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true class GridWeapon < ApplicationRecord + belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id + belongs_to :party, counter_cache: :weapons_count, inverse_of: :weapons validates_presence_of :party - has_one :object, class_name: 'Weapon', foreign_key: :id, primary_key: :weapon_id - belongs_to :weapon_key1, class_name: 'WeaponKey', foreign_key: :weapon_key1_id, optional: true belongs_to :weapon_key2, class_name: 'WeaponKey', foreign_key: :weapon_key2_id, optional: true belongs_to :weapon_key3, class_name: 'WeaponKey', foreign_key: :weapon_key3_id, optional: true @@ -33,10 +33,6 @@ class GridWeapon < ApplicationRecord GridWeaponBlueprint end - def weapon - Weapon.find(weapon_id) - end - def weapon_keys [weapon_key1, weapon_key2, weapon_key3].compact end diff --git a/app/models/party.rb b/app/models/party.rb index f1bebc2..19f7dc1 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -7,9 +7,9 @@ class Party < ApplicationRecord foreign_key: :source_party_id, optional: true - has_many :derivative_parties, + has_many :remixes, -> { order(created_at: :desc) }, class_name: 'Party', - foreign_key: :source_party_id, + foreign_key: 'source_party_id', inverse_of: :source_party, dependent: :nullify @@ -60,18 +60,21 @@ class Party < ApplicationRecord has_many :characters, foreign_key: 'party_id', class_name: 'GridCharacter', + counter_cache: true, dependent: :destroy, inverse_of: :party has_many :weapons, foreign_key: 'party_id', class_name: 'GridWeapon', + counter_cache: true, dependent: :destroy, inverse_of: :party has_many :summons, foreign_key: 'party_id', class_name: 'GridSummon', + counter_cache: true, dependent: :destroy, inverse_of: :party @@ -103,8 +106,6 @@ class Party < ApplicationRecord validate :skills_are_unique validate :guidebooks_are_unique - attr_accessor :favorited - self.enum :preview_state, { pending: 0, queued: 1, @@ -113,11 +114,7 @@ class Party < ApplicationRecord failed: 4 } - after_commit :schedule_preview_regeneration, if: :preview_relevant_changes? - - def is_favorited(user) - user.favorite_parties.include? self if user - end + after_commit :schedule_preview_generation, if: :should_generate_preview? def is_remix !source_party.nil? @@ -143,6 +140,58 @@ class Party < ApplicationRecord visibility == 3 end + def is_favorited(user) + return false unless user + + Rails.cache.fetch("party_#{id}_favorited_by_#{user.id}", expires_in: 1.hour) do + user.favorite_parties.include?(self) + end + end + + def ready_for_preview? + return false if weapons_count < 1 # At least 1 weapon + return false if characters_count < 1 # At least 1 character + return false if summons_count < 1 # At least 1 summon + true + end + + def should_generate_preview? + return false unless ready_for_preview? + + # Always generate if no preview exists + return true if preview_state.nil? || preview_state == 'pending' + + # Generate if failed and enough time has passed for conditions to change + return true if preview_state == 'failed' && preview_generated_at < 5.minutes.ago + + # Generate if preview is old + return true if preview_state == 'generated' && preview_expired? + + # Only regenerate on content changes if the last generation was > 5 minutes ago + # This prevents rapid regeneration during party building + if preview_content_changed? + return true if preview_generated_at.nil? || preview_generated_at < 5.minutes.ago + end + + false + end + + def preview_expired? + preview_generated_at.nil? || + preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago + end + + def preview_content_changed? + saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) } + end + + def schedule_preview_generation + return if preview_state == 'queued' || preview_state == 'in_progress' + + update_column(:preview_state, 'queued') + GeneratePartyPreviewJob.perform_later(id) + end + private def set_shortcode @@ -188,17 +237,10 @@ class Party < ApplicationRecord errors.add(:guidebooks, 'must be unique') end - def preview_relevant_changes? - return false if preview_state == 'queued' - - (saved_changes.keys & %w[name job_id element weapons_count characters_count summons_count]).any? - end - - def schedule_preview_regeneration - # Cancel any pending jobs - GeneratePartyPreviewJob.cancel_scheduled_jobs(party_id: id) - - # Mark as pending - update_column(:preview_state, :pending) + def preview_relevant_attributes + %w[ + name job_id element weapons_count characters_count summons_count + full_auto auto_guard charge_attack clear_time + ] end end diff --git a/app/models/weapon_awakening.rb b/app/models/weapon_awakening.rb index a1a88f6..bc06602 100644 --- a/app/models/weapon_awakening.rb +++ b/app/models/weapon_awakening.rb @@ -3,12 +3,4 @@ class WeaponAwakening < ApplicationRecord belongs_to :weapon belongs_to :awakening - - def weapon - Weapon.find(weapon_id) - end - - def awakening - Awakening.find(awakening_id) - end end diff --git a/app/services/preview_service/coordinator.rb b/app/services/preview_service/coordinator.rb index 15e308a..748cbd8 100644 --- a/app/services/preview_service/coordinator.rb +++ b/app/services/preview_service/coordinator.rb @@ -8,6 +8,9 @@ module PreviewService GENERATION_TIMEOUT = 5.minutes LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews') + PREVIEW_DEBOUNCE_PERIOD = 5.minutes + PREVIEW_EXPIRY = 30.days + # Public Interface - Core Operations # Initialize the party preview service @@ -40,59 +43,59 @@ module PreviewService return false unless should_generate? begin - Rails.logger.info("Starting preview generation for party #{@party.id}") + Rails.logger.info("🖼️ Starting preview generation for party #{@party.id}") - # Update state to in_progress + Rails.logger.info("🖼️ Updating party state to in_progress") @party.update!(preview_state: :in_progress) set_generation_in_progress - Rails.logger.info("Checking ImageMagick installation...") + Rails.logger.info("🖼️ Checking ImageMagick installation...") begin version = `convert -version` - Rails.logger.info("ImageMagick version: #{version}") + Rails.logger.info("🖼️ ImageMagick version: #{version}") rescue => e - Rails.logger.error("Failed to get ImageMagick version: #{e.message}") + Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}") end - Rails.logger.info("Creating preview image...") + Rails.logger.info("🖼️ Creating preview image...") begin image = create_preview_image - Rails.logger.info("Preview image created successfully") + Rails.logger.info("🖼️ Preview image created successfully") rescue => e - Rails.logger.error("Failed to create preview image: #{e.class} - #{e.message}") + Rails.logger.error("🖼️ Failed to create preview image: #{e.class} - #{e.message}") Rails.logger.error(e.backtrace.join("\n")) raise e end - Rails.logger.info("Saving preview...") + Rails.logger.info("🖼️ Saving preview...") begin save_preview(image) - Rails.logger.info("Preview saved successfully") + Rails.logger.info("🖼️ Preview saved successfully") rescue => e - Rails.logger.error("Failed to save preview: #{e.class} - #{e.message}") + Rails.logger.error("🖼️ Failed to save preview: #{e.class} - #{e.message}") Rails.logger.error(e.backtrace.join("\n")) raise e end - Rails.logger.info("Updating party state...") + Rails.logger.info("🖼️ Updating party state...") @party.update!( preview_state: :generated, preview_generated_at: Time.current ) - Rails.logger.info("Party state updated successfully") + Rails.logger.info("🖼️ Party state updated successfully") true rescue => e - Rails.logger.error("Preview generation failed: #{e.class} - #{e.message}") - Rails.logger.error("Stack trace:") + Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}") + Rails.logger.error("🖼️ Stack trace:") Rails.logger.error(e.backtrace.join("\n")) handle_preview_generation_error(e) false ensure - Rails.logger.info("Cleaning up resources...") + Rails.logger.info("🖼️ Cleaning up resources...") @image_fetcher.cleanup clear_generation_in_progress - Rails.logger.info("Cleanup completed") + Rails.logger.info("🖼️ Cleanup completed") end end @@ -128,32 +131,48 @@ module PreviewService # # @return [Boolean] True if a new preview should be generated, false otherwise def should_generate? - Rails.logger.info("Checking should_generate? conditions") + Rails.logger.info("🖼️ Checking should_generate? conditions") - if generation_in_progress? - Rails.logger.info("Generation already in progress, returning false") + unless @party.ready_for_preview? + Rails.logger.info("🖼️ Party not ready for preview (insufficient content)") return false end - Rails.logger.info("Preview state: #{@party.preview_state}") - # Add 'queued' to the list of valid states for generation - if @party.preview_state.in?(['pending', 'failed', 'queued']) - Rails.logger.info("Preview state is #{@party.preview_state}, returning true") - return true + if generation_in_progress? + Rails.logger.info("🖼️ Generation already in progress, returning false") + return false end - if @party.preview_state == 'generated' - if @party.preview_generated_at < PREVIEW_EXPIRY.ago - Rails.logger.info("Preview is older than expiry time, returning true") - return true - else - Rails.logger.info("Preview is recent, returning false") - return false - end - end + Rails.logger.info("🖼️ Preview state: #{@party.preview_state}") - Rails.logger.info("No conditions met, returning false") - false + case @party.preview_state + when 'pending', 'queued' + Rails.logger.info("🖼️ State is #{@party.preview_state}, will generate") + true + when 'in_progress' + Rails.logger.info("🖼️ State is in_progress, skipping generation") + false + when 'failed' + should_retry = @party.preview_generated_at.nil? || + @party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago + Rails.logger.info("🖼️ Failed state, should retry: #{should_retry}") + should_retry + when 'generated' + expired = @party.preview_expired? + changed = @party.preview_content_changed? + debounced = @party.preview_generated_at.nil? || + @party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago + + should_regenerate = expired || (changed && debounced) + + Rails.logger.info("🖼️ Generated state check - expired: #{expired}, content changed: #{changed}, debounced: #{debounced}") + Rails.logger.info("🖼️ Should regenerate: #{should_regenerate}") + + should_regenerate + else + Rails.logger.info("🖼️ Unknown state, will generate") + true + end end # Checks if a preview generation is currently in progress @@ -480,6 +499,12 @@ module PreviewService ) end + def self.cleanup_stalled_jobs + Party.where(preview_state: :in_progress) + .where('updated_at < ?', 10.minutes.ago) + .update_all(preview_state: :pending) + end + # Deletes local preview files # # @return [void] @@ -498,8 +523,12 @@ module PreviewService def handle_preview_generation_error(error) Rails.logger.error("Preview generation failed for party #{@party.id}") Rails.logger.error("Error: #{error.class} - #{error.message}") - Rails.logger.error("Backtrace:\n#{error.backtrace.join("\n")}") - @party.update!(preview_state: :failed) + Rails.logger.error(error.backtrace.join("\n")) + + @party.update_columns( + preview_state: 'failed', + preview_generated_at: Time.current + ) end end end diff --git a/config/application.rb b/config/application.rb index 6d35568..5c0557b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,6 +29,11 @@ module HenseiApi config.paths["app/assets"].unshift(Rails.root.join("app", "assets").to_s) config.assets.paths << Rails.root.join("app", "assets", "fonts") + # Enable query logging + config.active_record.query_log_tags_enabled = true + config.active_record.query_log_tags = [:application, :controller, :action, :job] + config.active_record.cache_query_log_tags = true + # API-only application configuration config.api_only = true end diff --git a/config/environments/development.rb b/config/environments/development.rb index 31fbd86..3d94ebd 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -76,4 +76,8 @@ Rails.application.configure do # This makes it easy to tag log lines with debug information like subdomain and request id - # both very helpful in debugging multi-user production applications. config.log_tags = [:request_id] + + config.after_initialize do + Prosopite.rails_logger = true + end end diff --git a/config/initializers/active_record_logger.rb b/config/initializers/active_record_logger.rb index 2c30eda..145395a 100644 --- a/config/initializers/active_record_logger.rb +++ b/config/initializers/active_record_logger.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -class CacheFreeLogger < ActiveSupport::Logger - def add(severity, message = nil, progname = nil, &block) - return true if progname&.include? 'CACHE' - - super - end -end - -ActiveRecord::Base.logger = CacheFreeLogger.new($stdout) -ActiveRecord::Base.logger.level = 1 +# class CacheFreeLogger < ActiveSupport::Logger +# def add(severity, message = nil, progname = nil, &block) +# return true if progname&.include? 'CACHE' +# +# super +# end +# end +# +ActiveRecord::Base.logger = Logger.new(STDOUT) +# ActiveRecord::Base.logger.level = 1 diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 48408f7..7782932 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,14 +1,12 @@ -# Fetch environment variables with defaults if not set -redis_url = ENV.fetch('REDIS_URL', 'redis://localhost') -redis_port = ENV.fetch('REDISPORT', '6379') - -# Combine URL and port (adjust the path/DB as needed) -full_redis_url = "#{redis_url}/0" +redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') Sidekiq.configure_server do |config| - config.redis = { url: full_redis_url } + config.redis = { url: redis_url } + config.death_handlers << ->(job, ex) do + Rails.logger.error("Preview generation job #{job['jid']} failed with: #{ex.message}") + end end Sidekiq.configure_client do |config| - config.redis = { url: full_redis_url } + config.redis = { url: redis_url } end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..8fc1d9e --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,5 @@ +:scheduler: + cleanup_party_previews: + cron: '0 0 * * *' # Daily at midnight + class: CleanupPartyPreviewsJob + queue: maintenance diff --git a/db/migrate/20250120214715_set_preview_state_default_to_pending.rb b/db/migrate/20250120214715_set_preview_state_default_to_pending.rb new file mode 100644 index 0000000..5a83222 --- /dev/null +++ b/db/migrate/20250120214715_set_preview_state_default_to_pending.rb @@ -0,0 +1,7 @@ +class SetPreviewStateDefaultToPending < ActiveRecord::Migration[8.0] + def up + Party.where(preview_state: nil).find_each do |party| + party.update_column(:preview_state, :pending) + end + end +end diff --git a/db/migrate/20250120224953_add_missing_indexes_to_parties.rb b/db/migrate/20250120224953_add_missing_indexes_to_parties.rb new file mode 100644 index 0000000..25b2c4a --- /dev/null +++ b/db/migrate/20250120224953_add_missing_indexes_to_parties.rb @@ -0,0 +1,12 @@ +class AddMissingIndexesToParties < ActiveRecord::Migration[8.0] + def change + add_index :parties, :visibility + add_index :parties, :element + add_index :parties, :created_at + add_index :parties, [:weapons_count, :characters_count, :summons_count], + name: 'index_parties_on_counters' + add_index :parties, [:visibility, :created_at], + name: 'index_parties_on_visibility_created_at' + add_index :parties, :shortcode + end +end diff --git a/db/migrate/20250201030154_add_missing_indexes_to_grid_objects.rb b/db/migrate/20250201030154_add_missing_indexes_to_grid_objects.rb new file mode 100644 index 0000000..a79b854 --- /dev/null +++ b/db/migrate/20250201030154_add_missing_indexes_to_grid_objects.rb @@ -0,0 +1,8 @@ +class AddMissingIndexesToGridObjects < ActiveRecord::Migration[8.0] + def change + add_index :parties, :raid_id unless index_exists?(:parties, :raid_id) + add_index :characters, :granblue_id unless index_exists?(:characters, :granblue_id) + add_index :summons, :granblue_id unless index_exists?(:summons, :granblue_id) + add_index :weapons, :granblue_id unless index_exists?(:weapons, :granblue_id) + end +end diff --git a/db/migrate/20250201041406_create_pghero_query_stats.rb b/db/migrate/20250201041406_create_pghero_query_stats.rb new file mode 100644 index 0000000..74aaaa9 --- /dev/null +++ b/db/migrate/20250201041406_create_pghero_query_stats.rb @@ -0,0 +1,15 @@ +class CreatePgheroQueryStats < ActiveRecord::Migration[8.0] + def change + create_table :pghero_query_stats do |t| + t.text :database + t.text :user + t.text :query + t.integer :query_hash, limit: 8 + t.float :total_time + t.integer :calls, limit: 8 + t.timestamp :captured_at + end + + add_index :pghero_query_stats, [:database, :captured_at] + end +end diff --git a/db/migrate/20250201120655_enable_pg_statements.rb b/db/migrate/20250201120655_enable_pg_statements.rb new file mode 100644 index 0000000..d8e5894 --- /dev/null +++ b/db/migrate/20250201120655_enable_pg_statements.rb @@ -0,0 +1,9 @@ +class EnablePgStatements < ActiveRecord::Migration[8.0] + def up + execute 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements;' + end + + def down + execute 'DROP EXTENSION IF EXISTS pg_stat_statements;' + end +end diff --git a/db/migrate/20250201120842_remove_unused_index.rb b/db/migrate/20250201120842_remove_unused_index.rb new file mode 100644 index 0000000..54889a3 --- /dev/null +++ b/db/migrate/20250201120842_remove_unused_index.rb @@ -0,0 +1,5 @@ +class RemoveUnusedIndex < ActiveRecord::Migration[8.0] + def change + remove_index :parties, :visibility + end +end diff --git a/db/migrate/20250201170037_add_optimized_indexes_to_parties.rb b/db/migrate/20250201170037_add_optimized_indexes_to_parties.rb new file mode 100644 index 0000000..e03d989 --- /dev/null +++ b/db/migrate/20250201170037_add_optimized_indexes_to_parties.rb @@ -0,0 +1,8 @@ +class AddOptimizedIndexesToParties < ActiveRecord::Migration[8.0] + def change + # Add composite index for grid positions since we order by these + add_index :grid_weapons, [:party_id, :position], name: 'index_grid_weapons_on_party_id_and_position' + add_index :grid_characters, [:party_id, :position], name: 'index_grid_characters_on_party_id_and_position' + add_index :grid_summons, [:party_id, :position], name: 'index_grid_summons_on_party_id_and_position' + end +end diff --git a/db/schema.rb b/db/schema.rb index b07f427..e5dedd9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,13 +10,13 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do +ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" + enable_extension "pg_stat_statements" enable_extension "pg_trgm" enable_extension "pgcrypto" - enable_extension "uuid-ossp" create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t| t.string "update_type", null: false @@ -67,6 +67,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.string "kamigame", default: "" t.string "nicknames_en", default: [], null: false, array: true t.string "nicknames_jp", default: [], null: false, array: true + t.index ["granblue_id"], name: "index_characters_on_granblue_id" t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin end @@ -129,6 +130,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.integer "awakening_level", default: 1 t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id" t.index ["character_id"], name: "index_grid_characters_on_character_id" + t.index ["party_id", "position"], name: "index_grid_characters_on_party_id_and_position" t.index ["party_id"], name: "index_grid_characters_on_party_id" end @@ -143,6 +145,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.datetime "updated_at", null: false t.integer "transcendence_step", default: 0, null: false t.boolean "quick_summon", default: false + t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position" t.index ["party_id"], name: "index_grid_summons_on_party_id" t.index ["summon_id"], name: "index_grid_summons_on_summon_id" end @@ -168,6 +171,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.integer "transcendence_step", default: 0 t.string "weapon_key4_id" t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id" + t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position" t.index ["party_id"], name: "index_grid_weapons_on_party_id" t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id" t.index ["weapon_key1_id"], name: "index_grid_weapons_on_weapon_key1_id" @@ -304,18 +308,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.datetime "preview_generated_at" t.string "preview_s3_key" t.index ["accessory_id"], name: "index_parties_on_accessory_id" + t.index ["created_at"], name: "index_parties_on_created_at" + t.index ["element"], name: "index_parties_on_element" t.index ["guidebook1_id"], name: "index_parties_on_guidebook1_id" t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id" t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id" t.index ["job_id"], name: "index_parties_on_job_id" t.index ["preview_generated_at"], name: "index_parties_on_preview_generated_at" t.index ["preview_state"], name: "index_parties_on_preview_state" + t.index ["raid_id"], name: "index_parties_on_raid_id" + t.index ["shortcode"], name: "index_parties_on_shortcode" t.index ["skill0_id"], name: "index_parties_on_skill0_id" t.index ["skill1_id"], name: "index_parties_on_skill1_id" t.index ["skill2_id"], name: "index_parties_on_skill2_id" t.index ["skill3_id"], name: "index_parties_on_skill3_id" t.index ["source_party_id"], name: "index_parties_on_source_party_id" t.index ["user_id"], name: "index_parties_on_user_id" + t.index ["visibility", "created_at"], name: "index_parties_on_visibility_created_at" + t.index ["weapons_count", "characters_count", "summons_count"], name: "index_parties_on_counters" end create_table "pg_search_documents", force: :cascade do |t| @@ -331,6 +341,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable" end + create_table "pghero_query_stats", force: :cascade do |t| + t.text "database" + t.text "user" + t.text "query" + t.bigint "query_hash" + t.float "total_time" + t.bigint "calls" + t.datetime "captured_at", precision: nil + t.index ["database", "captured_at"], name: "index_pghero_query_stats_on_database_and_captured_at" + end + create_table "raid_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name_en", null: false t.string "name_jp", null: false @@ -349,7 +370,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.integer "element" t.string "slug" t.uuid "group_id" - t.index ["group_id"], name: "index_raids_on_group_id" end create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -400,6 +420,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.date "transcendence_date" t.string "nicknames_en", default: [], null: false, array: true t.string "nicknames_jp", default: [], null: false, array: true + t.index ["granblue_id"], name: "index_summons_on_granblue_id" t.index ["name_en"], name: "index_summons_on_name_en", opclass: :gin_trgm_ops, using: :gin end @@ -474,6 +495,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do t.boolean "transcendence", default: false t.date "transcendence_date" t.string "recruits" + t.index ["granblue_id"], name: "index_weapons_on_granblue_id" t.index ["name_en"], name: "index_weapons_on_name_en", opclass: :gin_trgm_ops, using: :gin t.index ["recruits"], name: "index_weapons_on_recruits" end @@ -501,9 +523,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do add_foreign_key "parties", "job_skills", column: "skill2_id" add_foreign_key "parties", "job_skills", column: "skill3_id" add_foreign_key "parties", "jobs" + add_foreign_key "parties", "parties", column: "source_party_id" add_foreign_key "parties", "raids" add_foreign_key "parties", "users" - add_foreign_key "raids", "raid_groups", column: "group_id" + add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey" add_foreign_key "weapon_awakenings", "awakenings" add_foreign_key "weapon_awakenings", "weapons" end diff --git a/sig/api/v1/parties_controller.rbs b/sig/api/v1/parties_controller.rbs new file mode 100644 index 0000000..7f8c4ef --- /dev/null +++ b/sig/api/v1/parties_controller.rbs @@ -0,0 +1,111 @@ +module Api + module V1 + class PartiesController < Api::V1::ApiController + @parties: ActiveRecord::Relation[Party] + + MAX_CHARACTERS: Integer + MAX_SUMMONS: Integer + MAX_WEAPONS: Integer + DEFAULT_MIN_CHARACTERS: Integer + DEFAULT_MIN_SUMMONS: Integer + DEFAULT_MIN_WEAPONS: Integer + DEFAULT_MAX_CLEAR_TIME: Integer + + def create: () -> void + + def show: () -> void + + def update: () -> void + + def destroy: () -> void + + def remix: () -> void + + def index: () -> void + + def favorites: () -> void + + def preview: () -> void + + def preview_status: () -> void + + def regenerate_preview: () -> void + + private + + def authorize: () -> void + + def grid_table_and_object_table: (String) -> [String?, String?] + + def not_owner: () -> bool + + def schedule_preview_generation: () -> void + + def apply_filters: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party] + + def apply_privacy_settings: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party] + + def apply_includes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party] + + def apply_excludes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party] + + def build_filters: () -> Hash[Symbol, untyped] + + def build_date_range: () -> Range[DateTime]? + + def build_start_time: (String?) -> DateTime? + + def build_count: (String?, Integer) -> Integer + + def build_max_clear_time: (String?) -> Integer + + def build_element: (String?) -> Integer? + + def build_option: (String?) -> Integer? + + def build_query: (Hash[Symbol, untyped], bool) -> ActiveRecord::Relation[Party] + + def fetch_parties: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party] + + def calculate_count: (ActiveRecord::Relation[Party]) -> Integer + + def calculate_total_pages: (Integer) -> Integer + + def paginate_parties: ( + ActiveRecord::Relation[Party], + ?page: Integer?, + ?per_page: Integer + ) -> ActiveRecord::Relation[Party] + + def excluded_characters: () -> ActiveRecord::Relation[GridCharacter]? + + def excluded_summons: () -> ActiveRecord::Relation[GridSummon]? + + def excluded_weapons: () -> ActiveRecord::Relation[GridWeapon]? + + def render_party_json: (ActiveRecord::Relation[Party]) -> void + + def privacy: (?favorites: bool) -> String? + + def user_quality: () -> String? + + def name_quality: () -> String? + + def original: () -> String? + + def includes: (String) -> String + + def excludes: (String) -> String + + def id_to_table: (String) -> String + + def remixed_name: (String) -> String + + def set_from_slug: () -> void + + def set: () -> void + + def party_params: () -> Hash[Symbol, untyped]? + end + end +end