Compare commits

..

No commits in common. "feature/party-sharing" and "main" have entirely different histories.

357 changed files with 462 additions and 38605 deletions

4
.env
View file

@ -1,5 +1 @@
RAILS_LOG_TO_STDOUT=true
OPENAI_API_KEY="not-needed-for-local"
OPENAI_BASE_URL="http://192.168.1.246:8000/v1"
OPENAI_MODEL="cpatonn/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit"

1
.gitignore vendored
View file

@ -59,4 +59,3 @@ config/application.yml
# Ignore AI Codebase-generated files
codebase.md
mise.toml
public/assets

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactBlueprint < ApiBlueprint
field :name do |a|
{
en: a.name_en,
ja: a.name_jp
}
end
fields :granblue_id, :rarity
# Return proficiency as integer (nil for quirk artifacts)
field :proficiency do |a|
a.proficiency_before_type_cast
end
field :release_date, if: ->(_field, a, _options) { a.release_date.present? }
end
end
end

View file

@ -1,38 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactSkillBlueprint < ApiBlueprint
field :name do |s|
{
en: s.name_en,
ja: s.name_jp
}
end
field :game_name do |s|
{
en: s.game_name_en,
ja: s.game_name_jp
}
end
fields :skill_group, :modifier, :polarity
field :base_values do |s|
s.base_values
end
field :growth, if: ->(_field, s, _options) { s.growth.present? } do |s|
s.growth.to_f
end
field :suffix do |s|
{
en: s.suffix_en,
ja: s.suffix_jp
}
end
end
end
end

View file

@ -11,34 +11,7 @@ module Api
end
fields :granblue_id, :character_id, :rarity,
:element, :gender, :special, :season
field :season_name do |c|
c.season_name
end
field :series do |c|
# Use new lookup table if available
if c.character_series_records.any?
c.character_series_records.ordered.map do |cs|
{
id: cs.id,
slug: cs.slug,
name: {
en: cs.name_en,
ja: cs.name_jp
}
}
end
else
# Legacy fallback - return integer array
c.series
end
end
field :series_names do |c|
c.series_names
end
:element, :gender, :special
field :uncap do |c|
{
@ -65,60 +38,6 @@ module Api
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
end
end
field :nicknames do |c|
{
en: c.nicknames_en,
ja: c.nicknames_jp
}
end
field :wiki do |c|
{
en: c.wiki_en,
ja: c.wiki_ja
}
end
fields :gamewith, :kamigame
end
# Separate view for recruitment info - only include when needed (e.g., character detail page)
view :with_recruitment do
include_view :full
field :recruited_by do |c|
weapon = Weapon.find_by(recruits: c.granblue_id)
next nil unless weapon
{
id: weapon.id,
granblue_id: weapon.granblue_id,
name: {
en: weapon.name_en,
ja: weapon.name_jp
},
promotions: weapon.promotions,
promotion_names: weapon.promotion_names
}
end
end
# Separate view for raw data - only used by dedicated endpoint
view :raw do
excludes :name, :granblue_id, :character_id, :rarity, :element, :gender, :special, :uncap, :race, :proficiency
field :wiki_raw do |c|
c.wiki_raw
end
field :game_raw_en do |c|
c.game_raw_en
end
field :game_raw_jp do |c|
c.game_raw_jp
end
end
view :stats do

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CharacterSeriesBlueprint < ApiBlueprint
field :name do |cs|
{
en: cs.name_en,
ja: cs.name_jp
}
end
fields :slug, :order
view :full do
field :character_count do |cs|
cs.characters.count
end
end
end
end
end

View file

@ -1,64 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CollectionArtifactBlueprint < ApiBlueprint
identifier :id
fields :level, :nickname, :reroll_slot, :created_at, :updated_at
# Return element as integer
field :element do |obj|
obj.element_before_type_cast
end
# Proficiency is only present on quirk artifacts, return as integer
field :proficiency, if: ->(_field, obj, _options) { obj.proficiency.present? } do |obj|
obj.proficiency_before_type_cast
end
field :skills do |obj|
[
[obj.skill1, 1],
[obj.skill2, 2],
[obj.skill3, 3],
[obj.skill4, 4]
].map do |skill, slot|
next nil if skill.blank? || skill == {}
# Determine skill group based on slot
group = case slot
when 1, 2 then 1 # Group I
when 3 then 2 # Group II
when 4 then 3 # Group III
end
# Look up skill and compute strength from quality
modifier = skill['modifier']
quality = skill['quality'] || 1
level = skill['level'] || 1
artifact_skill = ArtifactSkill.find_skill(group, modifier)
strength = artifact_skill&.strength_for_quality(quality)
{
modifier: modifier,
strength: strength,
level: level
}
end
end
# Include grade and recommendation by default
field :grade do |obj|
ArtifactGrader.new(obj).grade
end
association :artifact, blueprint: ArtifactBlueprint
view :full do
association :artifact, blueprint: ArtifactBlueprint
end
end
end
end

View file

@ -1,26 +0,0 @@
module Api
module V1
class CollectionCharacterBlueprint < ApiBlueprint
identifier :id
fields :uncap_level, :transcendence_step, :perpetuity,
:ring1, :ring2, :ring3, :ring4, :earring,
:created_at, :updated_at
field :awakening do |obj|
if obj.awakening.present?
{
type: AwakeningBlueprint.render_as_hash(obj.awakening),
level: obj.awakening_level
}
end
end
association :character, blueprint: CharacterBlueprint
view :full do
association :character, blueprint: CharacterBlueprint, view: :full
end
end
end
end

View file

@ -1,11 +0,0 @@
module Api
module V1
class CollectionJobAccessoryBlueprint < ApiBlueprint
identifier :id
fields :created_at, :updated_at
association :job_accessory, blueprint: JobAccessoryBlueprint
end
end
end

View file

@ -1,16 +0,0 @@
module Api
module V1
class CollectionSummonBlueprint < ApiBlueprint
identifier :id
fields :uncap_level, :transcendence_step,
:created_at, :updated_at
association :summon, blueprint: SummonBlueprint
view :full do
association :summon, blueprint: SummonBlueprint, view: :full
end
end
end
end

View file

@ -1,50 +0,0 @@
module Api
module V1
class CollectionWeaponBlueprint < ApiBlueprint
identifier :id
fields :uncap_level, :transcendence_step, :element,
:created_at, :updated_at
field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj|
skills = []
if obj.ax_modifier1.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier1),
strength: obj.ax_strength1
}
end
if obj.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier2),
strength: obj.ax_strength2
}
end
skills
end
field :befoulment, if: ->(_, obj, _) { obj.befoulment_modifier.present? } do |obj|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.befoulment_modifier),
strength: obj.befoulment_strength,
exorcism_level: obj.exorcism_level
}
end
field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj|
{
type: AwakeningBlueprint.render_as_hash(obj.awakening),
level: obj.awakening_level
}
end
association :weapon, blueprint: WeaponBlueprint
association :weapon_keys, blueprint: WeaponKeyBlueprint,
if: ->(_, obj, _) { obj.weapon_keys.any? }
view :full do
association :weapon, blueprint: WeaponBlueprint, view: :full
end
end
end
end

View file

@ -1,49 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewBlueprint < ApiBlueprint
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
view :minimal do
fields :name, :gamertag
end
view :full do
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
field :member_count do |crew|
crew.active_memberships.count
end
field :captain do |crew|
captain = crew.captain
UserBlueprint.render_as_hash(captain, view: :minimal) if captain
end
field :vice_captains do |crew|
UserBlueprint.render_as_hash(crew.vice_captains, view: :minimal)
end
field :current_membership do |crew, options|
current_user = options[:current_user]
next nil unless current_user
membership = crew.crew_memberships.find_by(user_id: current_user.id, retired: false)
CrewMembershipBlueprint.render_as_hash(membership) if membership
end
end
view :with_members do
include_view :full
field :members do |crew|
CrewMembershipBlueprint.render_as_hash(
crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc),
view: :with_user
)
end
end
end
end
end

View file

@ -1,65 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewGwParticipationBlueprint < ApiBlueprint
fields :preliminary_ranking, :final_ranking
field :total_score do |participation|
participation.total_individual_honors
end
field :wins do |participation|
participation.wins_count
end
field :losses do |participation|
participation.losses_count
end
view :summary do
# summary uses base fields only (no gw_event)
end
view :with_event do
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
end
view :with_crew do
field :crew do |participation|
CrewBlueprint.render_as_hash(participation.crew, view: :minimal)
end
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
end
view :full do
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
field :crew_scores do |participation|
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
end
end
view :with_individual_scores do
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
field :crew_scores do |participation|
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
end
field :individual_scores do |participation, options|
GwIndividualScoreBlueprint.render_as_hash(
participation.gw_individual_scores.includes(:crew_membership).order(:round),
view: :with_member,
current_user: options[:current_user]
)
end
end
end
end
end

View file

@ -1,36 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewInvitationBlueprint < ApiBlueprint
fields :status, :expires_at, :created_at
view :default do
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
end
end
view :with_user do
field :user do |invitation|
UserBlueprint.render_as_hash(invitation.user, view: :minimal)
end
field :invited_by do |invitation|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
end
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
end
end
view :for_invitee do
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :full)
end
field :invited_by do |invitation|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
end
end
end
end
end

View file

@ -1,25 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewMembershipBlueprint < ApiBlueprint
fields :role, :retired, :retired_at, :joined_at, :created_at
view :with_user do
fields :role, :retired, :retired_at, :joined_at, :created_at
field :user do |membership|
UserBlueprint.render_as_hash(membership.user, view: :minimal)
end
end
view :with_crew do
fields :role, :retired, :retired_at, :joined_at, :created_at
field :crew do |membership|
CrewBlueprint.render_as_hash(membership.crew, view: :minimal)
end
end
end
end
end

View file

@ -1,54 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GridArtifactBlueprint < ApiBlueprint
fields :level, :reroll_slot, :orphaned
field :collection_artifact_id
field :out_of_sync, if: ->(_field, ga, _options) { ga.collection_artifact_id.present? } do |ga|
ga.out_of_sync?
end
# Return element as integer
field :element do |obj|
obj.element_before_type_cast
end
# Proficiency is only present on quirk artifacts, return as integer
field :proficiency, if: ->(_field, obj, _options) { obj.proficiency.present? } do |obj|
obj.proficiency_before_type_cast
end
field :skills do |obj|
[obj.skill1, obj.skill2, obj.skill3, obj.skill4].map do |skill|
next nil if skill.blank? || skill == {}
{
modifier: skill['modifier'],
strength: skill['strength'],
level: skill['level']
}
end
end
# Include grade and recommendation by default
field :grade do |obj|
ArtifactGrader.new(obj).grade
end
view :nested do
association :artifact, blueprint: ArtifactBlueprint
end
view :full do
include_view :nested
association :grid_character, blueprint: GridCharacterBlueprint
end
view :destroyed do
fields :created_at, :updated_at
end
end
end
end

View file

@ -9,25 +9,18 @@ module Api
gc.transcendence_step
end
field :collection_character_id
field :out_of_sync, if: ->(_field, gc, _options) { gc.collection_character_id.present? } do |gc|
gc.out_of_sync?
end
view :preview do
association :character, blueprint: CharacterBlueprint
association :character, name: :object, blueprint: CharacterBlueprint
end
view :nested do
include_view :mastery_bonuses
association :character, blueprint: CharacterBlueprint, view: :full
association :grid_artifact, blueprint: GridArtifactBlueprint, view: :nested,
if: ->(_field_name, gc, _options) { gc.grid_artifact.present? }
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
end
view :uncap do
association :party, blueprint: PartyBlueprint
fields :position, :uncap_level, :transcendence_step
fields :position, :uncap_level
end
view :destroyed do

View file

@ -3,19 +3,14 @@
module Api
module V1
class GridSummonBlueprint < ApiBlueprint
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step, :orphaned
field :collection_summon_id
field :out_of_sync, if: ->(_field, gs, _options) { gs.collection_summon_id.present? } do |gs|
gs.out_of_sync?
end
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
view :preview do
association :summon, blueprint: SummonBlueprint
association :summon, name: :object, blueprint: SummonBlueprint
end
view :nested do
association :summon, blueprint: SummonBlueprint, view: :full
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
end
view :full do

View file

@ -3,41 +3,18 @@
module Api
module V1
class GridWeaponBlueprint < ApiBlueprint
fields :mainhand, :position, :uncap_level, :transcendence_step, :element, :orphaned
field :collection_weapon_id
field :out_of_sync, if: ->(_field, gw, _options) { gw.collection_weapon_id.present? } do |gw|
gw.out_of_sync?
end
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
view :preview do
association :weapon, blueprint: WeaponBlueprint
association :weapon, name: :object, blueprint: WeaponBlueprint
end
view :nested do
field :ax, if: ->(_field_name, w, _options) { w.ax_modifier1.present? } do |w|
skills = []
if w.ax_modifier1.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier1),
strength: w.ax_strength1
}
end
if w.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier2),
strength: w.ax_strength2
}
end
skills
end
field :befoulment, if: ->(_field_name, w, _options) { w.befoulment_modifier.present? } do |w|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(w.befoulment_modifier),
strength: w.befoulment_strength,
exorcism_level: w.exorcism_level
}
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|
@ -47,15 +24,15 @@ module Api
}
end
association :weapon, blueprint: WeaponBlueprint, view: :full,
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
if: ->(_field_name, w, _options) { w.weapon.present? }
association :weapon_keys,
blueprint: WeaponKeyBlueprint,
if: ->(_field_name, w, _options) {
w.weapon.present? &&
w.weapon.weapon_series.present? &&
w.weapon.weapon_series.has_weapon_keys
w.weapon.series.present? &&
[2, 3, 17, 24, 34].include?(w.weapon.series)
}
end
@ -66,7 +43,7 @@ module Api
view :uncap do
association :party, blueprint: PartyBlueprint
fields :position, :uncap_level, :transcendence_step
fields :position, :uncap_level
end
view :destroyed do

View file

@ -1,14 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GwCrewScoreBlueprint < ApiBlueprint
fields :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id, :victory
# Return round as integer value instead of enum string
field :round do |score|
GwCrewScore.rounds[score.round]
end
end
end
end

View file

@ -1,34 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GwEventBlueprint < ApiBlueprint
fields :start_date, :end_date, :event_number
field :element do |event|
GwEvent.elements[event.element]
end
field :status do |event|
if event.active?
'active'
elsif event.upcoming?
'upcoming'
else
'finished'
end
end
# Include crew's total score if participation data is provided
field :crew_total_score, if: ->(_fn, event, options) { options[:participations]&.key?(event.id) } do |event, options|
options[:participations][event.id]&.total_individual_honors
end
view :with_participation do
field :participation, if: ->(_fn, _obj, options) { options[:participation].present? } do |_, options|
CrewGwParticipationBlueprint.render_as_hash(options[:participation], view: :summary)
end
end
end
end
end

View file

@ -1,41 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GwIndividualScoreBlueprint < ApiBlueprint
fields :round, :score, :is_cumulative, :excused
field :player_name do |score|
score.player_name
end
field :player_type do |score|
if score.crew_membership_id.present?
'member'
elsif score.phantom_player_id.present?
'phantom'
end
end
# Only return excuse_reason to crew officers
field :excuse_reason do |score, options|
current_user = options[:current_user]
score.excuse_reason if current_user&.crew_officer?
end
view :with_member do
field :member do |score|
if score.crew_membership.present?
CrewMembershipBlueprint.render_as_hash(score.crew_membership, view: :with_user)
end
end
field :phantom do |score|
if score.phantom_player.present?
PhantomPlayerBlueprint.render_as_hash(score.phantom_player)
end
end
end
end
end
end

View file

@ -14,7 +14,7 @@ module Api
name: :job,
blueprint: JobBlueprint
fields :granblue_id, :rarity, :release_date, :accessory_type
fields :granblue_id, :rarity, :release_date
end
end
end

View file

@ -19,7 +19,7 @@ module Api
fields :granblue_id, :row, :order,
:master_level, :ultimate_mastery,
:accessory, :accessory_type, :aux_weapon
:accessory, :accessory_type
end
end
end

View file

@ -14,7 +14,7 @@ module Api
name: :job,
blueprint: JobBlueprint
fields :slug, :color, :main, :base, :sub, :emp, :order, :image_id, :action_id
fields :slug, :color, :main, :base, :sub, :emp, :order
end
end
end

View file

@ -6,12 +6,12 @@ module Api
# Base fields that are always needed
fields :local_id, :description, :shortcode, :visibility,
:name, :element, :extra, :charge_attack,
:button_count, :turn_count, :chain_count, :summon_count, :clear_time,
:full_auto, :auto_guard, :auto_summon, :video_url,
: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, :summon_count,
:button_count, :turn_count, :chain_count,
:master_level, :ultimate_mastery
# Party associations
@ -28,16 +28,7 @@ module Api
# Metadata associations
field :favorited do |party, options|
# Use preloaded favorite_party_ids if available, otherwise fall back to query
if options[:favorite_party_ids]
options[:favorite_party_ids].include?(party.id)
else
party.favorited?(options[:current_user])
end
end
field :has_orphaned_items do |party|
party.has_orphaned_items?
party.favorited?(options[:current_user])
end
# For collection views
@ -52,21 +43,6 @@ module Api
include_view :nested_objects # Characters, Weapons, Summons
include_view :remix_metadata # Remixes, Source party
include_view :job_metadata # Accessory, Skills, Guidebooks
# Shares (only visible to owner)
field :shares, if: ->(_field_name, party, options) {
options[:current_user] && party.user_id == options[:current_user].id
} do |party|
party.party_shares.includes(:shareable).map do |share|
{
id: share.id,
shareable_type: share.shareable_type.downcase,
shareable_id: share.shareable_id,
shareable_name: share.shareable.try(:name),
created_at: share.created_at
}
end
end
end
# Primary object associations

View file

@ -1,57 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PartyShareBlueprint < ApiBlueprint
identifier :id
fields :created_at
field :shareable_type do |share|
share.shareable_type.downcase
end
field :shareable_id do |share|
share.shareable_id
end
view :with_shareable do
fields :created_at
field :shareable_type do |share|
share.shareable_type.downcase
end
field :shareable do |share|
case share.shareable_type
when 'Crew'
CrewBlueprint.render_as_hash(share.shareable, view: :minimal)
end
end
field :shared_by do |share|
UserBlueprint.render_as_hash(share.shared_by, view: :minimal)
end
end
view :with_party do
fields :created_at
field :shareable_type do |share|
share.shareable_type.downcase
end
field :party do |share|
PartyBlueprint.render_as_hash(share.party, view: :preview)
end
field :shareable do |share|
case share.shareable_type
when 'Crew'
CrewBlueprint.render_as_hash(share.shareable, view: :minimal)
end
end
end
end
end
end

View file

@ -1,40 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PhantomPlayerBlueprint < ApiBlueprint
fields :name, :granblue_id, :notes, :claim_confirmed, :retired, :retired_at, :joined_at
field :claimed do |phantom|
phantom.claimed_by_id.present?
end
view :with_claimed_by do
field :claimed_by do |phantom|
phantom.claimed_by ? UserBlueprint.render_as_hash(phantom.claimed_by, view: :minimal) : nil
end
end
view :with_scores do
include_view :with_claimed_by
field :total_score do |phantom|
phantom.gw_individual_scores.sum(:score)
end
field :score_count do |phantom|
phantom.gw_individual_scores.count
end
end
# Used for pending phantom claims - includes crew info for context
view :with_crew do
include_view :with_claimed_by
field :crew do |phantom|
phantom.crew ? CrewBlueprint.render_as_hash(phantom.crew, view: :minimal) : nil
end
end
end
end
end

View file

@ -4,8 +4,6 @@ module Api
module V1
class RaidBlueprint < ApiBlueprint
view :nested do
identifier :id
field :name do |raid|
{
en: raid.name_en,
@ -20,6 +18,7 @@ module Api
view :full do
include_view :nested
association :group, blueprint: RaidGroupBlueprint, view: :flat
end
end
end

View file

@ -4,8 +4,6 @@ module Api
module V1
class RaidGroupBlueprint < ApiBlueprint
view :flat do
identifier :id
field :name do |group|
{
en: group.name_en,
@ -13,7 +11,7 @@ module Api
}
end
fields :difficulty, :order, :section, :extra, :guidebooks, :hl, :unlimited
fields :difficulty, :order, :section, :extra, :guidebooks, :hl
end
view :full do

View file

@ -5,27 +5,6 @@ module Api
class SearchBlueprint < Blueprinter::Base
identifier :searchable_id
fields :searchable_type, :granblue_id, :name_en, :name_jp, :element
# Character-specific fields (nil for non-characters)
field :season do |document|
document.searchable_type == 'Character' ? document.searchable&.season : nil
end
field :series do |document|
next nil unless document.searchable_type == 'Character'
character = document.searchable
next nil unless character
# Return series as array of objects with id, slug, and name
character.character_series_records.ordered.map do |series|
{
id: series.id,
slug: series.slug,
name: { en: series.name_en, ja: series.name_jp }
}
end
end
end
end
end

View file

@ -10,24 +10,7 @@ module Api
}
end
fields :granblue_id, :element, :rarity, :max_level, :promotions
field :promotion_names do |s|
s.promotion_names
end
field :series do |s|
if s.summon_series.present?
{
id: s.summon_series_id,
slug: s.summon_series.slug,
name: {
en: s.summon_series.name_en,
ja: s.summon_series.name_jp
}
}
end
end
fields :granblue_id, :element, :rarity, :max_level
field :uncap do |s|
{
@ -69,39 +52,6 @@ module Api
view :full do
include_view :stats
include_view :dates
field :nicknames do |s|
{
en: s.nicknames_en,
ja: s.nicknames_jp
}
end
field :wiki do |s|
{
en: s.wiki_en,
ja: s.wiki_ja
}
end
fields :gamewith, :kamigame
end
# Separate view for raw data - only used by dedicated endpoint
view :raw do
excludes :name, :granblue_id, :element, :rarity, :max_level, :uncap
field :wiki_raw do |s|
s.wiki_raw
end
field :game_raw_en do |s|
s.game_raw_en
end
field :game_raw_jp do |s|
s.game_raw_jp
end
end
end
end

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class SummonSeriesBlueprint < ApiBlueprint
field :name do |ss|
{
en: ss.name_en,
ja: ss.name_jp
}
end
fields :slug, :order
view :full do
field :summon_count do |ss|
ss.summons.count
end
end
end
end
end

View file

@ -4,23 +4,13 @@ module Api
module V1
class UserBlueprint < ApiBlueprint
view :minimal do
fields :username, :language, :private, :gender, :theme, :role, :granblue_id, :show_gamertag, :show_granblue_id
# Return collection_privacy as integer (enum returns string by default)
field :collection_privacy do |user|
User.collection_privacies[user.collection_privacy]
end
fields :username, :language, :private, :gender, :theme, :role
field :avatar do |user|
{
picture: user.picture,
element: user.element
}
end
# Use preloaded active_crew_membership to avoid N+1
field :gamertag, if: ->(_, user, _) {
user.show_gamertag && user.active_crew_membership&.crew&.gamertag.present?
} do |user|
user.active_crew_membership.crew.gamertag
end
end
view :profile do
@ -35,9 +25,7 @@ module Api
fields :username, :token
end
# Settings view includes all user data + email (only for authenticated user viewing own settings)
view :settings do
include_view :minimal
fields :email
end
end

View file

@ -12,42 +12,15 @@ module Api
# Primary information
fields :granblue_id, :element, :proficiency,
:max_level, :max_skill_level, :max_awakening_level, :max_exorcism_level,
:limit, :rarity, :ax, :ax_type, :gacha, :promotions, :forge_order, :extra
# Series - returns full object with flags if weapon_series is present, fallback to legacy integer
field :series do |w|
if w.weapon_series.present?
{
id: w.weapon_series_id,
slug: w.weapon_series.slug,
name: {
en: w.weapon_series.name_en,
ja: w.weapon_series.name_jp
},
has_weapon_keys: w.weapon_series.has_weapon_keys,
has_awakening: w.weapon_series.has_awakening,
augment_type: w.weapon_series.augment_type,
extra: w.weapon_series.extra,
element_changeable: w.weapon_series.element_changeable
}
else
# Legacy fallback for backwards compatibility
w.series
end
end
field :promotion_names do |w|
w.promotion_names
end
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
:series, :ax, :ax_type
# Uncap information
field :uncap do |w|
{
flb: w.flb,
ulb: w.ulb,
transcendence: w.transcendence,
extra_prerequisite: w.extra_prerequisite
transcendence: w.transcendence
}
end
@ -84,89 +57,6 @@ module Api
association :awakenings,
blueprint: AwakeningBlueprint,
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
field :nicknames do |w|
{
en: w.nicknames_en,
ja: w.nicknames_jp
}
end
field :wiki do |w|
{
en: w.wiki_en,
ja: w.wiki_ja
}
end
fields :gamewith, :kamigame
field :recruits do |w|
next nil unless w.recruits.present?
character = Character.find_by(granblue_id: w.recruits)
next nil unless character
{
id: character.id,
granblue_id: character.granblue_id,
name: {
en: character.name_en,
ja: character.name_jp
}
}
end
# Forge chain fields
field :forged_from do |w|
next nil unless w.forged_from.present?
parent = w.forged_from_weapon
next nil unless parent
{
id: parent.id,
granblue_id: parent.granblue_id,
name: {
en: parent.name_en,
ja: parent.name_jp
}
}
end
field :forge_chain do |w|
next nil unless w.forge_chain_id.present?
w.forge_chain.map do |weapon|
{
id: weapon.id,
granblue_id: weapon.granblue_id,
name: {
en: weapon.name_en,
ja: weapon.name_jp
},
forge_order: weapon.forge_order
}
end
end
end
# Separate view for raw data - only used by dedicated endpoint
view :raw do
excludes :name, :granblue_id, :element, :proficiency, :max_level, :max_skill_level,
:max_awakening_level, :limit, :rarity, :series, :ax, :ax_type, :uncap
field :wiki_raw do |w|
w.wiki_raw
end
field :game_raw_en do |w|
w.game_raw_en
end
field :game_raw_jp do |w|
w.game_raw_jp
end
end
end
end

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class WeaponSeriesBlueprint < ApiBlueprint
field :name do |ws|
{
en: ws.name_en,
ja: ws.name_jp
}
end
fields :slug, :order, :extra, :element_changeable, :has_weapon_keys,
:has_awakening, :augment_type
view :full do
field :weapon_count do |ws|
ws.weapons.count
end
end
end
end
end

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class WeaponStatModifierBlueprint < Blueprinter::Base
identifier :id
fields :slug, :name_en, :name_jp, :category, :stat, :polarity, :suffix
end
end
end

View file

@ -9,18 +9,8 @@ module Api
##### Constants
COLLECTION_PER_PAGE = 15
SEARCH_PER_PAGE = 10
MAX_PER_PAGE = 100
MIN_PER_PAGE = 1
##### Errors
# Catch-all for unhandled exceptions - log details and return 500
# NOTE: Must be defined FIRST so it's checked LAST (Rails matches bottom-to-top)
rescue_from StandardError do |e|
Rails.logger.error "[500 Error] #{e.class}: #{e.message}"
Rails.logger.error e.backtrace&.first(20)&.join("\n")
render json: { error: 'Internal Server Error', message: e.message }, status: :internal_server_error
end
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response
rescue_from ActiveRecord::RecordNotDestroyed, with: :render_unprocessable_entity_response
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response_without_object
@ -34,29 +24,10 @@ module Api
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response
# Collection errors
rescue_from CollectionErrors::CollectionError do |e|
render json: e.to_hash, status: e.http_status
end
# Crew errors
rescue_from CrewErrors::CrewError do |e|
render json: e.to_hash, status: e.http_status
end
# Party share errors
rescue_from PartyShareErrors::PartyShareError do |e|
render json: e.to_hash, status: e.http_status
end
rescue_from GranblueError do |e|
render_error(e)
end
rescue_from Api::V1::GranblueError do |e|
render_error(e)
end
##### Hooks
before_action :current_user
before_action :default_content_type
@ -115,17 +86,7 @@ module Api
end
def render_unprocessable_entity_response(exception)
error_data = if exception.respond_to?(:to_hash)
exception.to_hash
elsif exception.is_a?(ActionController::ParameterMissing)
{ message: exception.message, param: exception.param }
elsif exception.respond_to?(:message)
{ message: exception.message }
else
exception
end
render json: ErrorBlueprint.render_as_json(nil, errors: error_data),
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash),
status: :unprocessable_entity
end
@ -160,41 +121,12 @@ module Api
raise UnauthorizedError unless current_user
end
# Returns the requested page size within valid bounds
# Falls back to default if not specified or invalid
# Reads from X-Per-Page header
def page_size(default = COLLECTION_PER_PAGE)
per_page_header = request.headers['X-Per-Page']
return default unless per_page_header.present?
requested_size = per_page_header.to_i
return default if requested_size <= 0
[[requested_size, MAX_PER_PAGE].min, MIN_PER_PAGE].max
end
# Returns the requested page size for search operations
def search_page_size
page_size(SEARCH_PER_PAGE)
end
def n_plus_one_detection
Prosopite.scan
yield
ensure
Prosopite.finish
end
# Returns pagination metadata for will_paginate collections
# @param collection [ActiveRecord::Relation] Paginated collection using will_paginate
# @return [Hash] Pagination metadata with count, total_pages, and per_page
def pagination_meta(collection)
{
count: collection.total_entries,
total_pages: collection.total_pages,
per_page: collection.limit_value || collection.per_page
}
end
end
end
end

View file

@ -1,70 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactSkillsController < Api::V1::ApiController
before_action :set_artifact_skill, only: %w[show update]
before_action :ensure_editor_role, only: %w[update]
# GET /artifact_skills
def index
@skills = ArtifactSkill.all
@skills = @skills.where(skill_group: params[:group]) if params[:group].present?
@skills = @skills.where(polarity: params[:polarity]) if params[:polarity].present?
render json: ArtifactSkillBlueprint.render(@skills, root: :artifact_skills)
end
# GET /artifact_skills/for_slot/:slot
# Returns skills valid for a specific slot (1-4)
def for_slot
slot = params[:slot].to_i
unless (1..4).cover?(slot)
return render json: { error: 'Slot must be between 1 and 4' }, status: :unprocessable_entity
end
@skills = ArtifactSkill.for_slot(slot)
render json: ArtifactSkillBlueprint.render(@skills, root: :artifact_skills)
end
# GET /artifact_skills/:id
def show
render json: ArtifactSkillBlueprint.render(@skill)
end
# PATCH/PUT /artifact_skills/:id
def update
if @skill.update(artifact_skill_params)
ArtifactSkill.clear_cache!
render json: ArtifactSkillBlueprint.render(@skill)
else
render_validation_error_response(@skill)
end
end
private
def set_artifact_skill
@skill = ArtifactSkill.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def artifact_skill_params
params.permit(
:skill_group, :modifier,
:name_en, :name_jp,
:game_name_en, :game_name_jp,
:suffix_en, :suffix_jp,
:growth, :polarity,
base_values: []
)
end
end
end
end

View file

@ -1,118 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactsController < Api::V1::ApiController
before_action :set_artifact, only: %i[show download_image download_images download_status]
# GET /artifacts
def index
@artifacts = Artifact.all
@artifacts = @artifacts.where(rarity: params[:rarity]) if params[:rarity].present?
@artifacts = @artifacts.where(proficiency: params[:proficiency]) if params[:proficiency].present?
render json: ArtifactBlueprint.render(@artifacts, root: :artifacts)
end
# GET /artifacts/:id
def show
render json: ArtifactBlueprint.render(@artifact)
end
# POST /artifacts/grade
# Grades artifact skills without persisting. Accepts skill data and returns grade/recommendation.
#
# @param artifact_id [String] Optional - ID of base artifact (for quirk detection)
# @param skill1 [Hash] Skill data with modifier, strength, level
# @param skill2 [Hash] Skill data with modifier, strength, level
# @param skill3 [Hash] Skill data with modifier, strength, level
# @param skill4 [Hash] Skill data with modifier, strength, level
def grade
artifact_data = build_gradeable_artifact
grader = ArtifactGrader.new(artifact_data)
render json: { grade: grader.grade }
end
# POST /artifacts/:id/download_image
# Synchronously downloads a single image size for the artifact
#
# @param size [String] Required - 'square' or 'wide'
# @param force [Boolean] Optional - Force re-download even if exists
def download_image
size = params[:size]
force = params[:force] == true || params[:force] == 'true'
unless %w[square wide].include?(size)
return render json: { error: "Invalid size. Must be 'square' or 'wide'" }, status: :bad_request
end
service = ArtifactImageDownloadService.new(@artifact, force: force, size: size, storage: :s3)
result = service.download
if result.success?
render json: { success: true, images: result.images }
else
render json: { success: false, error: result.error }, status: :unprocessable_entity
end
end
# POST /artifacts/:id/download_images
# Asynchronously downloads all images for the artifact via background job
#
# @param options.force [Boolean] Optional - Force re-download even if exists
# @param options.size [String] Optional - 'square', 'wide', or 'all' (default)
def download_images
options = params[:options] || {}
force = options[:force] == true || options[:force] == 'true'
size = options[:size] || 'all'
DownloadArtifactImagesJob.perform_later(@artifact.id, force: force, size: size)
render json: {
status: 'queued',
message: "Image download queued for artifact #{@artifact.granblue_id}",
artifact_id: @artifact.id
}, status: :accepted
end
# GET /artifacts/:id/download_status
# Returns the current status of a background download job
def download_status
status = DownloadArtifactImagesJob.status(@artifact.id)
render json: status
end
private
def set_artifact
@artifact = Artifact.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_not_found_response('artifact')
end
def build_gradeable_artifact
base_artifact = params[:artifact_id].present? ? Artifact.find_by(id: params[:artifact_id]) : nil
# Build a simple struct that responds to what ArtifactGrader needs
OpenStruct.new(
skill1: grade_params[:skill1] || {},
skill2: grade_params[:skill2] || {},
skill3: grade_params[:skill3] || {},
skill4: grade_params[:skill4] || {},
artifact: base_artifact || OpenStruct.new(quirk?: false)
)
end
def grade_params
params.permit(
:artifact_id,
skill1: %i[modifier strength level],
skill2: %i[modifier strength level],
skill3: %i[modifier strength level],
skill4: %i[modifier strength level]
)
end
end
end
end

View file

@ -1,72 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CharacterSeriesController < Api::V1::ApiController
before_action :set_character_series, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /character_series
def index
character_series = CharacterSeries.ordered
render json: CharacterSeriesBlueprint.render(character_series)
end
# GET /character_series/:id
def show
render json: CharacterSeriesBlueprint.render(@character_series, view: :full)
end
# POST /character_series
def create
character_series = CharacterSeries.new(character_series_params)
if character_series.save
render json: CharacterSeriesBlueprint.render(character_series, view: :full), status: :created
else
render_validation_error_response(character_series)
end
end
# PATCH/PUT /character_series/:id
def update
if @character_series.update(character_series_params)
render json: CharacterSeriesBlueprint.render(@character_series, view: :full)
else
render_validation_error_response(@character_series)
end
end
# DELETE /character_series/:id
def destroy
if @character_series.characters.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete series with associated characters',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@character_series.destroy!
head :no_content
end
end
private
def set_character_series
# Support lookup by slug or UUID
@character_series = CharacterSeries.find_by(slug: params[:id]) || CharacterSeries.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[CHARACTER_SERIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def character_series_params
params.require(:character_series).permit(:name_en, :name_jp, :slug, :order)
end
end
end
end

View file

@ -3,237 +3,16 @@
module Api
module V1
class CharactersController < Api::V1::ApiController
include IdResolvable
include BatchPreviewable
before_action :set
before_action :set, only: %i[show related download_image download_images download_status update raw fetch_wiki]
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
# GET /characters/:id
def show
render json: CharacterBlueprint.render(@character, view: :full)
end
# GET /characters/:id/related
def related
return render json: [] unless @character.character_id
related = Character.where(character_id: @character.character_id)
.where.not(id: @character.id)
render json: CharacterBlueprint.render(related)
end
# POST /characters
# Creates a new character record
def create
character = Character.new(character_params)
if character.save
render json: CharacterBlueprint.render(character, view: :full), status: :created
else
render_validation_error_response(character)
end
end
# PATCH/PUT /characters/:id
# Updates an existing character record
def update
if @character.update(character_params)
render json: CharacterBlueprint.render(@character, view: :full)
else
render_validation_error_response(@character)
end
end
# GET /characters/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = CharacterImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /characters/:id/download_image
# Synchronously downloads a single image for a character
def download_image
size = params[:size]
transformation = params[:transformation]
force = params[:force] == true
# Validate size
valid_sizes = Granblue::Downloaders::CharacterDownloader::SIZES
unless valid_sizes.include?(size)
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
end
# Validate transformation for characters (01, 02, 03, 04)
valid_transformations = %w[01 02 03 04]
if transformation.present? && !valid_transformations.include?(transformation)
return render json: { error: "Invalid transformation. Must be one of: #{valid_transformations.join(', ')}" }, status: :unprocessable_entity
end
# Build variant ID
variant_id = transformation.present? ? "#{@character.granblue_id}_#{transformation}" : "#{@character.granblue_id}_01"
begin
downloader = Granblue::Downloaders::CharacterDownloader.new(
@character.granblue_id,
storage: :s3,
force: force,
verbose: true
)
# Call the download_variant method directly for a single variant/size
downloader.send(:download_variant, variant_id, size)
render json: {
success: true,
character_id: @character.id,
granblue_id: @character.granblue_id,
size: size,
transformation: transformation,
message: 'Image downloaded successfully'
}
rescue StandardError => e
Rails.logger.error "[CHARACTERS] Image download error for #{@character.id}: #{e.message}"
render json: { success: false, error: e.message }, status: :internal_server_error
end
end
# POST /characters/:id/download_images
# Triggers async image download for a character
def download_images
# Queue the download job
DownloadCharacterImagesJob.perform_later(
@character.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadCharacterImagesJob.update_status(
@character.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
character_id: @character.id,
granblue_id: @character.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /characters/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadCharacterImagesJob.status(@character.id)
render json: status.merge(
character_id: @character.id,
granblue_id: @character.granblue_id
)
end
# GET /characters/:id/raw
# Returns raw wiki and game data for database viewing
def raw
render json: CharacterBlueprint.render(@character, view: :raw)
end
# POST /characters/batch_preview
# Fetches wiki data and suggestions for multiple wiki page names
def batch_preview
wiki_pages = params[:wiki_pages]
wiki_data = params[:wiki_data] || {}
unless wiki_pages.is_a?(Array) && wiki_pages.any?
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
end
# Limit to 10 pages
wiki_pages = wiki_pages.first(10)
results = wiki_pages.map do |wiki_page|
process_wiki_preview(wiki_page, :character, wiki_raw: wiki_data[wiki_page])
end
render json: { results: results }
end
# POST /characters/:id/fetch_wiki
# Fetches and stores wiki data for this character
def fetch_wiki
unless @character.wiki_en.present?
return render json: { error: 'No wiki page configured for this character' }, status: :unprocessable_entity
end
begin
wiki_text = Granblue::Parsers::Wiki.new.fetch(@character.wiki_en)
# Handle redirects
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
@character.update!(wiki_en: redirect_target)
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
end
@character.update!(wiki_raw: wiki_text)
render json: CharacterBlueprint.render(@character, view: :raw)
rescue Granblue::WikiError => e
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
rescue StandardError => e
Rails.logger.error "[CHARACTERS] Wiki fetch error for #{@character.id}: #{e.message}"
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
end
render json: CharacterBlueprint.render(@character)
end
private
def set
@character = find_by_any_id(Character, params[:id])
render_not_found_response('character') unless @character
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[CHARACTERS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def character_params
params.require(:character).permit(
:granblue_id, :name_en, :name_jp, :rarity, :element,
:proficiency1, :proficiency2, :gender, :race1, :race2,
:flb, :ulb, :special, :season,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
:base_da, :base_ta, :ougi_ratio, :ougi_ratio_flb,
:release_date, :flb_date, :ulb_date,
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
nicknames_en: [], nicknames_jp: [], character_id: [], series: []
)
@character = Character.where(granblue_id: params[:id]).first
end
end
end

View file

@ -1,253 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CollectionArtifactsController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_artifact_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_artifact_for_write, only: %i[update destroy]
def index
@collection_artifacts = @target_user.collection_artifacts.includes(:artifact)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_artifacts = @collection_artifacts.where(artifact_id: params[:artifact_id]) if params[:artifact_id]
@collection_artifacts = @collection_artifacts.where(element: array_param(:element)) if params[:element]
@collection_artifacts = @collection_artifacts.by_proficiency(array_param(:proficiency)) if params[:proficiency].present?
@collection_artifacts = @collection_artifacts.joins(:artifact).where(artifacts: { rarity: array_param(:rarity) }) if params[:rarity]
# Skill filters - each slot uses OR logic, slots combined with AND logic
@collection_artifacts = @collection_artifacts.with_skill_in_slot(1, params[:skill1]) if params[:skill1].present?
@collection_artifacts = @collection_artifacts.with_skill_in_slot(2, params[:skill2]) if params[:skill2].present?
@collection_artifacts = @collection_artifacts.with_skill_in_slot(3, params[:skill3]) if params[:skill3].present?
@collection_artifacts = @collection_artifacts.with_skill_in_slot(4, params[:skill4]) if params[:skill4].present?
@collection_artifacts = @collection_artifacts.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifacts,
root: :artifacts,
meta: pagination_meta(@collection_artifacts)
)
end
def show
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifact,
view: :full
)
end
def create
@collection_artifact = current_user.collection_artifacts.build(collection_artifact_params)
if @collection_artifact.save
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifact,
view: :full
), status: :created
else
render_validation_error_response(@collection_artifact)
end
end
def update
if @collection_artifact.update(collection_artifact_params)
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifact,
view: :full
)
else
render_validation_error_response(@collection_artifact)
end
end
def destroy
@collection_artifact.destroy
head :no_content
end
# POST /collection/artifacts/batch
# Creates multiple collection artifacts in a single request
def batch
items = batch_artifact_params[:collection_artifacts] || []
created = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
collection_artifact = current_user.collection_artifacts.build(item_params)
if collection_artifact.save
created << collection_artifact
else
errors << {
index: index,
artifact_id: item_params[:artifact_id],
error: collection_artifact.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionArtifactBlueprint.render(
created,
root: :artifacts,
meta: { created: created.size, errors: errors }
), status: status
end
# POST /collection/artifacts/import
# Imports artifacts from game JSON data
#
# @param data [Hash] Game data containing artifact list
# @param update_existing [Boolean] Whether to update existing artifacts (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = ArtifactImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created&.size || 0,
updated: result.updated&.size || 0,
skipped: result.skipped&.size || 0,
errors: result.errors || [],
reconciliation: result.reconciliation
}, status: status
end
# POST /collection/artifacts/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing artifact list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = ArtifactImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |ca|
{
id: ca.id,
game_id: ca.game_id,
name: ca.artifact&.name_en,
granblue_id: ca.artifact&.granblue_id,
element: ca.element,
level: ca.level
}
end,
count: items_to_delete.size
}
end
# DELETE /collection/artifacts/batch_destroy
# Deletes multiple collection artifacts in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_artifacts.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def check_collection_access
return if @target_user.nil?
return if @target_user.collection_viewable_by?(current_user)
render json: { error: 'You do not have permission to view this collection' }, status: :forbidden
end
def set_collection_artifact_for_read
@collection_artifact = @target_user.collection_artifacts.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('artifact', params[:id])
end
def set_collection_artifact_for_write
@collection_artifact = current_user.collection_artifacts.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('artifact', params[:id])
end
def collection_artifact_params
params.require(:collection_artifact).permit(
:artifact_id, :element, :proficiency, :level, :nickname, :reroll_slot,
skill1: %i[modifier strength level],
skill2: %i[modifier strength level],
skill3: %i[modifier strength level],
skill4: %i[modifier strength level]
)
end
def batch_artifact_params
params.permit(collection_artifacts: [
:artifact_id, :element, :proficiency, :level, :nickname, :reroll_slot,
{ skill1: %i[modifier strength level] },
{ skill2: %i[modifier strength level] },
{ skill3: %i[modifier strength level] },
{ skill4: %i[modifier strength level] }
])
end
def import_params
{
update_existing: params[:update_existing],
is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -1,225 +0,0 @@
module Api
module V1
class CollectionCharactersController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_character_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import]
before_action :set_collection_character_for_write, only: %i[update destroy]
def index
@collection_characters = @target_user.collection_characters
.includes(:character, :awakening)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_characters = @collection_characters.by_element(array_param(:element)) if params[:element]
@collection_characters = @collection_characters.by_rarity(array_param(:rarity)) if params[:rarity]
@collection_characters = @collection_characters.by_race(array_param(:race)) if params[:race]
@collection_characters = @collection_characters.by_proficiency(array_param(:proficiency)) if params[:proficiency]
@collection_characters = @collection_characters.by_gender(array_param(:gender)) if params[:gender]
# Apply sorting
@collection_characters = @collection_characters.sorted_by(params[:sort])
# Apply pagination
@collection_characters = @collection_characters.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_characters,
root: :characters,
meta: pagination_meta(@collection_characters)
)
end
def show
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_character,
view: :full
)
end
def create
@collection_character = current_user.collection_characters.build(collection_character_params)
if @collection_character.save
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_character,
view: :full
), status: :created
else
# Check for duplicate character error
if @collection_character.errors[:character_id].any? { |e| e.include?('already exists') }
raise CollectionErrors::DuplicateCharacter.new(@collection_character.character_id)
end
render_validation_error_response(@collection_character)
end
end
def update
if @collection_character.update(collection_character_params)
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_character,
view: :full
)
else
render_validation_error_response(@collection_character)
end
end
def destroy
@collection_character.destroy
head :no_content
end
# POST /collection/characters/batch
# Creates multiple collection characters in a single request
def batch
items = batch_character_params[:collection_characters] || []
created = []
skipped = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
# Check if already exists (skip duplicates)
if current_user.collection_characters.exists?(character_id: item_params[:character_id])
skipped << { index: index, character_id: item_params[:character_id], reason: 'already_exists' }
next
end
collection_character = current_user.collection_characters.build(item_params)
if collection_character.save
created << collection_character
else
errors << {
index: index,
character_id: item_params[:character_id],
error: collection_character.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionCharacterBlueprint.render(
created,
root: :characters,
meta: { created: created.size, skipped: skipped.size, skipped_items: skipped, errors: errors }
), status: status
end
# DELETE /collection/characters/batch_destroy
# Deletes multiple collection characters in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_characters.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
# POST /collection/characters/import
# Imports characters from game JSON data
#
# @param data [Hash] Game data containing character list
# @param update_existing [Boolean] Whether to update existing characters (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = CharacterImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created.size,
updated: result.updated.size,
skipped: result.skipped.size,
errors: result.errors
}, status: status
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil? # Already handled by set_target_user
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
def set_collection_character_for_read
@collection_character = @target_user.collection_characters.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('character', params[:id])
end
def set_collection_character_for_write
@collection_character = current_user.collection_characters.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('character', params[:id])
end
def collection_character_params
params.require(:collection_character).permit(
:character_id, :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]
)
end
def batch_character_params
params.permit(collection_characters: [
:character_id, :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]
])
end
def import_params
{
update_existing: params[:update_existing],
data: params[:data]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -1,34 +0,0 @@
module Api
module V1
class CollectionController < ApiController
before_action :set_target_user
before_action :check_collection_access
# GET /api/v1/users/:user_id/collection/counts
# Returns total counts for all collection entity types
def counts
render json: {
characters: @target_user.collection_characters.count,
weapons: @target_user.collection_weapons.count,
summons: @target_user.collection_summons.count,
artifacts: @target_user.collection_artifacts.count
}
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil?
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
end
end
end

View file

@ -1,60 +0,0 @@
module Api
module V1
class CollectionJobAccessoriesController < ApiController
before_action :restrict_access
before_action :set_collection_job_accessory, only: [:show, :destroy]
def index
@collection_accessories = current_user.collection_job_accessories
.includes(job_accessory: :job)
@collection_accessories = @collection_accessories.by_job(params[:job_id]) if params[:job_id]
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
@collection_accessories,
root: :collection_job_accessories
)
end
def show
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
@collection_job_accessory
)
end
def create
@collection_accessory = current_user.collection_job_accessories
.build(collection_job_accessory_params)
if @collection_accessory.save
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
@collection_accessory
), status: :created
else
# Check for duplicate job accessory error
if @collection_accessory.errors[:job_accessory_id].any? { |e| e.include?('already exists') }
raise CollectionErrors::DuplicateJobAccessory.new(@collection_accessory.job_accessory_id)
end
render_validation_error_response(@collection_accessory)
end
end
def destroy
@collection_job_accessory.destroy
head :no_content
end
private
def set_collection_job_accessory
@collection_job_accessory = current_user.collection_job_accessories.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('job accessory', params[:id])
end
def collection_job_accessory_params
params.require(:collection_job_accessory).permit(:job_accessory_id)
end
end
end
end

View file

@ -1,237 +0,0 @@
module Api
module V1
class CollectionSummonsController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_summon_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_summon_for_write, only: %i[update destroy]
def index
@collection_summons = @target_user.collection_summons
.includes(:summon)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_summons = @collection_summons.by_summon(params[:summon_id]) if params[:summon_id]
@collection_summons = @collection_summons.by_element(array_param(:element)) if params[:element]
@collection_summons = @collection_summons.by_rarity(array_param(:rarity)) if params[:rarity]
@collection_summons = @collection_summons.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summons,
root: :summons,
meta: pagination_meta(@collection_summons)
)
end
def show
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summon,
view: :full
)
end
def create
@collection_summon = current_user.collection_summons.build(collection_summon_params)
if @collection_summon.save
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summon,
view: :full
), status: :created
else
render_validation_error_response(@collection_summon)
end
end
def update
if @collection_summon.update(collection_summon_params)
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summon,
view: :full
)
else
render_validation_error_response(@collection_summon)
end
end
def destroy
@collection_summon.destroy
head :no_content
end
# POST /collection/summons/batch
# Creates multiple collection summons in a single request
# Unlike characters, summons can have duplicates (user can own multiple copies)
def batch
items = batch_summon_params[:collection_summons] || []
created = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
collection_summon = current_user.collection_summons.build(item_params)
if collection_summon.save
created << collection_summon
else
errors << {
index: index,
summon_id: item_params[:summon_id],
error: collection_summon.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionSummonBlueprint.render(
created,
root: :summons,
meta: { created: created.size, errors: errors }
), status: status
end
# DELETE /collection/summons/batch_destroy
# Deletes multiple collection summons in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_summons.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
# POST /collection/summons/import
# Imports summons from game JSON data
#
# @param data [Hash] Game data containing summon list
# @param update_existing [Boolean] Whether to update existing summons (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = SummonImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created.size,
updated: result.updated.size,
skipped: result.skipped.size,
errors: result.errors,
reconciliation: result.reconciliation
}, status: status
end
# POST /collection/summons/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing summon list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = SummonImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |cs|
{
id: cs.id,
game_id: cs.game_id,
name: cs.summon&.name_en,
granblue_id: cs.summon&.granblue_id,
uncap_level: cs.uncap_level,
transcendence_step: cs.transcendence_step
}
end,
count: items_to_delete.size
}
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil? # Already handled by set_target_user
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
def set_collection_summon_for_read
@collection_summon = @target_user.collection_summons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('summon', params[:id])
end
def set_collection_summon_for_write
@collection_summon = current_user.collection_summons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('summon', params[:id])
end
def collection_summon_params
params.require(:collection_summon).permit(
:summon_id, :uncap_level, :transcendence_step
)
end
def batch_summon_params
params.permit(collection_summons: [
:summon_id, :uncap_level, :transcendence_step
])
end
def import_params
{
update_existing: params[:update_existing],
is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -1,255 +0,0 @@
module Api
module V1
class CollectionWeaponsController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_weapon_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_weapon_for_write, only: %i[update destroy]
def index
@collection_weapons = @target_user.collection_weapons
.includes(:weapon, :awakening,
:weapon_key1, :weapon_key2,
:weapon_key3, :weapon_key4,
:ax_modifier1, :ax_modifier2,
:befoulment_modifier)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_weapons = @collection_weapons.by_weapon(params[:weapon_id]) if params[:weapon_id]
@collection_weapons = @collection_weapons.by_element(array_param(:element)) if params[:element]
@collection_weapons = @collection_weapons.by_rarity(array_param(:rarity)) if params[:rarity]
@collection_weapons = @collection_weapons.by_proficiency(array_param(:proficiency)) if params[:proficiency]
@collection_weapons = @collection_weapons.by_series(array_param(:series)) if params[:series]
@collection_weapons = @collection_weapons.sorted_by(params[:sort])
@collection_weapons = @collection_weapons.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapons,
root: :weapons,
meta: pagination_meta(@collection_weapons)
)
end
def show
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapon,
view: :full
)
end
def create
@collection_weapon = current_user.collection_weapons.build(collection_weapon_params)
if @collection_weapon.save
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapon,
view: :full
), status: :created
else
render_validation_error_response(@collection_weapon)
end
end
def update
if @collection_weapon.update(collection_weapon_params)
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapon,
view: :full
)
else
render_validation_error_response(@collection_weapon)
end
end
def destroy
@collection_weapon.destroy
head :no_content
end
# POST /collection/weapons/batch
# Creates multiple collection weapons in a single request
# Unlike characters, weapons can have duplicates (user can own multiple copies)
def batch
items = batch_weapon_params[:collection_weapons] || []
created = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
collection_weapon = current_user.collection_weapons.build(item_params)
if collection_weapon.save
created << collection_weapon
else
errors << {
index: index,
weapon_id: item_params[:weapon_id],
error: collection_weapon.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionWeaponBlueprint.render(
created,
root: :weapons,
meta: { created: created.size, errors: errors }
), status: status
end
# DELETE /collection/weapons/batch_destroy
# Deletes multiple collection weapons in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_weapons.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
# POST /collection/weapons/import
# Imports weapons from game JSON data
#
# @param data [Hash] Game data containing weapon list
# @param update_existing [Boolean] Whether to update existing weapons (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = WeaponImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created.size,
updated: result.updated.size,
skipped: result.skipped.size,
errors: result.errors,
reconciliation: result.reconciliation
}, status: status
end
# POST /collection/weapons/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing weapon list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = WeaponImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |cw|
{
id: cw.id,
game_id: cw.game_id,
name: cw.weapon&.name_en,
granblue_id: cw.weapon&.granblue_id,
uncap_level: cw.uncap_level,
transcendence_step: cw.transcendence_step
}
end,
count: items_to_delete.size
}
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil? # Already handled by set_target_user
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
def set_collection_weapon_for_read
@collection_weapon = @target_user.collection_weapons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('weapon', params[:id])
end
def set_collection_weapon_for_write
@collection_weapon = current_user.collection_weapons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('weapon', params[:id])
end
def collection_weapon_params
params.require(:collection_weapon).permit(
:weapon_id, :uncap_level, :transcendence_step,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
:awakening_id, :awakening_level,
:ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:element
)
end
def batch_weapon_params
params.permit(collection_weapons: [
:weapon_id, :uncap_level, :transcendence_step,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
:awakening_id, :awakening_level,
:ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:element
])
end
def import_params
{
update_existing: params[:update_existing],
is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -1,96 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewGwParticipationsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_member!
before_action :set_participation, only: %i[show update]
before_action :authorize_crew_officer!, only: %i[create update]
# GET /crew/gw_participations
def index
participations = @crew.crew_gw_participations.includes(:gw_event).order('gw_events.start_date DESC')
render json: CrewGwParticipationBlueprint.render(participations, view: :with_event, root: :crew_gw_participations)
end
# GET /crew/gw_participations/:id
def show
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_individual_scores, root: :crew_gw_participation, current_user: current_user)
end
# GET /crew/gw_participations/by_event/:event_id
def by_event
# Support lookup by event_id (UUID) or event_number (integer)
event = if params[:event_id].match?(/\A\d+\z/)
GwEvent.find_by(event_number: params[:event_id])
else
GwEvent.find_by(id: params[:event_id])
end
return render json: { gw_event: nil, crew_gw_participation: nil, members_during_event: [] } unless event
participation = @crew.crew_gw_participations
.includes(:gw_event, gw_individual_scores: [{ crew_membership: :user }, :phantom_player])
.find_by(gw_event: event)
# Get all members who were active during the event (includes retired members who left after event started)
# Also include all currently active members for score entry purposes
# Uses joined_at (editable) for historical accuracy
members_during_event = @crew.crew_memberships
.includes(:user)
.active_during(event.start_date, event.end_date)
# Get all phantom players who were active during the event (excludes claimed/deleted phantoms)
phantom_players = @crew.phantom_players.not_deleted.active_during(event.start_date, event.end_date)
render json: {
gw_event: GwEventBlueprint.render_as_hash(event),
crew_gw_participation: participation ? CrewGwParticipationBlueprint.render_as_hash(participation, view: :with_individual_scores, current_user: current_user) : nil,
members_during_event: CrewMembershipBlueprint.render_as_hash(members_during_event, view: :with_user),
phantom_players: PhantomPlayerBlueprint.render_as_hash(phantom_players)
}
end
# POST /gw_events/:id/participations
def create
event = GwEvent.find(params[:id])
participation = @crew.crew_gw_participations.build(gw_event: event)
if participation.save
render json: CrewGwParticipationBlueprint.render(participation, view: :with_event, root: :crew_gw_participation), status: :created
else
render_validation_error_response(participation)
end
end
# PUT /crew/gw_participations/:id
def update
if @participation.update(participation_params)
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_event, root: :crew_gw_participation)
else
render_validation_error_response(@participation)
end
end
private
def set_crew
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_participation
@participation = @crew.crew_gw_participations.find(params[:id])
end
def participation_params
params.require(:crew_gw_participation).permit(:preliminary_ranking, :final_ranking)
end
end
end
end

View file

@ -1,78 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewInvitationsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, only: %i[index create]
before_action :authorize_crew_officer!, only: %i[index create]
before_action :set_invitation, only: %i[accept reject]
# GET /crews/:crew_id/invitations
# List pending invitations for a crew (officers only)
def index
invitations = @crew.crew_invitations.pending.includes(:user, :invited_by)
render json: CrewInvitationBlueprint.render(invitations, view: :with_user, root: :invitations)
end
# POST /crews/:crew_id/invitations
# Send an invitation to a user (officers only)
def create
user = User.find_by(id: params[:user_id]) || User.find_by(username: params[:username])
raise ActiveRecord::RecordNotFound, 'User not found' unless user
raise CrewErrors::CannotInviteSelfError if user.id == current_user.id
raise CrewErrors::AlreadyInCrewError if user.crew.present?
# Check for existing pending invitation
existing = @crew.crew_invitations.pending.find_by(user: user)
raise CrewErrors::UserAlreadyInvitedError if existing
invitation = @crew.crew_invitations.build(
user: user,
invited_by: current_user
)
if invitation.save
render json: CrewInvitationBlueprint.render(invitation, view: :with_user, root: :invitation), status: :created
else
render_validation_error_response(invitation)
end
end
# GET /invitations/pending
# List pending invitations for current user
def pending
invitations = current_user.crew_invitations.active.includes(:crew, :invited_by)
render json: CrewInvitationBlueprint.render(invitations, view: :for_invitee, root: :invitations)
end
# POST /invitations/:id/accept
def accept
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
@invitation.accept!
render json: CrewBlueprint.render(current_user.crew, view: :full, root: :crew)
end
# POST /invitations/:id/reject
def reject
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
@invitation.reject!
head :no_content
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_invitation
@invitation = CrewInvitation.find(params[:id])
end
end
end
end

View file

@ -1,149 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewMembershipsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, except: %i[gw_scores]
before_action :set_crew_from_user, only: %i[gw_scores]
before_action :set_membership, only: %i[update destroy promote demote]
before_action :set_membership_for_scores, only: %i[gw_scores]
before_action :authorize_crew_officer!, only: %i[destroy history]
before_action :authorize_crew_captain!, only: %i[promote demote]
before_action :authorize_membership_update!, only: %i[update]
before_action :authorize_crew_member!, only: %i[gw_scores]
# PUT /crews/:crew_id/memberships/:id
def update
allowed_params = if current_user.crew_captain?
membership_params
else
membership_params.slice(:joined_at)
end
if @membership.update(allowed_params)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
else
render_validation_error_response(@membership)
end
end
# DELETE /crews/:crew_id/memberships/:id
def destroy
raise CrewErrors::CannotRemoveCaptainError if @membership.captain?
@membership.retire!
head :no_content
end
# POST /crews/:crew_id/memberships/:id/promote
def promote
raise CrewErrors::CannotRemoveCaptainError if @membership.captain?
# Check vice captain limit
current_vc_count = @crew.crew_memberships.where(role: :vice_captain, retired: false).count
raise CrewErrors::ViceCaptainLimitError if current_vc_count >= 3 && !@membership.vice_captain?
@membership.update!(role: :vice_captain)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
end
# POST /crews/:crew_id/memberships/:id/demote
def demote
raise CrewErrors::CannotDemoteCaptainError if @membership.captain?
@membership.update!(role: :member)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
end
# GET /crews/:crew_id/memberships/by_user/:user_id
def history
memberships = @crew.crew_memberships
.where(user_id: params[:user_id])
.order(created_at: :desc)
render json: CrewMembershipBlueprint.render(memberships, view: :with_user, root: :memberships)
end
# GET /crew/memberships/:id/gw_scores
def gw_scores
# Find ALL memberships for this user in the crew (for boomerang players)
all_memberships = @crew.crew_memberships.where(user_id: @membership.user_id)
membership_ids = all_memberships.pluck(:id)
# Get all crew GW events to identify gaps
all_crew_events = @crew.crew_gw_participations
.joins(:gw_event)
.order('gw_events.event_number DESC')
.pluck('gw_events.id, gw_events.event_number, gw_events.element, gw_events.start_date, gw_events.end_date')
# Get scores across all membership periods
scores_by_event = GwIndividualScore
.joins(crew_gw_participation: :gw_event)
.where(crew_membership_id: membership_ids)
.group('gw_events.id')
.pluck('gw_events.id, SUM(gw_individual_scores.score)')
.to_h
# Build event scores with gap markers
event_scores = all_crew_events.map do |event_id, event_number, element, start_date, end_date|
score = scores_by_event[event_id]
{
gw_event: { id: event_id, event_number: event_number, element: element, start_date: start_date, end_date: end_date },
total_score: score&.to_i,
in_crew: score.present?
}
end
grand_total = event_scores.sum { |es| es[:total_score] || 0 }
# Build membership periods for context
membership_periods = all_memberships.order(created_at: :desc).map do |m|
{ id: m.id, joined_at: m.joined_at, retired_at: m.retired_at, retired: m.retired }
end
render json: {
member: CrewMembershipBlueprint.render_as_hash(@membership, view: :with_user),
event_scores: event_scores,
grand_total: grand_total,
membership_periods: membership_periods
}
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_crew_from_user
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_membership
@membership = @crew.crew_memberships.find(params[:id])
end
def set_membership_for_scores
# Try to find by username first, then fall back to ID
@membership = @crew.crew_memberships.joins(:user).find_by(users: { username: params[:id] }) ||
@crew.crew_memberships.find(params[:id])
end
def membership_params
params.require(:membership).permit(:role, :joined_at, :retired, :retired_at)
end
def authorize_membership_update!
# Officers can update any membership's joined_at
# Captains can update anything
return if current_user.crew_captain?
return if current_user.crew_officer?
raise Api::V1::UnauthorizedError
end
end
end
end

View file

@ -1,199 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class CrewsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, only: %i[show update members roster leave transfer_captain shared_parties]
before_action :require_crew!, only: %i[show update members roster shared_parties]
before_action :authorize_crew_member!, only: %i[show members shared_parties]
before_action :authorize_crew_officer!, only: %i[update roster]
before_action :authorize_crew_captain!, only: %i[transfer_captain]
# GET /crew or GET /crews/:id
def show
render json: CrewBlueprint.render(@crew, view: :full, root: :crew, current_user: current_user)
end
# POST /crews
def create
raise CrewErrors::AlreadyInCrewError if current_user.crew.present?
@crew = Crew.new(crew_params)
ActiveRecord::Base.transaction do
@crew.save!
CrewMembership.create!(crew: @crew, user: current_user, role: :captain)
end
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user), status: :created
end
# PUT /crew
def update
if @crew.update(crew_params)
render json: CrewBlueprint.render(@crew, view: :full, root: :crew, current_user: current_user)
else
render_validation_error_response(@crew)
end
end
# GET /crew/members
# Params:
# filter: 'active' (default), 'retired', 'phantom', 'all'
def members
filter = params[:filter]&.to_sym || :active
case filter
when :active
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
phantoms = @crew.phantom_players.not_deleted.active.includes(:claimed_by).order(:name)
when :retired
members = @crew.crew_memberships.retired.includes(:user).order(retired_at: :desc)
phantoms = @crew.phantom_players.not_deleted.retired.includes(:claimed_by).order(:name)
when :phantom
members = []
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
when :all
members = @crew.crew_memberships.includes(:user).order(role: :desc, retired: :asc, created_at: :asc)
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
else
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
phantoms = @crew.phantom_players.not_deleted.active.includes(:claimed_by).order(:name)
end
render json: {
members: CrewMembershipBlueprint.render_as_hash(members, view: :with_user),
phantoms: PhantomPlayerBlueprint.render_as_hash(phantoms, view: :with_claimed_by)
}
end
# POST /crew/leave
def leave
membership = current_user.active_crew_membership
raise CrewErrors::NotInCrewError unless membership
raise CrewErrors::CaptainCannotLeaveError if membership.captain?
membership.retire!
head :no_content
end
# GET /crew/roster
# Returns collection ownership for crew members based on requested item IDs
# Params: character_ids[], weapon_ids[], summon_ids[]
def roster
members = @crew.active_memberships.includes(:user)
render json: {
members: members.map { |m| build_member_roster(m) }
}
end
# POST /crews/:id/transfer_captain
def transfer_captain
new_captain_id = params[:user_id]
new_captain_membership = @crew.active_memberships.find_by(user_id: new_captain_id)
raise CrewErrors::MemberNotFoundError unless new_captain_membership
ActiveRecord::Base.transaction do
current_user.active_crew_membership.update!(role: :vice_captain)
new_captain_membership.update!(role: :captain)
end
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user)
end
# GET /crew/shared_parties
# Returns parties that have been shared with this crew
def shared_parties
parties = @crew.shared_parties
.includes(:user, :job, :raid)
.order(created_at: :desc)
.paginate(page: params[:page], per_page: page_size)
render json: {
parties: PartyBlueprint.render_as_hash(parties, view: :preview, current_user: current_user),
meta: pagination_meta(parties)
}
end
private
def set_crew
@crew = if params[:id]
Crew.find(params[:id])
else
current_user&.crew
end
end
def crew_params
params.require(:crew).permit(:name, :gamertag, :granblue_crew_id, :description)
end
def require_crew!
render_not_found_response('crew') unless @crew
end
def build_member_roster(membership)
user = membership.user
{
user_id: user.id,
username: user.username,
role: membership.role,
characters: find_collection_items(user, :characters),
weapons: find_collection_items(user, :weapons),
summons: find_collection_items(user, :summons)
}
end
def find_collection_items(user, type)
ids = params["#{type.to_s.singularize}_ids"]
return [] if ids.blank?
collection = case type
when :characters then user.collection_characters.includes(:character).where(character_id: ids)
when :weapons then user.collection_weapons.includes(:weapon).where(weapon_id: ids)
when :summons then user.collection_summons.includes(:summon).where(summon_id: ids)
end
collection.map do |item|
canonical = case type
when :characters then item.character
when :weapons then item.weapon
when :summons then item.summon
end
result = {
id: item_id_for(item, type),
uncap_level: item.uncap_level,
transcendence_step: item.transcendence_step,
flb: canonical&.flb,
ulb: canonical&.ulb
}
if type == :characters
result[:special] = canonical&.special
# For characters, transcendence availability is indicated by ulb on non-special chars
result[:transcendence] = !canonical&.special && canonical&.ulb
else
result[:transcendence] = canonical&.transcendence
end
result
end
end
def item_id_for(item, type)
case type
when :characters then item.character_id
when :weapons then item.weapon_id
when :summons then item.summon_id
end
end
end
end
end

View file

@ -31,15 +31,10 @@ module Api
raise Api::V1::UnauthorizedError unless current_user
@favorite = Favorite.where(user_id: current_user.id, party_id: favorite_params[:party_id]).first
return render_not_found_response('favorite') unless @favorite
render_not_found_response('favorite') unless @favorite
if Favorite.destroy(@favorite.id)
render json: FavoriteBlueprint.render(@favorite, root: :favorite, view: :destroyed)
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new("Couldn't delete favorite")
)
end
render_error("Couldn't delete favorite") unless Favorite.destroy(@favorite.id)
render json: FavoriteBlueprint.render(@favorite, root: :favorite, view: :destroyed)
end
private

View file

@ -1,134 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GridArtifactsController < Api::V1::ApiController
before_action :find_grid_artifact, only: %i[update destroy sync]
before_action :find_party, only: %i[create update destroy sync]
before_action :find_grid_character, only: %i[create]
before_action :find_artifact, only: %i[create]
before_action :authorize_party_edit!, only: %i[create update destroy sync]
# POST /grid_artifacts
def create
# Check if grid_character already has an artifact
if @grid_character.grid_artifact.present?
@grid_character.grid_artifact.destroy
end
@grid_artifact = GridArtifact.new(
grid_artifact_params.merge(
grid_character_id: @grid_character.id,
artifact_id: @artifact.id
)
)
if @grid_artifact.save
render json: GridArtifactBlueprint.render(@grid_artifact, view: :nested, root: :grid_artifact), status: :created
else
render_validation_error_response(@grid_artifact)
end
end
# PATCH/PUT /grid_artifacts/:id
def update
if @grid_artifact.update(grid_artifact_params)
render json: GridArtifactBlueprint.render(@grid_artifact, view: :nested, root: :grid_artifact), status: :ok
else
render_validation_error_response(@grid_artifact)
end
end
# DELETE /grid_artifacts/:id
def destroy
if @grid_artifact.destroy
render json: GridArtifactBlueprint.render(@grid_artifact, view: :destroyed), status: :ok
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(@grid_artifact.errors.full_messages.join(', '))
)
end
end
# POST /grid_artifacts/:id/sync
def sync
unless @grid_artifact.collection_artifact.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection artifact linked')
)
end
@grid_artifact.sync_from_collection!
render json: GridArtifactBlueprint.render(@grid_artifact.reload,
root: :grid_artifact,
view: :nested)
end
private
def find_grid_artifact
@grid_artifact = GridArtifact.find_by(id: params[:id])
render_not_found_response('grid_artifact') unless @grid_artifact
end
def find_party
@party = if @grid_artifact
@grid_artifact.grid_character.party
else
Party.find_by(id: params[:party_id])
end
render_not_found_response('party') unless @party
end
def find_grid_character
@grid_character = GridCharacter.find_by(id: params.dig(:grid_artifact, :grid_character_id))
render_not_found_response('grid_character') unless @grid_character
end
def find_artifact
artifact_id = params.dig(:grid_artifact, :artifact_id)
@artifact = Artifact.find_by(id: artifact_id)
render_not_found_response('artifact') unless @artifact
end
def authorize_party_edit!
if @party.user.present?
authorize_user_party
else
authorize_anonymous_party
end
end
def authorize_user_party
return if current_user.present? && @party.user == current_user
render_unauthorized_response
end
def authorize_anonymous_party
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
return if valid_edit_key?(provided_edit_key, party_edit_key)
render_unauthorized_response
end
def valid_edit_key?(provided_edit_key, party_edit_key)
provided_edit_key.present? &&
provided_edit_key.bytesize == party_edit_key.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
def grid_artifact_params
params.require(:grid_artifact).permit(
:grid_character_id, :artifact_id, :collection_artifact_id,
:element, :proficiency, :level, :reroll_slot,
skill1: %i[modifier strength level],
skill2: %i[modifier strength level],
skill3: %i[modifier strength level],
skill4: %i[modifier strength level]
)
end
end
end
end

View file

@ -13,12 +13,10 @@ module Api
#
# @see Api::V1::ApiController for shared API behavior.
class GridCharactersController < Api::V1::ApiController
include IdResolvable
before_action :find_grid_character, only: %i[update update_uncap_level update_position destroy resolve sync]
before_action :find_party, only: %i[create resolve update update_uncap_level update_position swap destroy sync]
before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve]
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
before_action :find_incoming_character, only: :create
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level update_position swap destroy sync]
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy]
##
# Creates a new grid character.
@ -82,99 +80,17 @@ module Api
# @return [void]
def update_uncap_level
@grid_character.uncap_level = character_params[:uncap_level]
@grid_character.transcendence_step = character_params[:transcendence_step] || 0
@grid_character.transcendence_step = character_params[:transcendence_step]
if @grid_character.save
render json: GridCharacterBlueprint.render(@grid_character,
root: :grid_character,
view: :uncap)
view: :nested)
else
render_validation_error_response(@grid_character)
end
end
##
# Updates the position of a GridCharacter.
#
# Moves a grid character to a new position, maintaining sequential filling for main slots.
# Validates that the target position is empty and within allowed bounds.
#
# @return [void]
def update_position
new_position = position_params[:position].to_i
new_container = position_params[:container]
# Validate position bounds (0-4 main, 5-6 extra)
unless valid_character_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for character")
)
end
# Check if target position is occupied
if GridCharacter.exists?(party_id: @party.id, position: new_position)
return render_unprocessable_entity_response(
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
)
end
old_position = @grid_character.position
@grid_character.position = new_position
# Compact positions if needed (for main slots)
reordered = compact_character_positions if should_compact_characters?(old_position, new_position)
if @grid_character.save
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
grid_character: GridCharacterBlueprint.render_as_hash(@grid_character.reload, view: :nested),
reordered: reordered || false
}, status: :ok
else
render_validation_error_response(@grid_character)
end
end
##
# Swaps positions between two GridCharacters.
#
# Exchanges the positions of two grid characters within the same party.
# Both characters must belong to the same party.
#
# @return [void]
def swap
source_id = swap_params[:source_id]
target_id = swap_params[:target_id]
source = GridCharacter.find_by(id: source_id, party_id: @party.id)
target = GridCharacter.find_by(id: target_id, party_id: @party.id)
unless source && target
return render_not_found_response('grid_character')
end
# Perform the swap
ActiveRecord::Base.transaction do
temp_position = -999
source_pos = source.position
target_pos = target.position
source.update!(position: temp_position)
target.update!(position: source_pos)
source.update!(position: target_pos)
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
swapped: {
source: GridCharacterBlueprint.render_as_hash(source.reload, view: :nested),
target: GridCharacterBlueprint.render_as_hash(target.reload, view: :nested)
}
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error_response(e.record)
end
##
# Resolves conflicts for grid characters.
#
@ -184,7 +100,7 @@ module Api
#
# @return [void]
def resolve
incoming = find_by_any_id(Character, resolve_params[:incoming])
incoming = Character.find_by(id: resolve_params[:incoming])
render_not_found_response('character') and return unless incoming
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
@ -194,11 +110,22 @@ module Api
existing.destroy
end
# Compute the default uncap level based on the incoming character's flags.
if incoming.special
uncap_level = 3
uncap_level = 5 if incoming.ulb
uncap_level = 4 if incoming.flb
else
uncap_level = 4
uncap_level = 6 if incoming.ulb
uncap_level = 5 if incoming.flb
end
grid_character = GridCharacter.create!(
party_id: @party.id,
character_id: incoming.id,
position: resolve_params[:position],
uncap_level: compute_max_uncap_level(incoming)
uncap_level: uncap_level
)
render json: GridCharacterBlueprint.render(grid_character,
root: :grid_character,
@ -217,33 +144,7 @@ module Api
return render_not_found_response('grid_character') if grid_character.nil?
if grid_character.destroy
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed)
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(grid_character.errors.full_messages.join(', '))
)
end
end
##
# Syncs a grid character from its linked collection character.
#
# Copies all customizations from the collection character to this grid character.
# Returns 422 if no collection character is linked.
#
# @return [void]
def sync
unless @grid_character.collection_character.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection character linked')
)
end
@grid_character.sync_from_collection!
render json: GridCharacterBlueprint.render(@grid_character.reload,
root: :grid_character,
view: :nested)
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy
end
private
@ -257,8 +158,7 @@ module Api
grid_character = GridCharacter.new(
character_params.except(:rings, :awakening).merge(
party_id: @party.id,
character_id: @incoming_character.id,
uncap_level: compute_max_uncap_level(@incoming_character)
character_id: @incoming_character.id
)
)
assign_transformed_attributes(grid_character, processed_params)
@ -266,37 +166,17 @@ module Api
grid_character
end
##
# Computes the maximum uncap level for a character based on its flags.
#
# Special characters (limited/seasonal) have a different uncap progression:
# - Base: 3, FLB: 4, ULB: 5
# Regular characters:
# - Base: 4, FLB: 5, ULB: 6
#
# @param character [Character] the character to compute max uncap for.
# @return [Integer] the maximum uncap level.
def compute_max_uncap_level(character)
if character.special
character.ulb ? 5 : character.flb ? 4 : 3
else
character.ulb ? 6 : character.flb ? 5 : 4
end
end
##
# Assigns raw attributes from the original parameters to the grid character.
#
# These attributes (like new_rings and new_awakening) are used by model callbacks.
# Note: We exclude :character_id and :party_id because they are already set correctly
# in build_new_grid_character using the resolved UUIDs, not the raw granblue_id from params.
#
# @param grid_character [GridCharacter] the grid character instance.
# @return [void]
def assign_raw_attributes(grid_character)
grid_character.new_rings = character_params[:rings] if character_params[:rings].present?
grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present?
grid_character.assign_attributes(character_params.except(:rings, :awakening, :character_id, :party_id))
grid_character.assign_attributes(character_params.except(:rings, :awakening))
end
##
@ -435,12 +315,8 @@ module Api
#
# @return [void]
def find_incoming_character
character_id = character_params[:character_id]
@incoming_character = find_by_any_id(Character, character_id)
unless @incoming_character
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new)
end
@incoming_character = Character.find_by(id: character_params[:character_id])
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character
end
##
@ -498,45 +374,6 @@ module Api
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
##
# Validates if a character position is valid.
#
# @param position [Integer] the position to validate.
# @return [Boolean] true if the position is valid; false otherwise.
def valid_character_position?(position)
# Main slots (0-4), extra slots (5-7) for unlimited raids
(0..7).cover?(position)
end
##
# Determines if character positions should be compacted.
#
# @param old_position [Integer] the old position.
# @param new_position [Integer] the new position.
# @return [Boolean] true if compaction is needed; false otherwise.
def should_compact_characters?(old_position, new_position)
# Compact if moving from main slots (0-4) to extra (5-7) or vice versa
main_to_extra = (0..4).cover?(old_position) && (5..7).cover?(new_position)
extra_to_main = (5..7).cover?(old_position) && (0..4).cover?(new_position)
main_to_extra || extra_to_main
end
##
# Compacts character positions to maintain sequential filling.
#
# @return [Boolean] true if positions were reordered; false otherwise.
def compact_character_positions
main_characters = @party.characters.where(position: 0..4).order(:position)
ActiveRecord::Base.transaction do
main_characters.each_with_index do |char, index|
char.update!(position: index) if char.position != index
end
end
true
end
##
# Specifies and permits the allowed character parameters.
#
@ -546,7 +383,6 @@ module Api
:id,
:party_id,
:character_id,
:collection_character_id,
:position,
:uncap_level,
:transcendence_step,
@ -557,22 +393,6 @@ module Api
)
end
##
# Specifies and permits the position update parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def position_params
params.permit(:position, :container)
end
##
# Specifies and permits the swap parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def swap_params
params.permit(:source_id, :target_id)
end
##
# Specifies and permits the allowed resolve parameters.
#

View file

@ -10,14 +10,12 @@ module Api
#
# @see Api::V1::ApiController for shared API behavior.
class GridSummonsController < Api::V1::ApiController
include IdResolvable
attr_reader :party, :incoming_summon
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon update_position resolve destroy sync]
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon update_position swap resolve destroy sync]
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy]
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy]
before_action :find_incoming_summon, only: :create
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon update_position swap destroy sync]
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy]
##
# Creates a new grid summon.
@ -30,9 +28,10 @@ module Api
# @return [void]
def create
# Build a new grid summon using permitted parameters merged with party and summon IDs.
# Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper
# if it hasn't already been provided.
grid_summon = build_grid_summon.tap do |gs|
gs.uncap_level = max_uncap_level(gs.summon)
gs.uncap_level ||= max_uncap_level(gs)
end
# If the grid summon is valid (i.e. it passes all validations), then save it normally.
@ -83,7 +82,7 @@ module Api
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
render json: GridSummonBlueprint.render(@grid_summon, view: :uncap, root: :grid_summon)
render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon)
else
render_validation_error_response(@grid_summon)
end
@ -115,97 +114,6 @@ module Api
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
end
##
# Updates the position of a GridSummon.
#
# Moves a grid summon to a new position, optionally changing its container.
# Validates that the target position is empty and within allowed bounds.
#
# @return [void]
def update_position
new_position = position_params[:position].to_i
new_container = position_params[:container]
# Validate position bounds (-1 main, 0-3 sub, 4-5 subaura, 6 friend)
unless valid_summon_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for summon")
)
end
# Check if position is restricted (main summon, friend)
if restricted_summon_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Cannot move summon to restricted position #{new_position}")
)
end
# Check if target position is occupied
if GridSummon.exists?(party_id: @party.id, position: new_position)
return render_unprocessable_entity_response(
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
)
end
@grid_summon.position = new_position
if @grid_summon.save
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
grid_summon: GridSummonBlueprint.render_as_hash(@grid_summon.reload, view: :nested)
}, status: :ok
else
render_validation_error_response(@grid_summon)
end
end
##
# Swaps positions between two GridSummons.
#
# Exchanges the positions of two grid summons within the same party.
# Both summons must belong to the same party and not be in restricted positions.
#
# @return [void]
def swap
source_id = swap_params[:source_id]
target_id = swap_params[:target_id]
source = GridSummon.find_by(id: source_id, party_id: @party.id)
target = GridSummon.find_by(id: target_id, party_id: @party.id)
unless source && target
return render_not_found_response('grid_summon')
end
# Check if either position is restricted
if restricted_summon_position?(source.position) || restricted_summon_position?(target.position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Cannot swap summons in restricted positions")
)
end
# Perform the swap
ActiveRecord::Base.transaction do
temp_position = -999
source_pos = source.position
target_pos = target.position
source.update!(position: temp_position)
target.update!(position: source_pos)
source.update!(position: target_pos)
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
swapped: {
source: GridSummonBlueprint.render_as_hash(source.reload, view: :nested),
target: GridSummonBlueprint.render_as_hash(target.reload, view: :nested)
}
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error_response(e.record)
end
#
# Destroys a grid summon.
#
@ -219,30 +127,7 @@ module Api
return render_not_found_response('grid_summon') if grid_summon.nil?
if grid_summon.destroy
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(grid_summon.errors.full_messages.join(', '))
)
end
end
##
# Syncs a grid summon from its linked collection summon.
#
# @return [void]
def sync
unless @grid_summon.collection_summon.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection summon linked')
)
end
@grid_summon.sync_from_collection!
render json: GridSummonBlueprint.render(@grid_summon.reload,
root: :grid_summon,
view: :nested)
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy
end
##
@ -329,7 +214,7 @@ module Api
#
# @return [void]
def find_incoming_summon
@incoming_summon = find_by_any_id(Summon, summon_params[:summon_id])
@incoming_summon = Summon.find_by(id: summon_params[:summon_id])
end
##
@ -446,50 +331,13 @@ module Api
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
##
# Validates if a summon position is valid.
#
# @param position [Integer] the position to validate.
# @return [Boolean] true if the position is valid; false otherwise.
def valid_summon_position?(position)
# Main (-1), sub slots (0-3), subaura (4-5), friend (6)
position == -1 || (0..6).cover?(position)
end
##
# Checks if a summon position is restricted (cannot be drag-drop target).
#
# @param position [Integer] the position to check.
# @return [Boolean] true if the position is restricted; false otherwise.
def restricted_summon_position?(position)
# Main summon (-1) and friend summon (6) are restricted
position == -1 || position == 6
end
##
# Defines and permits the whitelisted parameters for a grid summon.
#
# @return [ActionController::Parameters] The permitted parameters.
def summon_params
params.require(:summon).permit(:id, :party_id, :summon_id, :collection_summon_id,
:position, :main, :friend, :quick_summon,
:uncap_level, :transcendence_step)
end
##
# Specifies and permits the position update parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def position_params
params.permit(:position, :container)
end
##
# Specifies and permits the swap parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def swap_params
params.permit(:source_id, :target_id)
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend,
:quick_summon, :uncap_level, :transcendence_step)
end
end
end

View file

@ -10,12 +10,10 @@ module Api
#
# @see Api::V1::ApiController for shared API behavior.
class GridWeaponsController < Api::V1::ApiController
include IdResolvable
before_action :find_grid_weapon, only: %i[update update_uncap_level update_position resolve destroy sync]
before_action :find_party, only: %i[create update update_uncap_level update_position swap resolve destroy sync]
before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy]
before_action :find_party, only: %i[create update update_uncap_level resolve destroy]
before_action :find_incoming_weapon, only: %i[create resolve]
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_position swap resolve destroy sync]
before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy]
##
# Creates a new GridWeapon.
@ -28,13 +26,10 @@ module Api
def create
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
position = weapon_params[:position]
collection_weapon_id = weapon_params[:collection_weapon_id]
grid_weapon = GridWeapon.new(
weapon_params.merge(
party_id: @party.id,
weapon_id: @incoming_weapon.id,
uncap_level: compute_default_uncap(@incoming_weapon, position, collection_weapon_id)
weapon_id: @incoming_weapon.id
)
)
@ -77,95 +72,13 @@ module Api
requested_uncap = weapon_params[:uncap_level].to_i
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: (weapon_params[:transcendence_step] || 0).to_i)
render json: GridWeaponBlueprint.render(@grid_weapon, view: :uncap, root: :grid_weapon), status: :ok
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i)
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok
else
render_validation_error_response(@grid_weapon)
end
end
##
# Updates the position of a GridWeapon.
#
# Moves a grid weapon to a new position, optionally changing its container.
# Validates that the target position is empty and within allowed bounds.
#
# @return [void]
def update_position
new_position = position_params[:position].to_i
new_container = position_params[:container]
# Validate position bounds
unless valid_weapon_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for weapon")
)
end
# Check if target position is occupied
if GridWeapon.exists?(party_id: @party.id, position: new_position)
return render_unprocessable_entity_response(
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
)
end
# Update position
old_position = @grid_weapon.position
@grid_weapon.position = new_position
# Update party attributes if needed
update_party_attributes_for_position(@grid_weapon, new_position)
if @grid_weapon.save
render json: {
party: PartyBlueprint.render_as_hash(@party, view: :full),
grid_weapon: GridWeaponBlueprint.render_as_hash(@grid_weapon, view: :full)
}, status: :ok
else
render_validation_error_response(@grid_weapon)
end
end
##
# Swaps positions between two GridWeapons.
#
# Exchanges the positions of two grid weapons within the same party.
# Both weapons must belong to the same party and be valid for swapping.
#
# @return [void]
def swap
source_id = swap_params[:source_id]
target_id = swap_params[:target_id]
source = GridWeapon.find_by(id: source_id, party_id: @party.id)
target = GridWeapon.find_by(id: target_id, party_id: @party.id)
unless source && target
return render_not_found_response('grid_weapon')
end
# Perform the swap
ActiveRecord::Base.transaction do
temp_position = -999
source_pos = source.position
target_pos = target.position
source.update!(position: temp_position)
target.update!(position: source_pos)
source.update!(position: target_pos)
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
swapped: {
source: GridWeaponBlueprint.render_as_hash(source.reload, view: :full),
target: GridWeaponBlueprint.render_as_hash(target.reload, view: :full)
}
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error_response(e.record)
end
##
# Resolves conflicts by removing conflicting grid weapons and creating a new one.
#
@ -175,7 +88,7 @@ module Api
#
# @return [void]
def resolve
incoming = find_by_any_id(Weapon, resolve_params[:incoming])
incoming = Weapon.find_by(id: resolve_params[:incoming])
conflicting_ids = resolve_params[:conflicting]
conflicting_weapons = GridWeapon.where(id: conflicting_ids)
@ -188,13 +101,11 @@ module Api
end
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
# For extra positions, force ULB for weapons with extra-capable series.
position = resolve_params[:position]
new_uncap = compute_default_uncap(incoming, position)
new_uncap = compute_default_uncap(incoming)
grid_weapon = GridWeapon.create!(
party_id: @party.id,
weapon_id: incoming.id,
position: position,
position: resolve_params[:position],
uncap_level: new_uncap,
transcendence_step: 0
)
@ -217,30 +128,7 @@ module Api
return render_not_found_response('grid_weapon') if grid_weapon.nil?
if grid_weapon.destroy
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(grid_weapon.errors.full_messages.join(', '))
)
end
end
##
# Syncs a grid weapon from its linked collection weapon.
#
# @return [void]
def sync
unless @grid_weapon.collection_weapon.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection weapon linked')
)
end
@grid_weapon.sync_from_collection!
render json: GridWeaponBlueprint.render(@grid_weapon.reload,
root: :grid_weapon,
view: :full)
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy
end
private
@ -266,38 +154,23 @@ module Api
# Computes the default uncap level for an incoming weapon.
#
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
# For extra positions (9-11), weapons with extra_prerequisite set will be forced to that uncap level.
# This logic is skipped for collection weapons which should retain their actual uncap level.
#
# @param incoming [Weapon] the incoming weapon.
# @param position [Integer] the target position (optional).
# @param collection_weapon_id [String] the collection weapon ID if linking from collection (optional).
# @return [Integer] the default uncap level.
def compute_default_uncap(incoming, position = nil, collection_weapon_id = nil)
max_uncap = compute_max_uncap_level(incoming)
# Skip prerequisite logic for collection weapons - use their actual uncap level
return max_uncap if collection_weapon_id.present?
# Extra positions require minimum uncap for weapons with extra_prerequisite set
if position && GridWeapon::EXTRA_POSITIONS.include?(position.to_i) &&
incoming.extra_prerequisite.present?
return [incoming.extra_prerequisite, max_uncap].min
end
max_uncap
def compute_default_uncap(incoming)
compute_max_uncap_level(incoming)
end
##
# Normalizes the AX modifier fields for the weapon parameters.
#
# Sets ax_modifier1_id and ax_modifier2_id to nil if their integer values equal -1.
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1.
#
# @return [void]
def normalize_ax_fields!
params[:weapon][:ax_modifier1_id] = nil if weapon_params[:ax_modifier1_id].to_i == -1
params[:weapon][:ax_modifier2_id] = nil if weapon_params[:ax_modifier2_id].to_i == -1
params[:weapon][:befoulment_modifier_id] = nil if weapon_params[:befoulment_modifier_id].to_i == -1
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1
end
##
@ -407,7 +280,7 @@ module Api
# @return [void]
def find_incoming_weapon
if params.dig(:weapon, :weapon_id).present?
@incoming_weapon = find_by_any_id(Weapon, params.dig(:weapon, :weapon_id))
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id))
render_not_found_response('weapon') unless @incoming_weapon
else
@incoming_weapon = nil
@ -480,63 +353,20 @@ module Api
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
##
# Validates if a weapon position is valid.
#
# @param position [Integer] the position to validate.
# @return [Boolean] true if the position is valid; false otherwise.
def valid_weapon_position?(position)
# Mainhand (-1), grid slots (0-8), extra slots (9-11)
position == -1 || (0..11).cover?(position)
end
##
# Updates party attributes based on the weapon's new position.
#
# @param grid_weapon [GridWeapon] the grid weapon being moved.
# @param new_position [Integer] the new position.
# @return [void]
def update_party_attributes_for_position(grid_weapon, new_position)
if new_position == -1
@party.element = grid_weapon.weapon.element
@party.save!
elsif GridWeapon::EXTRA_POSITIONS.include?(new_position)
@party.extra = true
@party.save!
end
end
##
# Specifies and permits the allowed weapon parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def weapon_params
params.require(:weapon).permit(
:id, :party_id, :weapon_id, :collection_weapon_id,
:id, :party_id, :weapon_id,
:position, :mainhand, :uncap_level, :transcendence_step, :element,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id,
:ax_modifier1_id, :ax_modifier2_id, :ax_strength1, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:ax_modifier1, :ax_modifier2, :ax_strength1, :ax_strength2,
:awakening_id, :awakening_level
)
end
##
# Specifies and permits the position update parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def position_params
params.permit(:position, :container)
end
##
# Specifies and permits the swap parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def swap_params
params.permit(:source_id, :target_id)
end
##
# Specifies and permits the resolve parameters.
#

View file

@ -1,60 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GwCrewScoresController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_officer!
before_action :set_participation
before_action :set_score, only: %i[update destroy]
# POST /crew/gw_participations/:gw_participation_id/crew_scores
def create
score = @participation.gw_crew_scores.build(score_params)
if score.save
render json: GwCrewScoreBlueprint.render(score, root: :gw_crew_score), status: :created
else
render_validation_error_response(score)
end
end
# PUT /crew/gw_participations/:gw_participation_id/crew_scores/:id
def update
if @score.update(score_params)
render json: GwCrewScoreBlueprint.render(@score, root: :gw_crew_score)
else
render_validation_error_response(@score)
end
end
# DELETE /crew/gw_participations/:gw_participation_id/crew_scores/:id
def destroy
@score.destroy!
head :no_content
end
private
def set_crew
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_participation
@participation = @crew.crew_gw_participations.find(params[:gw_participation_id])
end
def set_score
@score = @participation.gw_crew_scores.find(params[:id])
end
def score_params
params.require(:crew_score).permit(:round, :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id)
end
end
end
end

View file

@ -1,67 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GwEventsController < Api::V1::ApiController
before_action :restrict_access, only: %i[create update]
before_action :require_admin!, only: %i[create update]
before_action :set_event, only: %i[show update]
# GET /gw_events
def index
events = GwEvent.order(start_date: :desc)
# If user has a crew, include participation data for each event
participations_by_event = {}
if current_user&.crew
participations = current_user.crew.crew_gw_participations.includes(:gw_individual_scores)
participations.each do |p|
participations_by_event[p.gw_event_id] = p
end
end
render json: GwEventBlueprint.render(events, root: :gw_events, participations: participations_by_event)
end
# GET /gw_events/:id
def show
participation = current_user&.crew&.crew_gw_participations&.find_by(gw_event: @event)
render json: GwEventBlueprint.render(@event, view: :with_participation, participation: participation, root: :gw_event)
end
# POST /gw_events (admin only)
def create
event = GwEvent.new(event_params)
if event.save
render json: GwEventBlueprint.render(event, root: :gw_event), status: :created
else
render_validation_error_response(event)
end
end
# PUT /gw_events/:id (admin only)
def update
if @event.update(event_params)
render json: GwEventBlueprint.render(@event, root: :gw_event)
else
render_validation_error_response(@event)
end
end
private
def set_event
@event = GwEvent.find(params[:id])
end
def event_params
params.require(:gw_event).permit(:element, :start_date, :end_date, :event_number)
end
def require_admin!
raise Api::V1::UnauthorizedError unless current_user&.admin?
end
end
end
end

View file

@ -1,156 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class GwIndividualScoresController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_member!
before_action :set_participation, except: %i[create_by_event batch_by_event]
before_action :set_or_create_participation_by_event, only: %i[create_by_event batch_by_event]
before_action :set_score, only: %i[update destroy]
# POST /crew/gw_participations/:gw_participation_id/individual_scores
def create
# Members can only record their own scores, officers can record anyone's
membership_id = score_params[:crew_membership_id]
unless can_record_score_for?(membership_id)
raise Api::V1::UnauthorizedError
end
score = @participation.gw_individual_scores.build(score_params)
score.recorded_by = current_user
if score.save
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score, current_user: current_user), status: :created
else
render_validation_error_response(score)
end
end
# PUT /crew/gw_participations/:gw_participation_id/individual_scores/:id
def update
unless can_record_score_for?(@score.crew_membership_id)
raise Api::V1::UnauthorizedError
end
if @score.update(score_params.except(:crew_membership_id))
render json: GwIndividualScoreBlueprint.render(@score, view: :with_member, root: :individual_score, current_user: current_user)
else
render_validation_error_response(@score)
end
end
# DELETE /crew/gw_participations/:gw_participation_id/individual_scores/:id
def destroy
unless can_record_score_for?(@score.crew_membership_id)
raise Api::V1::UnauthorizedError
end
@score.destroy!
head :no_content
end
# POST /crew/gw_participations/:gw_participation_id/individual_scores/batch
def batch
return render_unauthorized_response unless current_user.crew_officer?
process_batch_scores
end
# POST /crew/gw_events/:gw_event_id/individual_scores
# Auto-creates participation if needed, officers only
def create_by_event
return render_unauthorized_response unless current_user.crew_officer?
score = @participation.gw_individual_scores.build(score_params_with_player)
score.recorded_by = current_user
if score.save
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score, current_user: current_user), status: :created
else
render_validation_error_response(score)
end
end
# POST /crew/gw_events/:gw_event_id/individual_scores/batch
# Auto-creates participation if needed, officers only
def batch_by_event
return render_unauthorized_response unless current_user.crew_officer?
process_batch_scores
end
private
def set_crew
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_participation
@participation = @crew.crew_gw_participations.find(params[:gw_participation_id])
end
def set_or_create_participation_by_event
event = GwEvent.find(params[:gw_event_id])
@participation = @crew.crew_gw_participations.find_or_create_by!(gw_event: event)
end
def set_score
@score = @participation.gw_individual_scores.find(params[:id])
end
def score_params
params.require(:individual_score).permit(:crew_membership_id, :round, :score, :is_cumulative, :excused, :excuse_reason)
end
def score_params_with_player
params.require(:individual_score).permit(:crew_membership_id, :phantom_player_id, :round, :score, :is_cumulative, :excused, :excuse_reason)
end
def can_record_score_for?(membership_id)
return true if current_user.crew_officer?
# Regular members can only record their own scores
current_user.active_crew_membership&.id == membership_id
end
def process_batch_scores
scores_params = params.require(:scores)
results = []
errors = []
scores_params.each_with_index do |score_data, index|
score = @participation.gw_individual_scores.find_or_initialize_by(
crew_membership_id: score_data[:crew_membership_id],
phantom_player_id: score_data[:phantom_player_id],
round: score_data[:round]
)
score.assign_attributes(
score: score_data[:score],
is_cumulative: score_data[:is_cumulative] || false,
excused: score_data[:excused] || false,
excuse_reason: score_data[:excuse_reason],
recorded_by: current_user
)
if score.save
results << score
else
errors << { index: index, errors: score.errors.full_messages }
end
end
if errors.empty?
render json: GwIndividualScoreBlueprint.render(results, view: :with_member, root: :individual_scores, current_user: current_user), status: :created
else
render json: { individual_scores: GwIndividualScoreBlueprint.render_as_hash(results, view: :with_member, current_user: current_user), errors: errors },
status: :multi_status
end
end
end
end
end

View file

@ -27,94 +27,6 @@ module Api
6 => 5
}.freeze
# GBF series_id to CharacterSeries slug mapping
GBF_SERIES_TO_SLUG = {
1 => 'summer',
2 => 'yukata',
3 => 'valentine',
4 => 'halloween',
5 => 'holiday',
6 => 'zodiac',
7 => 'grand',
8 => 'fantasy',
9 => 'collab',
10 => 'eternal',
11 => 'evoker',
12 => 'saint',
13 => 'formal'
}.freeze
# GBF series_id to WeaponSeries slug mapping
GBF_WEAPON_SERIES_TO_SLUG = {
1 => 'seraphic',
2 => 'grand',
3 => 'dark-opus',
4 => 'revenant',
5 => 'primal',
6 => 'beast',
7 => 'regalia',
8 => 'omega',
9 => 'olden-primal',
10 => 'hollowsky',
11 => 'xeno',
12 => 'rose',
13 => 'ultima',
14 => 'bahamut',
15 => 'epic',
16 => 'cosmos',
17 => 'superlative',
18 => 'vintage',
19 => 'class-champion',
20 => 'replica',
21 => 'relic',
22 => 'rusted',
23 => 'sephira',
24 => 'vyrmament',
25 => 'upgrader',
26 => 'astral',
27 => 'draconic',
28 => 'eternal-splendor',
29 => 'ancestral',
30 => 'new-world-foundation',
31 => 'ennead',
32 => 'militis',
33 => 'malice',
34 => 'menace',
35 => 'illustrious',
36 => 'proven',
37 => 'revans',
38 => 'world',
39 => 'exo',
40 => 'draconic-providence',
41 => 'celestial',
42 => 'omega-rebirth',
43 => 'collab',
44 => 'destroyer'
}.freeze
# GBF series_id to SummonSeries slug mapping
GBF_SUMMON_SERIES_TO_SLUG = {
1 => 'providence',
2 => 'genesis',
3 => 'magna',
4 => 'optimus',
5 => 'demi-optimus',
6 => 'archangel',
7 => 'arcarum',
8 => 'epic',
9 => 'carbuncle',
10 => 'dynamis',
12 => 'cryptid',
13 => 'six-dragons',
14 => 'summer',
15 => 'yukata',
16 => 'holiday',
17 => 'collab',
18 => 'bellum',
19 => 'crest',
20 => 'robur'
}.freeze
before_action :ensure_admin_role, only: %i[weapons summons characters]
##
@ -180,20 +92,6 @@ module Api
weapon.update!(
"game_raw_#{lang}" => body.to_json
)
# Parse series_id and assign WeaponSeries
series_id = body['series_id'] || body.dig('master', 'series_id')
if series_id
slug = GBF_WEAPON_SERIES_TO_SLUG[series_id.to_i]
if slug
series_record = WeaponSeries.find_by(slug: slug)
if series_record && weapon.weapon_series != series_record
weapon.update!(weapon_series: series_record)
Rails.logger.info "[IMPORT] Set series '#{slug}' for weapon #{weapon.granblue_id}"
end
end
end
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
@ -223,20 +121,6 @@ module Api
summon.update!(
"game_raw_#{lang}" => body.to_json
)
# Parse series_id and assign SummonSeries
series_id = body['series_id'] || body.dig('master', 'series_id')
if series_id
slug = GBF_SUMMON_SERIES_TO_SLUG[series_id.to_i]
if slug
series_record = SummonSeries.find_by(slug: slug)
if series_record && summon.summon_series != series_record
summon.update!(summon_series: series_record)
Rails.logger.info "[IMPORT] Set series '#{slug}' for summon #{summon.granblue_id}"
end
end
end
render json: { message: 'Summon gamedata updated successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
@ -270,20 +154,6 @@ module Api
character.update!(
"game_raw_#{lang}" => body.to_json
)
# Parse series_id and create CharacterSeriesMembership
series_id = body['series_id'] || body.dig('master', 'series_id')
if series_id
slug = GBF_SERIES_TO_SLUG[series_id.to_i]
if slug
series_record = CharacterSeries.find_by(slug: slug)
if series_record && !character.character_series_records.include?(series_record)
character.character_series_memberships.create!(character_series: series_record)
Rails.logger.info "[IMPORT] Added series '#{slug}' to character #{character.granblue_id}"
end
end
end
render json: { message: 'Character gamedata updated successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"

View file

@ -3,84 +3,10 @@
module Api
module V1
class JobAccessoriesController < Api::V1::ApiController
before_action :doorkeeper_authorize!, only: %i[create update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /job_accessories
# Optional filter: ?accessory_type=1 (1=Shield, 2=Manatura)
def index
accessories = JobAccessory.includes(:job).all
accessories = accessories.where(accessory_type: params[:accessory_type]) if params[:accessory_type].present?
accessories = accessories.order(:accessory_type, :granblue_id)
render json: JobAccessoryBlueprint.render(accessories)
end
# GET /job_accessories/:id
# Supports lookup by granblue_id or uuid
def show
accessory = find_accessory
return render_not_found_response('job_accessory') unless accessory
render json: JobAccessoryBlueprint.render(accessory)
end
# POST /job_accessories
def create
accessory = JobAccessory.new(job_accessory_params)
if accessory.save
render json: JobAccessoryBlueprint.render(accessory), status: :created
else
render_validation_error_response(accessory)
end
end
# PUT /job_accessories/:id
def update
accessory = find_accessory
return render_not_found_response('job_accessory') unless accessory
if accessory.update(job_accessory_params)
render json: JobAccessoryBlueprint.render(accessory)
else
render_validation_error_response(accessory)
end
end
# DELETE /job_accessories/:id
def destroy
accessory = find_accessory
return render_not_found_response('job_accessory') unless accessory
accessory.destroy
head :no_content
end
# GET /jobs/:id/accessories
# Legacy endpoint - get accessories for a specific job
def job
job = Job.find_by(granblue_id: params[:id]) || Job.find_by(id: params[:id])
return render_not_found_response('job') unless job
accessories = JobAccessory.where(job_id: job.id)
accessories = JobAccessory.where('job_id = ?', params[:id])
render json: JobAccessoryBlueprint.render(accessories)
end
private
def find_accessory
JobAccessory.find_by(granblue_id: params[:id]) || JobAccessory.find_by(id: params[:id])
end
def job_accessory_params
params.permit(:name_en, :name_jp, :granblue_id, :rarity, :release_date, :accessory_type, :job_id)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[JOB_ACCESSORIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
end
end
end

View file

@ -3,87 +3,16 @@
module Api
module V1
class JobSkillsController < Api::V1::ApiController
before_action :doorkeeper_authorize!, only: %i[create update destroy download_image]
before_action :ensure_editor_role, only: %i[create update destroy download_image]
def all
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
end
# Returns skills that belong to a specific job
def job
job = Job.find_by(granblue_id: params[:id])
return render_not_found_response('job') unless job
@skills = JobSkill.includes(:job)
.where(job_id: job.id)
.order(:order)
render json: JobSkillBlueprint.render(@skills)
end
# Returns EMP skills from other jobs (for party skill selection)
def emp
@skills = JobSkill.includes(:job)
.where.not(job_id: params[:id])
.where(emp: true)
render json: JobSkillBlueprint.render(@skills)
end
# POST /jobs/:job_id/skills
def create
job = Job.find_by(granblue_id: params[:job_id])
return render_not_found_response('job') unless job
skill = job.skills.build(job_skill_params)
if skill.save
render json: JobSkillBlueprint.render(skill), status: :created
else
render_validation_error_response(skill)
end
end
# PUT /jobs/:job_id/skills/:id
def update
skill = JobSkill.find(params[:id])
if skill.update(job_skill_params)
render json: JobSkillBlueprint.render(skill)
else
render_validation_error_response(skill)
end
end
# DELETE /jobs/:job_id/skills/:id
def destroy
skill = JobSkill.find(params[:id])
skill.destroy
head :no_content
end
# POST /jobs/:job_id/skills/:id/download_image
def download_image
skill = JobSkill.find(params[:id])
return render json: { error: 'No image_id' }, status: :unprocessable_entity unless skill.image_id.present?
return render json: { error: 'No slug' }, status: :unprocessable_entity unless skill.slug.present?
downloader = Granblue::Downloaders::JobSkillDownloader.new(skill.image_id, slug: skill.slug, storage: :s3)
result = downloader.download
render json: { success: result[:success], filename: "#{skill.slug}.png" }
end
private
def job_skill_params
params.permit(:name_en, :name_jp, :slug, :color, :main, :base, :sub, :emp, :order,
:image_id, :action_id)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[JOB_SKILLS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
end
end
end

View file

@ -3,10 +3,8 @@
module Api
module V1
class JobsController < Api::V1::ApiController
before_action :set_party, only: %w[update_job update_job_skills destroy_job_skill]
before_action :authorize_party, only: %w[update_job update_job_skills destroy_job_skill]
before_action :set_job, only: %w[update]
before_action :ensure_editor_role, only: %w[create update]
before_action :set, only: %w[update_job update_job_skills destroy_job_skill]
before_action :authorize, only: %w[update_job update_job_skills destroy_job_skill]
def all
render json: JobBlueprint.render(Job.all)
@ -16,28 +14,6 @@ module Api
render json: JobBlueprint.render(Job.find_by(granblue_id: params[:id]))
end
# POST /jobs
# Creates a new job record
def create
@job = Job.new(job_update_params)
if @job.save
render json: JobBlueprint.render(@job), status: :created
else
render_validation_error_response(@job)
end
end
# PATCH/PUT /jobs/:id
# Updates an existing job record
def update
if @job.update(job_update_params)
render json: JobBlueprint.render(@job)
else
render_validation_error_response(@job)
end
end
def update_job
if job_params[:job_id] != -1
# Extract job and find its main skills
@ -75,7 +51,7 @@ module Api
end
def update_job_skills
raise Api::V1::NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
throw NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
# Determine which incoming keys contain new skills
skill_keys = %w[skill1_id skill2_id skill3_id]
@ -83,47 +59,47 @@ module Api
# If there are new skills, merge them with the existing skills
unless new_skill_keys.empty?
# Load skills ONCE upfront to avoid N+1 queries
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
new_skills_loaded = JobSkill.where(id: new_skill_ids).index_by(&:id)
# Validate all skills exist and are compatible
new_skill_ids.each do |id|
skill = new_skills_loaded[id]
raise ActiveRecord::RecordNotFound.new("Couldn't find JobSkill") unless skill
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job, skill)
end
existing_skills = {
1 => @party.skill1,
2 => @party.skill2,
3 => @party.skill3
}
positions = extract_positions_from_keys(new_skill_keys)
# Pass loaded skills instead of IDs
merged = merge_skills_with_loaded_skills(existing_skills, new_skill_ids.map { |id| new_skills_loaded[id] }, positions)
skill_ids_hash = merged.each_with_object({}) do |(index, skill), memo|
memo["skill#{index}_id"] = skill&.id
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
new_skill_ids.map do |id|
skill = JobSkill.find(id)
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job,
skill)
end
@party.attributes = skill_ids_hash
positions = extract_positions_from_keys(new_skill_keys)
new_skills = merge_skills_with_existing_skills(existing_skills, new_skill_ids, positions)
new_skill_ids = new_skills.each_with_object({}) do |(index, skill), memo|
memo["skill#{index}_id"] = skill.id if skill
end
@party.attributes = new_skill_ids
end
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save!
end
def destroy_job_skill
position = job_params[:skill_position].to_i
@party["skill#{position}_id"] = nil
render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save
end
private
def merge_skills_with_loaded_skills(existing_skills, new_skills, positions)
# new_skills is now an array of already-loaded JobSkill objects
def merge_skills_with_existing_skills(
existing_skills,
new_skill_ids,
positions
)
new_skills = new_skill_ids.map { |id| JobSkill.find(id) }
new_skills.each_with_index do |skill, index|
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
end
@ -201,35 +177,12 @@ module Api
end
end
def authorize_party
def authorize
render_unauthorized_response if @party.user != current_user || @party.edit_key != edit_key
end
def set_party
@party = Party.find_by(shortcode: params[:id])
render_not_found_response('party') unless @party
end
def set_job
@job = Job.find_by(granblue_id: params[:id])
render_not_found_response('job') unless @job
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[JOBS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def job_update_params
params.permit(
:name_en, :name_jp, :granblue_id,
:proficiency1, :proficiency2, :row, :order,
:master_level, :ultimate_mastery,
:accessory, :accessory_type, :base_job_id
)
def set
@party = Party.where('id = ?', params[:id]).first
end
def job_params

View file

@ -32,9 +32,9 @@ module Api
# Default maximum clear time in seconds
DEFAULT_MAX_CLEAR_TIME = 5400
before_action :set_from_slug, except: %w[create destroy update index favorites grid_update]
before_action :set, only: %w[update destroy grid_update]
before_action :authorize_party!, only: %w[update destroy grid_update]
before_action :set_from_slug, except: %w[create destroy update index favorites]
before_action :set, only: %w[update destroy]
before_action :authorize_party!, only: %w[update destroy]
# Primary CRUD Actions
@ -44,8 +44,10 @@ module Api
def create
party = Party.new(party_params)
party.user = current_user if current_user
if party_params && party_params[:raid_id].present? && (raid = Raid.find_by(id: party_params[:raid_id]))
party.extra = raid.group.extra
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
party.schedule_preview_generation if party.ready_for_preview?
@ -56,15 +58,11 @@ module Api
end
# Shows a specific party.
# Uses viewable_by? to check visibility including crew sharing.
# Also allows access via edit_key for anonymous parties.
def show
unless @party.viewable_by?(current_user) || !not_owner?
return render_unauthorized_response
end
return render_unauthorized_response if @party.private? && (!current_user || not_owner?)
if @party
render json: PartyBlueprint.render(@party, view: :full, root: :party, current_user: current_user)
render json: PartyBlueprint.render(@party, view: :full, root: :party)
else
render_not_found_response('project')
end
@ -73,8 +71,10 @@ module Api
# Updates an existing party.
def update
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
if party_params && party_params[:raid_id] && (raid = Raid.find_by(id: party_params[:raid_id]))
@party.extra = raid.group.extra
if party_params && party_params[:raid_id]
if (raid = Raid.find_by(id: party_params[:raid_id]))
@party.extra = raid.group.extra
end
end
if @party.save
render json: PartyBlueprint.render(@party, view: :full, root: :party)
@ -85,13 +85,7 @@ module Api
# Deletes a party.
def destroy
if @party.destroy
head :no_content
else
render_unprocessable_entity_response(
Api::V1::PartyDeletionFailedError.new(@party.errors.full_messages)
)
end
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
end
# Extended Party Actions
@ -99,8 +93,7 @@ module Api
# Creates a remixed copy of an existing party.
def remix
new_party = @party.amoeba_dup
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party,
remix: true }
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true }
new_party.local_id = party_params[:local_id] if party_params
if new_party.save
new_party.schedule_preview_generation
@ -110,99 +103,11 @@ module Api
end
end
# Batch updates grid items (weapons, characters, summons) atomically.
def grid_update
operations = grid_update_params[:operations]
options = grid_update_params[:options] || {}
# Validate all operations first
validation_errors = validate_grid_operations(operations)
if validation_errors.any?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new("Validation failed: #{validation_errors.join(', ')}")
)
end
changes = []
ActiveRecord::Base.transaction do
operations.each do |operation|
change = apply_grid_operation(operation)
changes << change if change
end
# Compact character positions if needed
compact_party_character_positions if options[:maintain_character_sequence]
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
operations_applied: changes.count,
changes: changes
}, status: :ok
rescue StandardError => e
render_unprocessable_entity_response(
Api::V1::GranblueError.new("Grid update failed: #{e.message}")
)
end
# Syncs all linked grid items from their collection sources.
#
# POST /parties/:id/sync_all
def sync_all
@party = Party.find_by(id: params[:id])
return render_not_found_response('party') unless @party
return render_unauthorized_response unless authorized_to_edit?
synced = { characters: 0, weapons: 0, summons: 0, artifacts: 0 }
ActiveRecord::Base.transaction do
@party.characters.where.not(collection_character_id: nil).each do |gc|
gc.sync_from_collection!
synced[:characters] += 1
end
@party.weapons.where.not(collection_weapon_id: nil).each do |gw|
gw.sync_from_collection!
synced[:weapons] += 1
end
@party.summons.where.not(collection_summon_id: nil).each do |gs|
gs.sync_from_collection!
synced[:summons] += 1
end
GridArtifact.joins(:grid_character)
.where(grid_characters: { party_id: @party.id })
.where.not(collection_artifact_id: nil)
.each do |ga|
ga.sync_from_collection!
synced[:artifacts] += 1
end
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
synced: synced
}, status: :ok
end
# Lists parties based on query parameters.
def index
query = build_filtered_query(build_common_base_query)
@parties = query.paginate(page: params[:page], per_page: page_size)
# Preload current user's favorite party IDs to avoid N+1
favorite_party_ids = current_user ? current_user.favorites.pluck(:party_id).to_set : Set.new
render json: Api::V1::PartyBlueprint.render(
@parties,
view: :preview,
root: :results,
meta: pagination_meta(@parties),
current_user: current_user,
favorite_party_ids: favorite_party_ids
)
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
render_paginated_parties(@parties)
end
# GET /api/v1/parties/favorites
@ -210,23 +115,12 @@ module Api
raise Api::V1::UnauthorizedError unless current_user
base_query = build_common_base_query
.joins(:favorites)
.where(favorites: { user_id: current_user.id })
.distinct
.joins(:favorites)
.where(favorites: { user_id: current_user.id })
.distinct
query = build_filtered_query(base_query)
@parties = query.paginate(page: params[:page], per_page: page_size)
# All parties in this list are favorites, but preload for consistency
favorite_party_ids = current_user.favorites.pluck(:party_id).to_set
render json: Api::V1::PartyBlueprint.render(
@parties,
view: :preview,
root: :results,
meta: pagination_meta(@parties),
current_user: current_user,
favorite_party_ids: favorite_party_ids
)
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
render_paginated_parties(@parties)
end
# Preview Management
@ -241,8 +135,7 @@ module Api
# Returns the current preview status of a party.
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? }
render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? }
end
# Forces regeneration of the party preview.
@ -264,17 +157,15 @@ module Api
def set_from_slug
@party = Party.includes(
:user, :job, { raid: :group },
{ characters: [:character, :awakening, :grid_artifact] },
{ characters: %i[character awakening] },
{ weapons: {
weapon: [:awakenings, :weapon_series],
weapon: [:awakenings],
awakening: {},
weapon_key1: {},
weapon_key2: {},
weapon_key3: {},
ax_modifier1: {},
ax_modifier2: {},
befoulment_modifier: {}
} },
weapon_key3: {}
}
},
{ summons: :summon },
:guidebook1, :guidebook2, :guidebook3,
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
@ -295,141 +186,15 @@ module Api
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
:turn_count, :chain_count, :summon_count, :video_url, :guidebook1_id, :guidebook2_id, :guidebook3_id,
:turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id,
characters_attributes: [: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] }],
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1_id ax_modifier2_id ax_strength1 ax_strength2 befoulment_modifier_id befoulment_strength exorcism_level awakening_id awakening_level]
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level]
)
end
# Permits parameters for grid update operation.
def grid_update_params
params.permit(
operations: %i[type entity id source_id target_id position container],
options: %i[maintain_character_sequence validate_before_execute]
)
end
# Validates grid operations before executing.
def validate_grid_operations(operations)
errors = []
operations.each_with_index do |op, index|
case op[:type]
when 'move'
errors << "Operation #{index}: missing id" unless op[:id].present?
errors << "Operation #{index}: missing position" unless op[:position].present?
when 'swap'
errors << "Operation #{index}: missing source_id" unless op[:source_id].present?
errors << "Operation #{index}: missing target_id" unless op[:target_id].present?
when 'remove'
errors << "Operation #{index}: missing id" unless op[:id].present?
else
errors << "Operation #{index}: unknown operation type #{op[:type]}"
end
unless %w[weapon character summon].include?(op[:entity])
errors << "Operation #{index}: invalid entity type #{op[:entity]}"
end
end
errors
end
# Applies a single grid operation.
def apply_grid_operation(operation)
case operation[:type]
when 'move'
apply_move_operation(operation)
when 'swap'
apply_swap_operation(operation)
when 'remove'
apply_remove_operation(operation)
end
end
# Applies a move operation.
def apply_move_operation(operation)
model_class = grid_model_for_entity(operation[:entity])
item = model_class.find_by(id: operation[:id], party_id: @party.id)
return nil unless item
old_position = item.position
item.update!(position: operation[:position])
{
entity: operation[:entity],
id: operation[:id],
action: 'moved',
from: old_position,
to: operation[:position]
}
end
# Applies a swap operation.
def apply_swap_operation(operation)
model_class = grid_model_for_entity(operation[:entity])
source = model_class.find_by(id: operation[:source_id], party_id: @party.id)
target = model_class.find_by(id: operation[:target_id], party_id: @party.id)
return nil unless source && target
source_pos = source.position
target_pos = target.position
# Use a temporary position to avoid conflicts
source.update!(position: -999)
target.update!(position: source_pos)
source.update!(position: target_pos)
{
entity: operation[:entity],
id: operation[:source_id],
action: 'swapped',
with: operation[:target_id]
}
end
# Applies a remove operation.
def apply_remove_operation(operation)
model_class = grid_model_for_entity(operation[:entity])
item = model_class.find_by(id: operation[:id], party_id: @party.id)
return nil unless item
item.destroy
{
entity: operation[:entity],
id: operation[:id],
action: 'removed'
}
end
# Returns the model class for a given entity type.
def grid_model_for_entity(entity)
case entity
when 'weapon'
GridWeapon
when 'character'
GridCharacter
when 'summon'
GridSummon
end
end
# Compacts character positions to maintain sequential filling.
def compact_party_character_positions
main_characters = @party.characters.where(position: 0..4).order(:position)
main_characters.each_with_index do |char, index|
char.update!(position: index) if char.position != index
end
end
end
end
end

View file

@ -1,67 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PartySharesController < Api::V1::ApiController
before_action :restrict_access
before_action :set_party
before_action :authorize_party_owner!
before_action :set_party_share, only: [:destroy]
# GET /parties/:party_id/shares
# List all shares for a party (only for owner)
def index
shares = @party.party_shares.includes(:shareable, :shared_by)
render json: PartyShareBlueprint.render(shares, view: :with_shareable, root: :shares)
end
# POST /parties/:party_id/shares
# Share a party with the current user's crew
def create
crew = current_user.crew
raise PartyShareErrors::NotInCrewError unless crew
# For now, users can only share to their own crew
# Future: support party_share_params[:crew_id] for sharing to other crews
share = PartyShare.new(
party: @party,
shareable: crew,
shared_by: current_user
)
if share.save
render json: PartyShareBlueprint.render(share, view: :with_shareable, root: :share), status: :created
else
render_validation_error_response(share)
end
end
# DELETE /parties/:party_id/shares/:id
# Remove a share
def destroy
@party_share.destroy!
head :no_content
end
private
def set_party
@party = Party.find(params[:party_id])
end
def set_party_share
@party_share = @party.party_shares.find(params[:id])
end
def authorize_party_owner!
return if @party.user_id == current_user.id
raise Api::V1::UnauthorizedError
end
def party_share_params
params.require(:share).permit(:crew_id)
end
end
end
end

View file

@ -1,21 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PhantomClaimsController < Api::V1::ApiController
before_action :restrict_access
# GET /pending_phantom_claims
# Returns phantom players assigned to the current user that are pending confirmation
def index
phantoms = PhantomPlayer
.not_deleted
.includes(:crew, :claimed_by)
.where(claimed_by: current_user, claim_confirmed: false)
.order(created_at: :desc)
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_crew, root: :phantom_claims)
end
end
end
end

View file

@ -1,154 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PhantomPlayersController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, except: %i[gw_scores]
before_action :set_crew_from_user, only: %i[gw_scores]
before_action :authorize_crew_member!, only: %i[index confirm_claim decline_claim gw_scores]
before_action :authorize_crew_officer!, only: %i[create bulk_create update destroy assign]
before_action :set_phantom, only: %i[show update destroy assign confirm_claim decline_claim]
before_action :set_phantom_for_scores, only: %i[gw_scores]
# GET /crews/:crew_id/phantom_players
def index
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_claimed_by, root: :phantom_players)
end
# GET /crews/:crew_id/phantom_players/:id
def show
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_scores, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players
def create
phantom = @crew.phantom_players.build(phantom_params)
if phantom.save
render json: PhantomPlayerBlueprint.render(phantom, root: :phantom_player), status: :created
else
render_validation_error_response(phantom)
end
end
# POST /crews/:crew_id/phantom_players/bulk_create
def bulk_create
phantoms = []
ActiveRecord::Base.transaction do
bulk_params[:phantom_players].each do |phantom_attrs|
phantom = @crew.phantom_players.build(phantom_attrs.permit(:name, :granblue_id, :notes, :joined_at))
phantom.save!
phantoms << phantom
end
end
render json: PhantomPlayerBlueprint.render(phantoms, root: :phantom_players), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
# PUT /crews/:crew_id/phantom_players/:id
def update
if @phantom.update(phantom_params)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
else
render_validation_error_response(@phantom)
end
end
# DELETE /crews/:crew_id/phantom_players/:id
def destroy
@phantom.destroy!
head :no_content
end
# POST /crews/:crew_id/phantom_players/:id/assign
def assign
user = User.find(params[:user_id])
@phantom.assign_to(user)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players/:id/confirm_claim
def confirm_claim
@phantom.confirm_claim!(current_user)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players/:id/decline_claim
def decline_claim
raise CrewErrors::NotClaimedByUserError unless @phantom.claimed_by == current_user
@phantom.unassign!
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# GET /crew/phantom_players/:id/gw_scores
def gw_scores
# Get all crew GW events to identify gaps
all_crew_events = @crew.crew_gw_participations
.joins(:gw_event)
.order('gw_events.event_number DESC')
.pluck('gw_events.id, gw_events.event_number, gw_events.element, gw_events.start_date, gw_events.end_date')
# Get scores for this phantom
scores_by_event = GwIndividualScore
.joins(crew_gw_participation: :gw_event)
.where(phantom_player_id: @phantom.id)
.group('gw_events.id')
.pluck('gw_events.id, SUM(gw_individual_scores.score)')
.to_h
# Build event scores with gap markers
event_scores = all_crew_events.map do |event_id, event_number, element, start_date, end_date|
score = scores_by_event[event_id]
{
gw_event: { id: event_id, event_number: event_number, element: element, start_date: start_date, end_date: end_date },
total_score: score&.to_i,
in_crew: score.present?
}
end
grand_total = event_scores.sum { |es| es[:total_score] || 0 }
render json: {
phantom: PhantomPlayerBlueprint.render_as_hash(@phantom),
event_scores: event_scores,
grand_total: grand_total
}
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_crew_from_user
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_phantom
@phantom = @crew.phantom_players.find(params[:id])
end
def set_phantom_for_scores
@phantom = @crew.phantom_players.find(params[:id])
end
def phantom_params
params.require(:phantom_player).permit(:name, :granblue_id, :notes, :joined_at, :retired, :retired_at)
end
def bulk_params
params.permit(phantom_players: %i[name granblue_id notes joined_at])
end
end
end
end

View file

@ -1,77 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class RaidGroupsController < Api::V1::ApiController
before_action :set_raid_group, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /raid_groups
def index
groups = RaidGroup.includes(:raids).ordered
render json: RaidGroupBlueprint.render(groups, view: :full)
end
# GET /raid_groups/:id
def show
if @raid_group
render json: RaidGroupBlueprint.render(@raid_group, view: :full)
else
render json: { error: 'Raid group not found' }, status: :not_found
end
end
# POST /raid_groups
def create
raid_group = RaidGroup.new(raid_group_params)
if raid_group.save
render json: RaidGroupBlueprint.render(raid_group, view: :full), status: :created
else
render_validation_error_response(raid_group)
end
end
# PATCH/PUT /raid_groups/:id
def update
if @raid_group.update(raid_group_params)
render json: RaidGroupBlueprint.render(@raid_group, view: :full)
else
render_validation_error_response(@raid_group)
end
end
# DELETE /raid_groups/:id
def destroy
if @raid_group.raids.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete group with associated raids',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@raid_group.destroy!
head :no_content
end
end
private
def set_raid_group
@raid_group = RaidGroup.find_by(id: params[:id])
end
def raid_group_params
params.require(:raid_group).permit(
:name_en, :name_jp, :difficulty, :order, :section, :extra, :hl, :guidebooks, :unlimited
)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[RAID_GROUPS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
end
end
end

View file

@ -3,99 +3,17 @@
module Api
module V1
class RaidsController < Api::V1::ApiController
before_action :set_raid, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /raids
def index
raids = Raid.includes(:group)
raids = apply_filters(raids)
raids = raids.ordered
render json: RaidBlueprint.render(raids, view: :nested)
end
# GET /raids/:id
def show
if @raid
render json: RaidBlueprint.render(@raid, view: :full)
else
render json: { error: 'Raid not found' }, status: :not_found
end
end
# POST /raids
def create
raid = Raid.new(raid_params)
if raid.save
render json: RaidBlueprint.render(raid, view: :full), status: :created
else
render_validation_error_response(raid)
end
end
# PATCH/PUT /raids/:id
def update
if @raid.update(raid_params)
render json: RaidBlueprint.render(@raid, view: :full)
else
render_validation_error_response(@raid)
end
end
# DELETE /raids/:id
def destroy
if Party.where(raid_id: @raid.id).exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete raid with associated parties',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@raid.destroy!
head :no_content
end
end
# GET /raids/groups (legacy endpoint)
def groups
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).ordered, view: :full)
end
# Legacy alias for index
def all
index
render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested)
end
private
def set_raid
@raid = Raid.find_by(slug: params[:id]) || Raid.find_by(id: params[:id])
def show
raid = Raid.find_by(slug: params[:id])
render json: RaidBlueprint.render(Raid.find_by(slug: params[:id]), view: :full) if raid
end
def raid_params
params.require(:raid).permit(:name_en, :name_jp, :level, :element, :slug, :group_id)
end
def apply_filters(scope)
scope = scope.by_element(filter_params[:element]) if filter_params[:element].present?
scope = scope.by_group(filter_params[:group_id]) if filter_params[:group_id].present?
scope = scope.by_difficulty(filter_params[:difficulty]) if filter_params[:difficulty].present?
scope = scope.by_hl(filter_params[:hl]) if filter_params[:hl].present?
scope = scope.by_extra(filter_params[:extra]) if filter_params[:extra].present?
scope = scope.with_guidebooks if filter_params[:guidebooks] == 'true'
scope
end
def filter_params
params.permit(:element, :group_id, :difficulty, :hl, :extra, :guidebooks)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[RAIDS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
def groups
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full)
end
end
end

View file

@ -55,7 +55,6 @@ module Api
def characters
filters = search_params[:filters]
locale = search_params[:locale] || 'en'
exclude = search_params[:exclude]
conditions = {}
if filters
@ -69,7 +68,7 @@ module Api
conditions[:proficiency2] =
filters['proficiency2']
end
conditions[:season] = filters['season'] unless filters['season'].blank? || filters['season'].empty?
# conditions[:series] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
end
characters = if search_params[:query].present? && search_params[:query].length >= 2
@ -79,34 +78,19 @@ module Api
Character.en_search(search_params[:query]).where(conditions)
end
else
Character.where(conditions)
Character.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc'))
end
# Apply sorting if specified, otherwise use default
if search_params[:sort].present?
characters = apply_sort(characters, search_params[:sort], search_params[:order], locale)
elsif search_params[:query].blank?
characters = characters.order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc'))
end
# Filter by series (array overlap)
if filters && filters['series'].present? && !filters['series'].empty?
series_values = Array(filters['series']).map(&:to_i)
characters = characters.where('series && ARRAY[?]::integer[]', series_values)
end
# Exclude already-owned characters (for collection modal)
if exclude.present? && exclude.any?
characters = characters.where.not(id: exclude)
end
count = characters.length
paginated = characters.paginate(page: search_params[:page], per_page: search_page_size)
paginated = characters.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
render json: CharacterBlueprint.render(paginated,
view: :dates,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
meta: {
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end
def weapons
@ -121,7 +105,7 @@ module Api
conditions[:proficiency] =
filters['proficiency1']
end
conditions[:weapon_series_id] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
conditions[:series] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
conditions[:extra] = filters['extra'] unless filters['extra'].blank?
end
@ -132,29 +116,19 @@ module Api
Weapon.en_search(search_params[:query]).where(conditions)
end
else
Weapon.where(conditions)
Weapon.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
end
# Apply sorting if specified, otherwise use default
if search_params[:sort].present?
weapons = apply_sort(weapons, search_params[:sort], search_params[:order], locale)
elsif search_params[:query].blank?
weapons = weapons.order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
end
# Filter by promotions (array overlap)
if filters && filters['promotions'].present? && !filters['promotions'].empty?
promotions_values = Array(filters['promotions']).map(&:to_i)
weapons = weapons.where('promotions && ARRAY[?]::integer[]', promotions_values)
end
count = weapons.length
paginated = weapons.paginate(page: search_params[:page], per_page: search_page_size)
paginated = weapons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
render json: WeaponBlueprint.render(paginated,
view: :dates,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
meta: {
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end
def summons
@ -175,29 +149,19 @@ module Api
Summon.en_search(search_params[:query]).where(conditions)
end
else
Summon.where(conditions)
Summon.where(conditions).order(release_date: :desc).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
end
# Apply sorting if specified, otherwise use default
if search_params[:sort].present?
summons = apply_sort(summons, search_params[:sort], search_params[:order], locale)
elsif search_params[:query].blank?
summons = summons.order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
end
# Filter by promotions (array overlap)
if filters && filters['promotions'].present? && !filters['promotions'].empty?
promotions_values = Array(filters['promotions']).map(&:to_i)
summons = summons.where('promotions && ARRAY[?]::integer[]', promotions_values)
end
count = summons.length
paginated = summons.paginate(page: search_params[:page], per_page: search_page_size)
paginated = summons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
render json: SummonBlueprint.render(paginated,
view: :dates,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
meta: {
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end
def job_skills
@ -277,63 +241,15 @@ module Api
end
count = skills.length
paginated = skills.paginate(page: search_params[:page], per_page: search_page_size)
paginated = skills.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
render json: JobSkillBlueprint.render(paginated,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
end
def jobs
filters = search_params[:filters]
locale = search_params[:locale] || 'en'
conditions = {}
if filters
conditions[:row] = filters['row'] unless filters['row'].blank? || filters['row'].empty?
unless filters['proficiency'].blank? || filters['proficiency'].empty?
# Filter by either proficiency1 or proficiency2 matching
proficiency_values = Array(filters['proficiency']).map(&:to_i)
conditions[:proficiency1] = proficiency_values
end
end
jobs = if search_params[:query].present? && search_params[:query].length >= 2
if locale == 'ja'
Job.ja_search(search_params[:query]).where(conditions)
else
Job.en_search(search_params[:query]).where(conditions)
end
else
Job.where(conditions)
end
# Filter by proficiency2 as well (OR condition)
if filters && filters['proficiency'].present? && !filters['proficiency'].empty?
proficiency_values = Array(filters['proficiency']).map(&:to_i)
jobs = jobs.or(Job.where(proficiency2: proficiency_values))
end
# Apply feature filters
if filters
jobs = jobs.where(master_level: true) if filters['masterLevel'] == true || filters['masterLevel'] == 'true'
jobs = jobs.where(ultimate_mastery: true) if filters['ultimateMastery'] == true || filters['ultimateMastery'] == 'true'
jobs = jobs.where(accessory: true) if filters['accessory'] == true || filters['accessory'] == 'true'
end
# Apply sorting if specified, otherwise use default (row, then order)
if search_params[:sort].present?
jobs = apply_job_sort(jobs, search_params[:sort], search_params[:order], locale)
else
jobs = jobs.order(:row, :order)
end
count = jobs.length
paginated = jobs.paginate(page: search_params[:page], per_page: search_page_size)
render json: JobBlueprint.render(paginated,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
meta: {
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end
def guidebooks
@ -345,56 +261,27 @@ module Api
end
count = books.length
paginated = books.paginate(page: search_params[:page], per_page: search_page_size)
paginated = books.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE)
render json: GuidebookBlueprint.render(paginated,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
meta: {
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end
private
def total_pages(count)
count.to_f / SEARCH_PER_PAGE > 1 ? (count.to_f / SEARCH_PER_PAGE).ceil : 1
end
# Specify whitelisted properties that can be modified.
def search_params
return {} unless params[:search].present?
params.require(:search).permit!
end
# Apply sorting based on column name and order
def apply_sort(scope, column, order, locale)
sort_dir = order == 'desc' ? :desc : :asc
case column
when 'name'
name_col = locale == 'ja' ? :name_ja : :name_en
scope.order(name_col => sort_dir)
when 'element'
scope.order(element: sort_dir)
when 'rarity'
scope.order(rarity: sort_dir)
when 'last_updated'
scope.order(updated_at: sort_dir)
else
scope
end
end
# Apply sorting for jobs
def apply_job_sort(scope, column, order, locale)
sort_dir = order == 'desc' ? :desc : :asc
case column
when 'name'
name_col = locale == 'ja' ? :name_ja : :name_en
scope.order(name_col => sort_dir)
when 'row'
scope.order(row: sort_dir, order: :asc)
when 'proficiency'
scope.order(proficiency1: sort_dir)
else
scope.order(:row, :order)
end
end
end
end
end

View file

@ -1,72 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class SummonSeriesController < Api::V1::ApiController
before_action :set_summon_series, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /summon_series
def index
summon_series = SummonSeries.ordered
render json: SummonSeriesBlueprint.render(summon_series)
end
# GET /summon_series/:id
def show
render json: SummonSeriesBlueprint.render(@summon_series, view: :full)
end
# POST /summon_series
def create
summon_series = SummonSeries.new(summon_series_params)
if summon_series.save
render json: SummonSeriesBlueprint.render(summon_series, view: :full), status: :created
else
render_validation_error_response(summon_series)
end
end
# PATCH/PUT /summon_series/:id
def update
if @summon_series.update(summon_series_params)
render json: SummonSeriesBlueprint.render(@summon_series, view: :full)
else
render_validation_error_response(@summon_series)
end
end
# DELETE /summon_series/:id
def destroy
if @summon_series.summons.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete series with associated summons',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@summon_series.destroy!
head :no_content
end
end
private
def set_summon_series
# Support lookup by slug or UUID
@summon_series = SummonSeries.find_by(slug: params[:id]) || SummonSeries.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[SUMMON_SERIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def summon_series_params
params.require(:summon_series).permit(:name_en, :name_jp, :slug, :order)
end
end
end
end

View file

@ -3,227 +3,16 @@
module Api
module V1
class SummonsController < Api::V1::ApiController
include IdResolvable
include BatchPreviewable
before_action :set
before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki]
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
# GET /summons/:id
def show
render json: SummonBlueprint.render(@summon, view: :full)
end
# POST /summons
# Creates a new summon record
def create
summon = Summon.new(summon_params)
if summon.save
render json: SummonBlueprint.render(summon, view: :full), status: :created
else
render_validation_error_response(summon)
end
end
# PATCH/PUT /summons/:id
# Updates an existing summon record
def update
if @summon.update(summon_params)
render json: SummonBlueprint.render(@summon, view: :full)
else
render_validation_error_response(@summon)
end
end
# GET /summons/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = SummonImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /summons/:id/download_image
# Synchronously downloads a single image for a summon
def download_image
size = params[:size]
transformation = params[:transformation]
force = params[:force] == true
# Validate size
valid_sizes = Granblue::Downloaders::SummonDownloader::SIZES
unless valid_sizes.include?(size)
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
end
# Validate transformation for summons (none, 02, 03, 04)
valid_transformations = [nil, '', '02', '03', '04']
if transformation.present? && !valid_transformations.include?(transformation)
return render json: { error: 'Invalid transformation. Must be one of: 02, 03, 04 (or empty for base)' }, status: :unprocessable_entity
end
# Build variant ID - summons don't have suffix for base
variant_id = transformation.present? ? "#{@summon.granblue_id}_#{transformation}" : @summon.granblue_id
begin
downloader = Granblue::Downloaders::SummonDownloader.new(
@summon.granblue_id,
storage: :s3,
force: force,
verbose: true
)
# Call the download_variant method directly for a single variant/size
downloader.send(:download_variant, variant_id, size)
render json: {
success: true,
summon_id: @summon.id,
granblue_id: @summon.granblue_id,
size: size,
transformation: transformation,
message: 'Image downloaded successfully'
}
rescue StandardError => e
Rails.logger.error "[SUMMONS] Image download error for #{@summon.id}: #{e.message}"
render json: { success: false, error: e.message }, status: :internal_server_error
end
end
# POST /summons/:id/download_images
# Triggers async image download for a summon
def download_images
# Queue the download job
DownloadSummonImagesJob.perform_later(
@summon.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadSummonImagesJob.update_status(
@summon.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
summon_id: @summon.id,
granblue_id: @summon.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /summons/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadSummonImagesJob.status(@summon.id)
render json: status.merge(
summon_id: @summon.id,
granblue_id: @summon.granblue_id
)
end
# GET /summons/:id/raw
# Returns raw wiki and game data for database viewing
def raw
render json: SummonBlueprint.render(@summon, view: :raw)
end
# POST /summons/batch_preview
# Fetches wiki data and suggestions for multiple wiki page names
def batch_preview
wiki_pages = params[:wiki_pages]
wiki_data = params[:wiki_data] || {}
unless wiki_pages.is_a?(Array) && wiki_pages.any?
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
end
# Limit to 10 pages
wiki_pages = wiki_pages.first(10)
results = wiki_pages.map do |wiki_page|
process_wiki_preview(wiki_page, :summon, wiki_raw: wiki_data[wiki_page])
end
render json: { results: results }
end
# POST /summons/:id/fetch_wiki
# Fetches and stores wiki data for this summon
def fetch_wiki
unless @summon.wiki_en.present?
return render json: { error: 'No wiki page configured for this summon' }, status: :unprocessable_entity
end
begin
wiki_text = Granblue::Parsers::Wiki.new.fetch(@summon.wiki_en)
# Handle redirects
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
@summon.update!(wiki_en: redirect_target)
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
end
@summon.update!(wiki_raw: wiki_text)
render json: SummonBlueprint.render(@summon, view: :raw)
rescue Granblue::WikiError => e
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
rescue StandardError => e
Rails.logger.error "[SUMMONS] Wiki fetch error for #{@summon.id}: #{e.message}"
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
end
render json: SummonBlueprint.render(@summon)
end
private
def set
@summon = find_by_any_id(Summon, params[:id])
render_not_found_response('summon') unless @summon
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[SUMMONS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def summon_params
params.require(:summon).permit(
:granblue_id, :name_en, :name_jp, :summon_id, :rarity, :element, :series,
:flb, :ulb, :transcendence, :subaura, :limit,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb, :max_hp_xlb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb, :max_atk_xlb,
:max_level,
:release_date, :flb_date, :ulb_date, :transcendence_date,
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
nicknames_en: [], nicknames_jp: [], promotions: []
)
@summon = Summon.where(granblue_id: params[:id]).first
end
end
end

View file

@ -5,9 +5,8 @@ module Api
class UsersController < Api::V1::ApiController
class ForbiddenError < StandardError; end
before_action :set, except: %w[create check_email check_username me]
before_action :set, except: %w[create check_email check_username]
before_action :set_by_id, only: %w[update]
before_action :doorkeeper_authorize!, only: %w[me]
MAX_CHARACTERS = 5
MAX_SUMMONS = 8
@ -52,12 +51,6 @@ module Api
render json: UserBlueprint.render(@user, view: :minimal)
end
# GET /users/me - returns current user's settings including email
# This endpoint is ONLY for authenticated users viewing their own settings
def me
render json: UserBlueprint.render(current_user, view: :settings)
end
def show
if @user.nil?
render_not_found_response('user')
@ -86,14 +79,13 @@ module Api
current_user: current_user,
options: { skip_privacy: skip_privacy }
).build
current_page_size = page_size
parties = query.paginate(page: params[:page], per_page: current_page_size)
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE)
count = query.count
render json: UserBlueprint.render(@user,
view: :profile,
root: 'profile',
parties: parties,
meta: { count: count, total_pages: (count.to_f / current_page_size).ceil, per_page: current_page_size },
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE },
current_user: current_user
)
end
@ -234,18 +226,13 @@ module Api
end
def set_by_id
if params[:id] == 'me'
@user = current_user
else
@user = User.find_by('id = ?', params[:id])
end
@user = User.find_by('id = ?', params[:id])
end
def user_params
params.require(:user).permit(
:username, :email, :password, :password_confirmation,
:granblue_id, :picture, :element, :language, :gender, :private, :theme, :show_gamertag,
:show_granblue_id, :collection_privacy
:granblue_id, :picture, :element, :language, :gender, :private, :theme
)
end
end

View file

@ -4,20 +4,17 @@ module Api
module V1
class WeaponKeysController < Api::V1::ApiController
def all
weapon_keys = WeaponKey.all
# Filter by series - support both new slug-based and legacy integer-based filtering
if request.params['series_slug'].present?
series = WeaponSeries.find_by(slug: request.params['series_slug'])
weapon_keys = weapon_keys.joins(:weapon_series).where(weapon_series: { id: series.id }) if series
elsif request.params['series'].present?
# Legacy integer support (will be deprecated)
weapon_keys = weapon_keys.where('? = ANY(series)', request.params['series'].to_i)
conditions = {}.tap do |hash|
hash[:series] = request.params['series'].to_i unless request.params['series'].blank?
hash[:slot] = request.params['slot'].to_i unless request.params['slot'].blank?
hash[:group] = request.params['group'].to_i unless request.params['group'].blank?
end
# Filter by slot and group
weapon_keys = weapon_keys.where(slot: request.params['slot'].to_i) if request.params['slot'].present?
weapon_keys = weapon_keys.where(group: request.params['group'].to_i) if request.params['group'].present?
# Build the query based on the conditions
weapon_keys = WeaponKey.all
weapon_keys = weapon_keys.where('? = ANY(series)', conditions[:series]) if conditions.key?(:series)
weapon_keys = weapon_keys.where(slot: conditions[:slot]) if conditions.key?(:slot)
weapon_keys = weapon_keys.where(group: conditions[:group]) if conditions.key?(:group)
render json: WeaponKeyBlueprint.render(weapon_keys)
end

View file

@ -1,76 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class WeaponSeriesController < Api::V1::ApiController
before_action :set_weapon_series, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /weapon_series
def index
weapon_series = WeaponSeries.ordered
render json: WeaponSeriesBlueprint.render(weapon_series)
end
# GET /weapon_series/:id
def show
render json: WeaponSeriesBlueprint.render(@weapon_series, view: :full)
end
# POST /weapon_series
def create
weapon_series = WeaponSeries.new(weapon_series_params)
if weapon_series.save
render json: WeaponSeriesBlueprint.render(weapon_series, view: :full), status: :created
else
render_validation_error_response(weapon_series)
end
end
# PATCH/PUT /weapon_series/:id
def update
if @weapon_series.update(weapon_series_params)
render json: WeaponSeriesBlueprint.render(@weapon_series, view: :full)
else
render_validation_error_response(@weapon_series)
end
end
# DELETE /weapon_series/:id
def destroy
if @weapon_series.weapons.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete series with associated weapons',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@weapon_series.destroy!
head :no_content
end
end
private
def set_weapon_series
# Support lookup by slug or UUID
@weapon_series = WeaponSeries.find_by(slug: params[:id]) || WeaponSeries.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[WEAPON_SERIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def weapon_series_params
params.require(:weapon_series).permit(
:name_en, :name_jp, :slug, :order,
:extra, :element_changeable, :has_weapon_keys,
:has_awakening, :augment_type
)
end
end
end
end

View file

@ -1,21 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class WeaponStatModifiersController < Api::V1::ApiController
# GET /weapon_stat_modifiers
def index
@modifiers = WeaponStatModifier.all
@modifiers = @modifiers.where(category: params[:category]) if params[:category].present?
render json: WeaponStatModifierBlueprint.render(@modifiers, root: :weapon_stat_modifiers)
end
# GET /weapon_stat_modifiers/:id
def show
@modifier = WeaponStatModifier.find(params[:id])
render json: WeaponStatModifierBlueprint.render(@modifier)
end
end
end
end

View file

@ -3,228 +3,16 @@
module Api
module V1
class WeaponsController < Api::V1::ApiController
include IdResolvable
include BatchPreviewable
before_action :set
before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki]
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
# GET /weapons/:id
def show
render json: WeaponBlueprint.render(@weapon, view: :full)
end
# POST /weapons
# Creates a new weapon record
def create
weapon = Weapon.new(weapon_params)
if weapon.save
render json: WeaponBlueprint.render(weapon, view: :full), status: :created
else
render_validation_error_response(weapon)
end
end
# PATCH/PUT /weapons/:id
# Updates an existing weapon record
def update
if @weapon.update(weapon_params)
render json: WeaponBlueprint.render(@weapon, view: :full)
else
render_validation_error_response(@weapon)
end
end
# GET /weapons/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = WeaponImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /weapons/:id/download_image
# Synchronously downloads a single image for a weapon
def download_image
size = params[:size]
transformation = params[:transformation]
force = params[:force] == true
# Validate size
valid_sizes = Granblue::Downloaders::WeaponDownloader::SIZES
unless valid_sizes.include?(size)
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
end
# Validate transformation for weapons (none, 02, 03)
valid_transformations = [nil, '', '02', '03']
if transformation.present? && !valid_transformations.include?(transformation)
return render json: { error: 'Invalid transformation. Must be one of: 02, 03 (or empty for base)' }, status: :unprocessable_entity
end
# Build variant ID - weapons don't have suffix for base
variant_id = transformation.present? ? "#{@weapon.granblue_id}_#{transformation}" : @weapon.granblue_id
begin
downloader = Granblue::Downloaders::WeaponDownloader.new(
@weapon.granblue_id,
storage: :s3,
force: force,
verbose: true
)
# Call the download_variant method directly for a single variant/size
downloader.send(:download_variant, variant_id, size)
render json: {
success: true,
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id,
size: size,
transformation: transformation,
message: 'Image downloaded successfully'
}
rescue StandardError => e
Rails.logger.error "[WEAPONS] Image download error for #{@weapon.id}: #{e.message}"
render json: { success: false, error: e.message }, status: :internal_server_error
end
end
# POST /weapons/:id/download_images
# Triggers async image download for a weapon
def download_images
# Queue the download job
DownloadWeaponImagesJob.perform_later(
@weapon.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadWeaponImagesJob.update_status(
@weapon.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /weapons/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadWeaponImagesJob.status(@weapon.id)
render json: status.merge(
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id
)
end
# GET /weapons/:id/raw
# Returns raw wiki and game data for database viewing
def raw
render json: WeaponBlueprint.render(@weapon, view: :raw)
end
# POST /weapons/batch_preview
# Fetches wiki data and suggestions for multiple wiki page names
def batch_preview
wiki_pages = params[:wiki_pages]
wiki_data = params[:wiki_data] || {}
unless wiki_pages.is_a?(Array) && wiki_pages.any?
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
end
# Limit to 10 pages
wiki_pages = wiki_pages.first(10)
results = wiki_pages.map do |wiki_page|
process_wiki_preview(wiki_page, :weapon, wiki_raw: wiki_data[wiki_page])
end
render json: { results: results }
end
# POST /weapons/:id/fetch_wiki
# Fetches and stores wiki data for this weapon
def fetch_wiki
unless @weapon.wiki_en.present?
return render json: { error: 'No wiki page configured for this weapon' }, status: :unprocessable_entity
end
begin
wiki_text = Granblue::Parsers::Wiki.new.fetch(@weapon.wiki_en)
# Handle redirects
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
@weapon.update!(wiki_en: redirect_target)
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
end
@weapon.update!(wiki_raw: wiki_text)
render json: WeaponBlueprint.render(@weapon, view: :raw)
rescue Granblue::WikiError => e
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
rescue StandardError => e
Rails.logger.error "[WEAPONS] Wiki fetch error for #{@weapon.id}: #{e.message}"
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
end
render json: WeaponBlueprint.render(@weapon)
end
private
def set
@weapon = find_by_any_id(Weapon, params[:id])
render_not_found_response('weapon') unless @weapon
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[WEAPONS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def weapon_params
params.require(:weapon).permit(
:granblue_id, :name_en, :name_jp, :rarity, :element, :proficiency, :series, :new_series,
:flb, :ulb, :transcendence, :extra, :extra_prerequisite, :limit, :ax, :gacha,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
:max_level, :max_skill_level, :max_awakening_level, :max_exorcism_level,
:release_date, :flb_date, :ulb_date, :transcendence_date,
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
:recruits, :forged_from, :forge_chain_id, :forge_order,
nicknames_en: [], nicknames_jp: [], promotions: []
)
@weapon = Weapon.where(granblue_id: params[:id]).first
end
end
end

View file

@ -1,98 +0,0 @@
# frozen_string_literal: true
# Provides batch wiki preview functionality for entity controllers
module BatchPreviewable
extend ActiveSupport::Concern
private
# Process a single wiki page and return preview data
# @param wiki_page [String] The wiki page name to fetch
# @param entity_type [Symbol] The type of entity (:character, :weapon, :summon)
# @param wiki_raw [String, nil] Pre-fetched wiki text (from client-side fetch)
# @return [Hash] Preview data including status, parsed_data, and errors
def process_wiki_preview(wiki_page, entity_type, wiki_raw: nil)
result = {
wiki_page: wiki_page,
status: 'success'
}
begin
# Use provided wiki_raw or fetch from wiki
wiki_text = if wiki_raw.present?
wiki_raw
else
wiki = Granblue::Parsers::Wiki.new
wiki.fetch(wiki_page)
end
# Handle redirects (only if we fetched server-side)
if wiki_raw.blank?
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
result[:redirected_from] = wiki_page
result[:wiki_page] = redirect_target
wiki_text = wiki.fetch(redirect_target)
end
end
result[:wiki_raw] = wiki_text
# Parse data from wiki text based on entity type
parsed_data = case entity_type
when :character
Granblue::Parsers::WikiDataParser.parse_character(wiki_text)
when :weapon
Granblue::Parsers::WikiDataParser.parse_weapon(wiki_text)
when :summon
Granblue::Parsers::WikiDataParser.parse_summon(wiki_text)
end
result[:granblue_id] = parsed_data[:granblue_id] if parsed_data[:granblue_id].present?
result[:parsed_data] = parsed_data
# Queue image download if we have a granblue_id
if parsed_data[:granblue_id].present?
result[:image_status] = queue_image_download(parsed_data[:granblue_id], entity_type)
else
result[:image_status] = 'no_id'
end
rescue Granblue::WikiError => e
result[:status] = 'error'
result[:error] = "Wiki page not found: #{e.message}"
rescue StandardError => e
Rails.logger.error "[BATCH_PREVIEW] Error processing #{wiki_page}: #{e.message}"
result[:status] = 'error'
result[:error] = "Failed to process wiki page: #{e.message}"
end
result
end
# Queue an image download job for the entity
# @param granblue_id [String] The granblue ID to download images for
# @param entity_type [Symbol] The type of entity
# @return [String] Status of the image download ('queued', 'skipped', 'error')
def queue_image_download(granblue_id, entity_type)
# Check if entity already exists in database
model_class = case entity_type
when :character then Character
when :weapon then Weapon
when :summon then Summon
end
existing = model_class.find_by(granblue_id: granblue_id)
if existing
# Entity exists, skip download (images likely already exist)
return 'exists'
end
# For now, we don't queue the download since the entity doesn't exist yet
# The image download will happen after the entity is created
'pending'
rescue StandardError => e
Rails.logger.error "[BATCH_PREVIEW] Error queueing image download: #{e.message}"
'error'
end
end

View file

@ -1,20 +0,0 @@
# frozen_string_literal: true
module CrewAuthorizationConcern
extend ActiveSupport::Concern
# Checks whether the current user is a member of the crew
def authorize_crew_member!
render_unauthorized_response unless current_user&.crew == @crew
end
# Checks whether the current user is an officer (captain or vice captain) of the crew
def authorize_crew_officer!
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_officer?
end
# Checks whether the current user is the captain of the crew
def authorize_crew_captain!
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_captain?
end
end

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
module IdResolvable
extend ActiveSupport::Concern
UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
private
def uuid_format?(id)
id.to_s.match?(UUID_REGEX)
end
def find_by_any_id(model_class, id)
return nil if id.blank?
if uuid_format?(id)
model_class.find_by(id: id)
else
model_class.find_by(granblue_id: id)
end
end
end

View file

@ -5,8 +5,6 @@ module PartyAuthorizationConcern
# Checks whether the current user (or provided edit key) is authorized to modify @party.
def authorize_party!
return render_not_found_response('party') unless @party
if @party.user.present?
render_unauthorized_response unless current_user.present? && @party.user == current_user
else

View file

@ -9,7 +9,7 @@ module PartyQueryingConcern
Party.includes(
{ raid: :group },
:job,
{ user: { active_crew_membership: :crew } },
:user,
:skill0,
:skill1,
:skill2,
@ -18,7 +18,7 @@ module PartyQueryingConcern
:guidebook2,
:guidebook3,
{ characters: :character },
{ weapons: { weapon: :weapon_series } },
{ weapons: :weapon },
{ summons: :summon }
)
end
@ -31,6 +31,21 @@ module PartyQueryingConcern
options: { apply_defaults: true }).build
end
# Renders paginated parties using PartyBlueprint.
def render_paginated_parties(parties)
render json: Api::V1::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
# Returns a remixed party name based on the current party name and current_user language.
def remixed_name(name)
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }

18
app/errors/WikiError.rb Normal file
View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class WikiError < StandardError
def initialize(code: nil, page: nil, message: nil)
super
@code = code
@page = page
@message = message
end
def to_hash
{
message: @message,
code: @code,
page: @page
}
end
end

View file

@ -1,15 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class InvalidPositionError < GranblueError
def code
'invalid_position'
end
def message
@data || 'Invalid position specified'
end
end
end
end

View file

@ -1,38 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PartyDeletionFailedError < StandardError
attr_reader :errors
def initialize(errors = [])
@errors = errors
super(message)
end
def http_status
422
end
def code
'party_deletion_failed'
end
def message
if @errors.any?
"Failed to delete party: #{@errors.join(', ')}"
else
'Failed to delete party due to an unknown error'
end
end
def to_hash
{
message: message,
code: code,
errors: @errors
}
end
end
end
end

View file

@ -1,15 +0,0 @@
# frozen_string_literal: true
module Api
module V1
class PositionOccupiedError < GranblueError
def code
'position_occupied'
end
def message
@data || 'Position is already occupied'
end
end
end
end

View file

@ -1,48 +0,0 @@
# frozen_string_literal: true
module CollectionErrors
# Base class for all collection-related errors
class CollectionError < StandardError
attr_reader :http_status, :code
def initialize(message = nil, http_status: :unprocessable_entity, code: nil)
super(message)
@http_status = http_status
@code = code || self.class.name.demodulize.underscore
end
def to_hash
{
error: {
type: self.class.name.demodulize,
message: message,
code: code
}
}
end
end
# Raised when a collection item cannot be found
class CollectionItemNotFound < CollectionError
def initialize(item_type = 'item', item_id = nil)
message = item_id ? "Collection #{item_type} with ID #{item_id} not found" : "Collection #{item_type} not found"
super(message, http_status: :not_found)
end
end
# Raised when trying to add a duplicate character to collection
class DuplicateCharacter < CollectionError
def initialize(character_id = nil)
message = character_id ? "Character #{character_id} already exists in your collection" : "Character already exists in your collection"
super(message, http_status: :conflict)
end
end
# Raised when trying to add a duplicate job accessory to collection
class DuplicateJobAccessory < CollectionError
def initialize(accessory_id = nil)
message = accessory_id ? "Job accessory #{accessory_id} already exists in your collection" : "Job accessory already exists in your collection"
super(message, http_status: :conflict)
end
end
end

View file

@ -1,203 +0,0 @@
# frozen_string_literal: true
module CrewErrors
# Base class for all crew-related errors
class CrewError < StandardError
def http_status
:unprocessable_entity
end
def code
self.class.name.demodulize.underscore
end
def to_hash
{
message: message,
code: code
}
end
end
class AlreadyInCrewError < CrewError
def http_status
:unprocessable_entity
end
def code
'already_in_crew'
end
def message
'You are already in a crew'
end
end
class CaptainCannotLeaveError < CrewError
def http_status
:unprocessable_entity
end
def code
'captain_cannot_leave'
end
def message
'Captain must transfer ownership before leaving'
end
end
class CannotRemoveCaptainError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_remove_captain'
end
def message
'Cannot remove the captain from the crew'
end
end
class ViceCaptainLimitError < CrewError
def http_status
:unprocessable_entity
end
def code
'vice_captain_limit'
end
def message
'Crew can only have up to 3 vice captains'
end
end
class NotInCrewError < CrewError
def http_status
:unprocessable_entity
end
def code
'not_in_crew'
end
def message
'You are not in a crew'
end
end
class MemberNotFoundError < CrewError
def http_status
:not_found
end
def code
'member_not_found'
end
def message
'Member not found in this crew'
end
end
class CannotDemoteCaptainError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_demote_captain'
end
def message
'Cannot demote the captain'
end
end
class InvitationExpiredError < CrewError
def http_status
:gone
end
def code
'invitation_expired'
end
def message
'This invitation has expired'
end
end
class InvitationNotFoundError < CrewError
def http_status
:not_found
end
def code
'invitation_not_found'
end
def message
'Invitation not found'
end
end
class CannotInviteSelfError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_invite_self'
end
def message
'You cannot invite yourself'
end
end
class UserAlreadyInvitedError < CrewError
def http_status
:conflict
end
def code
'user_already_invited'
end
def message
'User already has a pending invitation'
end
end
class NotClaimedByUserError < CrewError
def http_status
:forbidden
end
def code
'not_claimed_by_user'
end
def message
'This phantom player is not assigned to you'
end
end
class PhantomNotFoundError < CrewError
def http_status
:not_found
end
def code
'phantom_not_found'
end
def message
'Phantom player not found'
end
end
end

View file

@ -1,77 +0,0 @@
# frozen_string_literal: true
module PartyShareErrors
# Base class for all party share-related errors
class PartyShareError < StandardError
def http_status
:unprocessable_entity
end
def code
self.class.name.demodulize.underscore
end
def to_hash
{
message: message,
code: code
}
end
end
class NotInCrewError < PartyShareError
def http_status
:unprocessable_entity
end
def code
'not_in_crew'
end
def message
'You must be in a crew to share parties'
end
end
class NotPartyOwnerError < PartyShareError
def http_status
:forbidden
end
def code
'not_party_owner'
end
def message
'Only the party owner can share this party'
end
end
class AlreadySharedError < PartyShareError
def http_status
:conflict
end
def code
'already_shared'
end
def message
'This party is already shared with this crew'
end
end
class CanOnlyShareToOwnCrewError < PartyShareError
def http_status
:forbidden
end
def code
'can_only_share_to_own_crew'
end
def message
'You can only share parties with your own crew'
end
end
end

View file

@ -1,87 +0,0 @@
# frozen_string_literal: true
# Background job for downloading artifact images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadArtifactImagesJob.perform_later(artifact.id)
# # Poll status with: DownloadArtifactImagesJob.status(artifact.id)
class DownloadArtifactImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
artifact_id = job.arguments.first
Rails.logger.error "[DownloadArtifactImages] Artifact #{artifact_id} not found"
update_status(artifact_id, 'failed', error: 'Artifact not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'artifact_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for an artifact
#
# @param artifact_id [String] UUID of the artifact
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(artifact_id)
data = redis.get(redis_key(artifact_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(artifact_id)
"#{REDIS_KEY_PREFIX}:#{artifact_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(artifact_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(artifact_id), STATUS_TTL, data.to_json)
end
end
def perform(artifact_id, force: false, size: 'all')
Rails.logger.info "[DownloadArtifactImages] Starting download for artifact #{artifact_id}"
artifact = Artifact.find(artifact_id)
update_status(artifact_id, 'processing', progress: 0, images_downloaded: 0)
service = ArtifactImageDownloadService.new(
artifact,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadArtifactImages] Completed for artifact #{artifact_id}"
update_status(
artifact_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadArtifactImages] Failed for artifact #{artifact_id}: #{result.error}"
update_status(artifact_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(artifact_id, status, **attrs)
self.class.update_status(artifact_id, status, **attrs)
end
end

View file

@ -1,87 +0,0 @@
# frozen_string_literal: true
# Background job for downloading character images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadCharacterImagesJob.perform_later(character.id)
# # Poll status with: DownloadCharacterImagesJob.status(character.id)
class DownloadCharacterImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
character_id = job.arguments.first
Rails.logger.error "[DownloadCharacterImages] Character #{character_id} not found"
update_status(character_id, 'failed', error: 'Character not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'character_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a character
#
# @param character_id [String] UUID of the character
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(character_id)
data = redis.get(redis_key(character_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(character_id)
"#{REDIS_KEY_PREFIX}:#{character_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(character_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(character_id), STATUS_TTL, data.to_json)
end
end
def perform(character_id, force: false, size: 'all')
Rails.logger.info "[DownloadCharacterImages] Starting download for character #{character_id}"
character = Character.find(character_id)
update_status(character_id, 'processing', progress: 0, images_downloaded: 0)
service = CharacterImageDownloadService.new(
character,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadCharacterImages] Completed for character #{character_id}"
update_status(
character_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadCharacterImages] Failed for character #{character_id}: #{result.error}"
update_status(character_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(character_id, status, **attrs)
self.class.update_status(character_id, status, **attrs)
end
end

View file

@ -1,87 +0,0 @@
# frozen_string_literal: true
# Background job for downloading summon images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadSummonImagesJob.perform_later(summon.id)
# # Poll status with: DownloadSummonImagesJob.status(summon.id)
class DownloadSummonImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
summon_id = job.arguments.first
Rails.logger.error "[DownloadSummonImages] Summon #{summon_id} not found"
update_status(summon_id, 'failed', error: 'Summon not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'summon_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a summon
#
# @param summon_id [String] UUID of the summon
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(summon_id)
data = redis.get(redis_key(summon_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(summon_id)
"#{REDIS_KEY_PREFIX}:#{summon_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(summon_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(summon_id), STATUS_TTL, data.to_json)
end
end
def perform(summon_id, force: false, size: 'all')
Rails.logger.info "[DownloadSummonImages] Starting download for summon #{summon_id}"
summon = Summon.find(summon_id)
update_status(summon_id, 'processing', progress: 0, images_downloaded: 0)
service = SummonImageDownloadService.new(
summon,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadSummonImages] Completed for summon #{summon_id}"
update_status(
summon_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadSummonImages] Failed for summon #{summon_id}: #{result.error}"
update_status(summon_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(summon_id, status, **attrs)
self.class.update_status(summon_id, status, **attrs)
end
end

View file

@ -1,87 +0,0 @@
# frozen_string_literal: true
# Background job for downloading weapon images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadWeaponImagesJob.perform_later(weapon.id)
# # Poll status with: DownloadWeaponImagesJob.status(weapon.id)
class DownloadWeaponImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
weapon_id = job.arguments.first
Rails.logger.error "[DownloadWeaponImages] Weapon #{weapon_id} not found"
update_status(weapon_id, 'failed', error: 'Weapon not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'weapon_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a weapon
#
# @param weapon_id [String] UUID of the weapon
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(weapon_id)
data = redis.get(redis_key(weapon_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(weapon_id)
"#{REDIS_KEY_PREFIX}:#{weapon_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(weapon_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(weapon_id), STATUS_TTL, data.to_json)
end
end
def perform(weapon_id, force: false, size: 'all')
Rails.logger.info "[DownloadWeaponImages] Starting download for weapon #{weapon_id}"
weapon = Weapon.find(weapon_id)
update_status(weapon_id, 'processing', progress: 0, images_downloaded: 0)
service = WeaponImageDownloadService.new(
weapon,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadWeaponImages] Completed for weapon #{weapon_id}"
update_status(
weapon_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadWeaponImages] Failed for weapon #{weapon_id}: #{result.error}"
update_status(weapon_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(weapon_id, status, **attrs)
self.class.update_status(weapon_id, status, **attrs)
end
end

View file

@ -1,36 +0,0 @@
# frozen_string_literal: true
class Artifact < ApplicationRecord
# Enums - using GranblueEnums::PROFICIENCY values (excluding None: 0)
# Sabre: 1, Dagger: 2, Axe: 3, Spear: 4, Bow: 5, Staff: 6, Melee: 7, Harp: 8, Gun: 9, Katana: 10
enum :proficiency, {
sabre: 1,
dagger: 2,
axe: 3,
spear: 4,
bow: 5,
staff: 6,
melee: 7,
harp: 8,
gun: 9,
katana: 10
}
enum :rarity, { standard: 0, quirk: 1 }
# Associations
has_many :collection_artifacts, dependent: :restrict_with_error
has_many :grid_artifacts, dependent: :restrict_with_error
# Validations
validates :granblue_id, presence: true, uniqueness: true
validates :name_en, presence: true
validates :proficiency, presence: true, if: :standard?
validates :proficiency, absence: true, if: :quirk?
validates :rarity, presence: true
# Scopes
scope :standard_artifacts, -> { where(rarity: :standard) }
scope :quirk_artifacts, -> { where(rarity: :quirk) }
scope :by_proficiency, ->(prof) { where(proficiency: prof) }
end

View file

@ -1,103 +0,0 @@
# frozen_string_literal: true
class ArtifactSkill < ApplicationRecord
# Enums
enum :skill_group, { group_i: 1, group_ii: 2, group_iii: 3 }
enum :polarity, { positive: 'positive', negative: 'negative' }
# Validations
validates :skill_group, presence: true
validates :modifier, presence: true, uniqueness: { scope: :skill_group }
validates :name_en, presence: true
validates :name_jp, presence: true
validates :polarity, presence: true
# Scopes
scope :for_slot, ->(slot) {
case slot
when 1, 2 then group_i
when 3 then group_ii
when 4 then group_iii
end
}
# Class methods for caching skill lookups
class << self
def cached_skills
@cached_skills ||= all.index_by { |s| [s.skill_group, s.modifier] }
end
def cached_by_game_name
@game_name_cache ||= begin
cache = {}
all.each do |skill|
# Use game names for matching, fall back to display names if not set
en_key = skill.game_name_en.presence || skill.name_en
jp_key = skill.game_name_jp.presence || skill.name_jp
cache[en_key] = skill
cache[jp_key] = skill
end
cache
end
end
def find_skill(group, modifier)
# Convert group number to enum key
group_key = case group
when 1 then 'group_i'
when 2 then 'group_ii'
when 3 then 'group_iii'
else group.to_s
end
cached_skills[[group_key, modifier]]
end
def find_by_game_name(name)
cached_by_game_name[name]
end
def clear_cache!
@cached_skills = nil
@game_name_cache = nil
end
end
# Calculate the current value of a skill given base strength and skill level
# @param base_strength [Numeric] The base strength value of the skill
# @param skill_level [Integer] The current skill level (1-5)
# @return [Numeric, nil] The calculated value
def calculate_value(base_strength, skill_level)
return base_strength if growth.nil?
base_strength + (growth * (skill_level - 1))
end
# Format a value with the appropriate suffix
# @param value [Numeric] The value to format
# @param locale [Symbol] :en or :jp
# @return [String] The formatted value with suffix
def format_value(value, locale = :en)
suffix = locale == :jp ? suffix_jp : suffix_en
"#{value}#{suffix}"
end
# Check if a strength value is valid for this skill
# @param strength [Numeric] The strength value to validate
# @return [Boolean]
def valid_strength?(strength)
return true if base_values.include?(nil) # Unknown values are always valid
base_values.include?(strength)
end
# Get the base strength value for a given quality tier
# @param quality [Integer] The quality tier (1-5)
# @return [Numeric, nil] The base strength value
def strength_for_quality(quality)
return nil if base_values.nil? || !base_values.is_a?(Array) || base_values.empty?
# Quality 1-5 maps to index 0-4
index = (quality - 1).clamp(0, base_values.size - 1)
base_values[index]
end
end

View file

@ -3,9 +3,6 @@
class Character < ApplicationRecord
include PgSearch::Model
has_many :character_series_memberships, dependent: :destroy
has_many :character_series_records, through: :character_series_memberships, source: :character_series
multisearchable against: %i[name_en name_jp],
additional_attributes: lambda { |character|
{
@ -44,20 +41,6 @@ class Character < ApplicationRecord
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
].freeze
# Validations
validates :season,
numericality: { only_integer: true },
inclusion: { in: GranblueEnums::CHARACTER_SEASONS.values },
allow_nil: true
validate :validate_series_values
# Scopes
scope :by_season, ->(season) { where(season: season) }
scope :by_series, ->(series) { where('? = ANY(series)', series) }
scope :seasonal, -> { where.not(season: [nil, GranblueEnums::CHARACTER_SEASONS[:Standard]]) }
def blueprint
CharacterBlueprint
end
@ -65,93 +48,4 @@ class Character < ApplicationRecord
def display_resource(character)
character.name_en
end
# Helper methods
def seasonal?
season.present? && season != GranblueEnums::CHARACTER_SEASONS[:Standard]
end
def season_name
return nil if season.nil?
GranblueEnums::CHARACTER_SEASONS.key(season)&.to_s
end
def series_names
# Use new lookup table if available
if character_series_records.loaded? ? character_series_records.any? : character_series_records.exists?
character_series_records.ordered.pluck(:name_en)
elsif series.present?
# Legacy fallback
series.filter_map { |s| GranblueEnums::CHARACTER_SERIES.key(s)&.to_s }
else
[]
end
end
def series_objects
character_series_records.ordered
end
def series_slugs
character_series_records.pluck(:slug)
end
# Mapping from legacy integer values to slugs
LEGACY_SERIES_TO_SLUG = {
1 => 'grand',
2 => 'zodiac',
3 => 'promo',
4 => 'collab',
5 => 'eternal',
6 => 'evoker',
7 => 'saint',
8 => 'fantasy',
9 => 'summer',
10 => 'yukata',
11 => 'valentine',
12 => 'halloween',
13 => 'formal',
14 => 'holiday',
15 => 'event'
}.freeze
# Virtual attribute to set character_series by array of IDs, slugs, or legacy integers
# Supports multiple formats for flexibility during migration
def series=(values)
return if values.blank?
# Ensure it's an array
values = Array(values)
values.each do |value|
next if value.blank?
# Try to find the series record
series_record = if value.is_a?(Integer)
# Legacy integer - convert to slug first
slug = LEGACY_SERIES_TO_SLUG[value]
slug ? CharacterSeries.find_by(slug: slug) : nil
else
# String - try UUID first, then slug
CharacterSeries.find_by(id: value) || CharacterSeries.find_by(slug: value)
end
next unless series_record
# Create membership if it doesn't exist
character_series_memberships.find_or_initialize_by(character_series: series_record)
end
end
private
def validate_series_values
return if series.blank?
invalid_values = series.reject { |s| GranblueEnums::CHARACTER_SERIES.values.include?(s) }
return if invalid_values.empty?
errors.add(:series, "contains invalid values: #{invalid_values.join(', ')}")
end
end

View file

@ -1,24 +0,0 @@
# frozen_string_literal: true
class CharacterSeries < ApplicationRecord
has_many :character_series_memberships, dependent: :destroy
has_many :characters, through: :character_series_memberships
validates :name_en, presence: true
validates :name_jp, presence: true
validates :slug, presence: true, uniqueness: true
validates :order, numericality: { only_integer: true }
scope :ordered, -> { order(:order) }
# Slug constants for commonly referenced series
GRAND = 'grand'
ZODIAC = 'zodiac'
ETERNAL = 'eternal'
EVOKER = 'evoker'
SAINT = 'saint'
def blueprint
CharacterSeriesBlueprint
end
end

View file

@ -1,8 +0,0 @@
# frozen_string_literal: true
class CharacterSeriesMembership < ApplicationRecord
belongs_to :character
belongs_to :character_series
validates :character_id, uniqueness: { scope: :character_series_id }
end

View file

@ -1,92 +0,0 @@
# frozen_string_literal: true
class CollectionArtifact < ApplicationRecord
include ArtifactSkillValidations
# Associations
belongs_to :user
belongs_to :artifact
has_many :grid_artifacts, dependent: :nullify
before_destroy :orphan_grid_items
# Enums - using GranblueEnums::ELEMENTS values (excluding Null)
# Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6
enum :element, {
wind: 1,
fire: 2,
water: 3,
earth: 4,
dark: 5,
light: 6
}
# Proficiency enum - only used for quirk artifacts (game assigns random proficiency)
enum :proficiency, {
sabre: 1,
dagger: 2,
axe: 3,
spear: 4,
bow: 5,
staff: 6,
melee: 7,
harp: 8,
gun: 9,
katana: 10
}
# Validations
validates :element, presence: true
validates :level, presence: true, inclusion: { in: 1..5 }
validates :nickname, length: { maximum: 50 }, allow_blank: true
validates :proficiency, presence: true, if: :quirk_artifact?
validates :proficiency, absence: true, unless: :quirk_artifact?
validates :reroll_slot, inclusion: { in: 1..4 }, allow_nil: true
# Scopes
scope :by_element, ->(el) { where(element: el) }
scope :by_artifact, ->(artifact_id) { where(artifact_id: artifact_id) }
# Filter by proficiency - handles both quirk (instance) and standard (artifact) proficiencies
scope :by_proficiency, ->(prof) {
joins(:artifact).where(
'collection_artifacts.proficiency IN (?) OR (collection_artifacts.proficiency IS NULL AND artifacts.proficiency IN (?))',
Array(prof), Array(prof)
)
}
scope :by_rarity, ->(rar) { joins(:artifact).where(artifacts: { rarity: rar }) }
scope :standard_only, -> { joins(:artifact).where(artifacts: { rarity: :standard }) }
scope :quirk_only, -> { joins(:artifact).where(artifacts: { rarity: :quirk }) }
# Filter by skill modifier in a specific slot (1-4)
# Uses OR logic when multiple modifiers are provided
scope :with_skill_in_slot, ->(slot, modifiers) {
return all if modifiers.blank?
modifiers = Array(modifiers).map(&:to_s)
column = "skill#{slot}"
# Build OR conditions for multiple modifiers
conditions = modifiers.map { |_| "#{column}->>'modifier' = ?" }.join(' OR ')
where(conditions, *modifiers)
}
# Returns the effective proficiency - from instance for quirk, from artifact for standard
def effective_proficiency
quirk_artifact? ? proficiency : artifact&.proficiency
end
private
def quirk_artifact?
artifact&.quirk?
end
##
# Marks all linked grid artifacts as orphaned before destroying this collection artifact.
#
# @return [void]
def orphan_grid_items
grid_artifacts.update_all(orphaned: true, collection_artifact_id: nil)
end
end

Some files were not shown because too many files have changed in this diff Show more