From a6b7e26210cfaeb91745b0e36c99ede82057f885 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 23 Dec 2025 22:44:35 -0800 Subject: [PATCH] collection sync with orphan handling (#200) - preview_sync endpoint shows what'll get deleted before you commit - import services handle reconciliation (find missing items, delete them) - grid items get flagged as orphaned when their collection source is gone - party exposes has_orphaned_items - blueprints include orphaned field --- .../api/v1/grid_artifact_blueprint.rb | 2 +- .../api/v1/grid_summon_blueprint.rb | 2 +- .../api/v1/grid_weapon_blueprint.rb | 2 +- app/blueprints/api/v1/party_blueprint.rb | 4 + .../api/v1/collection_artifacts_controller.rb | 43 +++++++++- .../api/v1/collection_summons_controller.rb | 43 +++++++++- .../api/v1/collection_weapons_controller.rb | 43 +++++++++- app/models/collection_artifact.rb | 12 +++ app/models/collection_summon.rb | 12 +++ app/models/collection_weapon.rb | 12 +++ app/models/grid_artifact.rb | 12 +++ app/models/grid_summon.rb | 12 +++ app/models/grid_weapon.rb | 12 +++ app/models/party.rb | 13 +++ app/services/artifact_import_service.rb | 82 ++++++++++++++++++- app/services/summon_import_service.rb | 82 ++++++++++++++++++- app/services/weapon_import_service.rb | 82 ++++++++++++++++++- config/routes.rb | 3 + ...251223000000_add_orphaned_to_grid_items.rb | 13 +++ 19 files changed, 465 insertions(+), 21 deletions(-) create mode 100644 db/migrate/20251223000000_add_orphaned_to_grid_items.rb diff --git a/app/blueprints/api/v1/grid_artifact_blueprint.rb b/app/blueprints/api/v1/grid_artifact_blueprint.rb index abe8216..c6ba3f0 100644 --- a/app/blueprints/api/v1/grid_artifact_blueprint.rb +++ b/app/blueprints/api/v1/grid_artifact_blueprint.rb @@ -3,7 +3,7 @@ module Api module V1 class GridArtifactBlueprint < ApiBlueprint - fields :level, :reroll_slot + fields :level, :reroll_slot, :orphaned field :collection_artifact_id field :out_of_sync, if: ->(_field, ga, _options) { ga.collection_artifact_id.present? } do |ga| diff --git a/app/blueprints/api/v1/grid_summon_blueprint.rb b/app/blueprints/api/v1/grid_summon_blueprint.rb index 327fbe5..4f9772c 100644 --- a/app/blueprints/api/v1/grid_summon_blueprint.rb +++ b/app/blueprints/api/v1/grid_summon_blueprint.rb @@ -3,7 +3,7 @@ module Api module V1 class GridSummonBlueprint < ApiBlueprint - fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step + 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| diff --git a/app/blueprints/api/v1/grid_weapon_blueprint.rb b/app/blueprints/api/v1/grid_weapon_blueprint.rb index a754059..8e5540a 100644 --- a/app/blueprints/api/v1/grid_weapon_blueprint.rb +++ b/app/blueprints/api/v1/grid_weapon_blueprint.rb @@ -3,7 +3,7 @@ module Api module V1 class GridWeaponBlueprint < ApiBlueprint - fields :mainhand, :position, :uncap_level, :transcendence_step, :element + 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| diff --git a/app/blueprints/api/v1/party_blueprint.rb b/app/blueprints/api/v1/party_blueprint.rb index 5a070d0..395b699 100644 --- a/app/blueprints/api/v1/party_blueprint.rb +++ b/app/blueprints/api/v1/party_blueprint.rb @@ -36,6 +36,10 @@ module Api end end + field :has_orphaned_items do |party| + party.has_orphaned_items? + end + # For collection views view :preview do include_view :preview_objects # Characters, Weapons, Summons diff --git a/app/controllers/api/v1/collection_artifacts_controller.rb b/app/controllers/api/v1/collection_artifacts_controller.rb index a1e0283..5197f50 100644 --- a/app/controllers/api/v1/collection_artifacts_controller.rb +++ b/app/controllers/api/v1/collection_artifacts_controller.rb @@ -9,7 +9,7 @@ module Api 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] + 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 @@ -109,6 +109,8 @@ module Api # # @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] @@ -119,7 +121,9 @@ module Api service = ArtifactImportService.new( current_user, game_data, - update_existing: import_params[:update_existing] == true + update_existing: import_params[:update_existing] == true, + is_full_inventory: import_params[:is_full_inventory] == true, + reconcile_deletions: import_params[:reconcile_deletions] == true ) result = service.import @@ -131,10 +135,41 @@ module Api created: result.created&.size || 0, updated: result.updated&.size || 0, skipped: result.skipped&.size || 0, - errors: result.errors || [] + 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] + + unless game_data.present? + return render json: { error: 'No data provided' }, status: :bad_request + end + + service = ArtifactImportService.new(current_user, game_data) + 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 @@ -197,6 +232,8 @@ module Api 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 } end diff --git a/app/controllers/api/v1/collection_summons_controller.rb b/app/controllers/api/v1/collection_summons_controller.rb index 7622517..d791a64 100644 --- a/app/controllers/api/v1/collection_summons_controller.rb +++ b/app/controllers/api/v1/collection_summons_controller.rb @@ -7,7 +7,7 @@ module Api 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] + 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 @@ -113,6 +113,8 @@ module Api # # @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] @@ -123,7 +125,9 @@ module Api service = SummonImportService.new( current_user, game_data, - update_existing: import_params[:update_existing] == true + update_existing: import_params[:update_existing] == true, + is_full_inventory: import_params[:is_full_inventory] == true, + reconcile_deletions: import_params[:reconcile_deletions] == true ) result = service.import @@ -135,10 +139,41 @@ module Api created: result.created.size, updated: result.updated.size, skipped: result.skipped.size, - errors: result.errors + 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] + + unless game_data.present? + return render json: { error: 'No data provided' }, status: :bad_request + end + + service = SummonImportService.new(current_user, game_data) + 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 @@ -181,6 +216,8 @@ module Api 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 } end diff --git a/app/controllers/api/v1/collection_weapons_controller.rb b/app/controllers/api/v1/collection_weapons_controller.rb index ea8a75f..74790a1 100644 --- a/app/controllers/api/v1/collection_weapons_controller.rb +++ b/app/controllers/api/v1/collection_weapons_controller.rb @@ -7,7 +7,7 @@ module Api 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] + 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 @@ -119,6 +119,8 @@ module Api # # @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] @@ -129,7 +131,9 @@ module Api service = WeaponImportService.new( current_user, game_data, - update_existing: import_params[:update_existing] == true + update_existing: import_params[:update_existing] == true, + is_full_inventory: import_params[:is_full_inventory] == true, + reconcile_deletions: import_params[:reconcile_deletions] == true ) result = service.import @@ -141,10 +145,41 @@ module Api created: result.created.size, updated: result.updated.size, skipped: result.skipped.size, - errors: result.errors + 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] + + unless game_data.present? + return render json: { error: 'No data provided' }, status: :bad_request + end + + service = WeaponImportService.new(current_user, game_data) + 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 @@ -195,6 +230,8 @@ module Api 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 } end diff --git a/app/models/collection_artifact.rb b/app/models/collection_artifact.rb index 2163a2f..d61c150 100644 --- a/app/models/collection_artifact.rb +++ b/app/models/collection_artifact.rb @@ -7,6 +7,10 @@ class CollectionArtifact < ApplicationRecord 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, { @@ -77,4 +81,12 @@ class CollectionArtifact < ApplicationRecord 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 diff --git a/app/models/collection_summon.rb b/app/models/collection_summon.rb index 1507b95..0a6ea05 100644 --- a/app/models/collection_summon.rb +++ b/app/models/collection_summon.rb @@ -2,6 +2,10 @@ class CollectionSummon < ApplicationRecord belongs_to :user belongs_to :summon + has_many :grid_summons, dependent: :nullify + + before_destroy :orphan_grid_items + validates :uncap_level, inclusion: { in: 0..5 } validates :transcendence_step, inclusion: { in: 0..10 } @@ -31,4 +35,12 @@ class CollectionSummon < ApplicationRecord errors.add(:transcendence_step, "not available for this summon") end end + + ## + # Marks all linked grid summons as orphaned before destroying this collection summon. + # + # @return [void] + def orphan_grid_items + grid_summons.update_all(orphaned: true, collection_summon_id: nil) + end end \ No newline at end of file diff --git a/app/models/collection_weapon.rb b/app/models/collection_weapon.rb index a9c97e9..f369fad 100644 --- a/app/models/collection_weapon.rb +++ b/app/models/collection_weapon.rb @@ -8,6 +8,10 @@ class CollectionWeapon < ApplicationRecord belongs_to :weapon_key3, class_name: 'WeaponKey', optional: true belongs_to :weapon_key4, class_name: 'WeaponKey', optional: true + has_many :grid_weapons, dependent: :nullify + + before_destroy :orphan_grid_items + # Set defaults before validation so database defaults don't cause validation failures attribute :awakening_level, :integer, default: 1 @@ -129,4 +133,12 @@ class CollectionWeapon < ApplicationRecord errors.add(:transcendence_step, "not available for this weapon") if transcendence_step > 0 end end + + ## + # Marks all linked grid weapons as orphaned before destroying this collection weapon. + # + # @return [void] + def orphan_grid_items + grid_weapons.update_all(orphaned: true, collection_weapon_id: nil) + end end \ No newline at end of file diff --git a/app/models/grid_artifact.rb b/app/models/grid_artifact.rb index 156ca23..b0522a5 100644 --- a/app/models/grid_artifact.rb +++ b/app/models/grid_artifact.rb @@ -11,6 +11,10 @@ class GridArtifact < ApplicationRecord has_one :party, through: :grid_character has_one :character, through: :grid_character + # Orphan status scopes + scope :orphaned, -> { where(orphaned: true) } + scope :not_orphaned, -> { where(orphaned: false) } + # Enums - using GranblueEnums::ELEMENTS values (excluding Null) # Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6 enum :element, { @@ -55,6 +59,14 @@ class GridArtifact < ApplicationRecord quirk_artifact? ? proficiency : artifact&.proficiency end + ## + # Marks this grid artifact as orphaned and clears its collection link. + # + # @return [Boolean] true if the update succeeded + def mark_orphaned! + update!(orphaned: true, collection_artifact_id: nil) + end + ## # Syncs customizations from the linked collection artifact. # diff --git a/app/models/grid_summon.rb b/app/models/grid_summon.rb index 596a7ed..7b62f73 100644 --- a/app/models/grid_summon.rb +++ b/app/models/grid_summon.rb @@ -19,6 +19,10 @@ class GridSummon < ApplicationRecord belongs_to :collection_summon, optional: true validates_presence_of :party + # Orphan status scopes + scope :orphaned, -> { where(orphaned: true) } + scope :not_orphaned, -> { where(orphaned: false) } + # Validate that position is provided. validates :position, presence: true validate :compatible_with_position, on: :create @@ -40,6 +44,14 @@ class GridSummon < ApplicationRecord GridSummonBlueprint end + ## + # Marks this grid summon as orphaned and clears its collection link. + # + # @return [Boolean] true if the update succeeded + def mark_orphaned! + update!(orphaned: true, collection_summon_id: nil) + end + ## # Syncs customizations from the linked collection summon. # diff --git a/app/models/grid_weapon.rb b/app/models/grid_weapon.rb index e9bc7a4..b4f1164 100644 --- a/app/models/grid_weapon.rb +++ b/app/models/grid_weapon.rb @@ -39,6 +39,10 @@ class GridWeapon < ApplicationRecord belongs_to :awakening, optional: true belongs_to :collection_weapon, optional: true + # Orphan status scopes + scope :orphaned, -> { where(orphaned: true) } + scope :not_orphaned, -> { where(orphaned: false) } + # Validate that uncap_level is present and numeric, transcendence_step is optional but must be numeric if present. validates :uncap_level, presence: true, numericality: { only_integer: true } validates :transcendence_step, numericality: { only_integer: true }, allow_nil: true @@ -90,6 +94,14 @@ class GridWeapon < ApplicationRecord true end + ## + # Marks this grid weapon as orphaned and clears its collection link. + # + # @return [Boolean] true if the update succeeded + def mark_orphaned! + update!(orphaned: true, collection_weapon_id: nil) + end + ## # Checks if grid weapon is out of sync with collection. # diff --git a/app/models/party.rb b/app/models/party.rb index 3cb3b99..17780e6 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -274,6 +274,19 @@ class Party < ApplicationRecord end end + ## + # Checks if the party has any orphaned grid items. + # + # An orphaned item is one whose linked collection item has been deleted, + # indicating the user no longer has that item in their game inventory. + # + # @return [Boolean] true if the party has orphaned weapons, summons, or artifacts. + def has_orphaned_items? + weapons.orphaned.exists? || + summons.orphaned.exists? || + characters.joins(:grid_artifact).where(grid_artifacts: { orphaned: true }).exists? + end + ## # Determines if the party meets the minimum requirements for preview generation. # diff --git a/app/services/artifact_import_service.rb b/app/services/artifact_import_service.rb index 594151a..1397e26 100644 --- a/app/services/artifact_import_service.rb +++ b/app/services/artifact_import_service.rb @@ -12,7 +12,7 @@ # end # class ArtifactImportService - Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) + Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true) # Game element values to our element enum values # Game: 1=Fire, 2=Water, 3=Earth, 4=Wind, 5=Light, 6=Dark @@ -30,10 +30,38 @@ class ArtifactImportService @user = user @game_data = game_data @update_existing = options[:update_existing] || false + @is_full_inventory = options[:is_full_inventory] || false + @reconcile_deletions = options[:reconcile_deletions] || false @created = [] @updated = [] @skipped = [] @errors = [] + @processed_game_ids = [] + end + + ## + # Previews what would be deleted in a sync operation. + # Does not modify any data, just returns items that would be removed. + # + # @return [Array] Collection artifacts that would be deleted + def preview_deletions + items = extract_items + return [] if items.empty? + + # Extract all game_ids from the import data + # Artifacts use 'id' directly (not nested in 'param') + game_ids = items.filter_map do |item| + data = item.is_a?(Hash) ? item.with_indifferent_access : item + data['id'].to_s if data['id'].present? + end + + return [] if game_ids.empty? + + # Find collection artifacts with game_ids NOT in the import + @user.collection_artifacts + .includes(:artifact) + .where.not(game_id: nil) + .where.not(game_id: game_ids) end ## @@ -43,7 +71,14 @@ class ArtifactImportService def import items = extract_items if items.empty? - return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No artifact items found in data']) + return Result.new( + success?: false, + created: [], + updated: [], + skipped: [], + errors: ['No artifact items found in data'], + reconciliation: nil + ) end # Preload artifacts and existing collection artifacts to avoid N+1 queries @@ -58,12 +93,19 @@ class ArtifactImportService end end + # Handle deletion reconciliation if requested + reconciliation_result = nil + if @reconcile_deletions && @is_full_inventory && @processed_game_ids.any? + reconciliation_result = reconcile_deletions + end + Result.new( success?: @errors.empty?, created: @created, updated: @updated, skipped: @skipped, - errors: @errors + errors: @errors, + reconciliation: reconciliation_result ) end @@ -92,6 +134,10 @@ class ArtifactImportService # Handle both string and symbol keys from params data = item.is_a?(Hash) ? item.with_indifferent_access : item + # Track this game_id as processed (for reconciliation) + game_id = data['id'] + @processed_game_ids << game_id.to_s if game_id.present? + artifact = find_artifact(data['artifact_id']) unless artifact @errors << { game_id: data['id'], artifact_id: data['artifact_id'], error: 'Artifact not found' } @@ -193,4 +239,34 @@ class ArtifactImportService 'level' => level.to_i } end + + ## + # Reconciles deletions by removing collection artifacts not in the processed list. + # Only called when @is_full_inventory and @reconcile_deletions are both true. + # + # @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs + def reconcile_deletions + # Find collection artifacts with game_ids NOT in our processed list + missing = @user.collection_artifacts + .where.not(game_id: nil) + .where.not(game_id: @processed_game_ids) + + deleted_count = 0 + orphaned_grid_item_ids = [] + + missing.find_each do |coll_artifact| + # Collect IDs of grid items that will be orphaned + grid_artifact_ids = GridArtifact.where(collection_artifact_id: coll_artifact.id).pluck(:id) + orphaned_grid_item_ids.concat(grid_artifact_ids) + + # The before_destroy callback on CollectionArtifact will mark grid items as orphaned + coll_artifact.destroy + deleted_count += 1 + end + + { + deleted: deleted_count, + orphaned_grid_items: orphaned_grid_item_ids + } + end end diff --git a/app/services/summon_import_service.rb b/app/services/summon_import_service.rb index 1e4a83f..3e5c3a8 100644 --- a/app/services/summon_import_service.rb +++ b/app/services/summon_import_service.rb @@ -12,16 +12,43 @@ # end # class SummonImportService - Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) + Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true) def initialize(user, game_data, options = {}) @user = user @game_data = game_data @update_existing = options[:update_existing] || false + @is_full_inventory = options[:is_full_inventory] || false + @reconcile_deletions = options[:reconcile_deletions] || false @created = [] @updated = [] @skipped = [] @errors = [] + @processed_game_ids = [] + end + + ## + # Previews what would be deleted in a sync operation. + # Does not modify any data, just returns items that would be removed. + # + # @return [Array] Collection summons that would be deleted + def preview_deletions + items = extract_items + return [] if items.empty? + + # Extract all game_ids from the import data + game_ids = items.filter_map do |item| + param = item['param'] || {} + param['id'].to_s if param['id'].present? + end + + return [] if game_ids.empty? + + # Find collection summons with game_ids NOT in the import + @user.collection_summons + .includes(:summon) + .where.not(game_id: nil) + .where.not(game_id: game_ids) end ## @@ -30,7 +57,16 @@ class SummonImportService # @return [Result] Import result with counts and errors def import items = extract_items - return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No summon items found in data']) if items.empty? + if items.empty? + return Result.new( + success?: false, + created: [], + updated: [], + skipped: [], + errors: ['No summon items found in data'], + reconciliation: nil + ) + end ActiveRecord::Base.transaction do items.each_with_index do |item, index| @@ -40,12 +76,19 @@ class SummonImportService end end + # Handle deletion reconciliation if requested + reconciliation_result = nil + if @reconcile_deletions && @is_full_inventory && @processed_game_ids.any? + reconciliation_result = reconcile_deletions + end + Result.new( success?: @errors.empty?, created: @created, updated: @updated, skipped: @skipped, - errors: @errors + errors: @errors, + reconciliation: reconciliation_result ) end @@ -68,6 +111,9 @@ class SummonImportService granblue_id = image_id || master['id'] game_id = param['id'] + # Track this game_id as processed (for reconciliation) + @processed_game_ids << game_id.to_s if game_id.present? + summon = find_summon(granblue_id) unless summon @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Summon not found' } @@ -143,4 +189,34 @@ class SummonImportService value = phase.to_i value.clamp(0, 10) end + + ## + # Reconciles deletions by removing collection summons not in the processed list. + # Only called when @is_full_inventory and @reconcile_deletions are both true. + # + # @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs + def reconcile_deletions + # Find collection summons with game_ids NOT in our processed list + missing = @user.collection_summons + .where.not(game_id: nil) + .where.not(game_id: @processed_game_ids) + + deleted_count = 0 + orphaned_grid_item_ids = [] + + missing.find_each do |coll_summon| + # Collect IDs of grid items that will be orphaned + grid_summon_ids = GridSummon.where(collection_summon_id: coll_summon.id).pluck(:id) + orphaned_grid_item_ids.concat(grid_summon_ids) + + # The before_destroy callback on CollectionSummon will mark grid items as orphaned + coll_summon.destroy + deleted_count += 1 + end + + { + deleted: deleted_count, + orphaned_grid_items: orphaned_grid_item_ids + } + end end diff --git a/app/services/weapon_import_service.rb b/app/services/weapon_import_service.rb index 6923395..d0c8a98 100644 --- a/app/services/weapon_import_service.rb +++ b/app/services/weapon_import_service.rb @@ -12,7 +12,7 @@ # end # class WeaponImportService - Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) + Result = Struct.new(:success?, :created, :updated, :skipped, :errors, :reconciliation, keyword_init: true) # Game awakening form to our slug mapping AWAKENING_FORM_MAPPING = { @@ -28,11 +28,38 @@ class WeaponImportService @user = user @game_data = game_data @update_existing = options[:update_existing] || false + @is_full_inventory = options[:is_full_inventory] || false + @reconcile_deletions = options[:reconcile_deletions] || false @created = [] @updated = [] @skipped = [] @errors = [] @awakening_cache = {} + @processed_game_ids = [] + end + + ## + # Previews what would be deleted in a sync operation. + # Does not modify any data, just returns items that would be removed. + # + # @return [Array] Collection weapons that would be deleted + def preview_deletions + items = extract_items + return [] if items.empty? + + # Extract all game_ids from the import data + game_ids = items.filter_map do |item| + param = item['param'] || {} + param['id'].to_s if param['id'].present? + end + + return [] if game_ids.empty? + + # Find collection weapons with game_ids NOT in the import + @user.collection_weapons + .includes(:weapon) + .where.not(game_id: nil) + .where.not(game_id: game_ids) end ## @@ -41,7 +68,16 @@ class WeaponImportService # @return [Result] Import result with counts and errors def import items = extract_items - return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No weapon items found in data']) if items.empty? + if items.empty? + return Result.new( + success?: false, + created: [], + updated: [], + skipped: [], + errors: ['No weapon items found in data'], + reconciliation: nil + ) + end ActiveRecord::Base.transaction do items.each_with_index do |item, index| @@ -51,12 +87,19 @@ class WeaponImportService end end + # Handle deletion reconciliation if requested + reconciliation_result = nil + if @reconcile_deletions && @is_full_inventory && @processed_game_ids.any? + reconciliation_result = reconcile_deletions + end + Result.new( success?: @errors.empty?, created: @created, updated: @updated, skipped: @skipped, - errors: @errors + errors: @errors, + reconciliation: reconciliation_result ) end @@ -77,6 +120,9 @@ class WeaponImportService granblue_id = param['image_id'] || master['id'] game_id = param['id'] + # Track this game_id as processed (for reconciliation) + @processed_game_ids << game_id.to_s if game_id.present? + weapon = find_weapon(granblue_id) unless weapon @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Weapon not found' } @@ -263,4 +309,34 @@ class WeaponImportService nil end + + ## + # Reconciles deletions by removing collection weapons not in the processed list. + # Only called when @is_full_inventory and @reconcile_deletions are both true. + # + # @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs + def reconcile_deletions + # Find collection weapons with game_ids NOT in our processed list + missing = @user.collection_weapons + .where.not(game_id: nil) + .where.not(game_id: @processed_game_ids) + + deleted_count = 0 + orphaned_grid_item_ids = [] + + missing.find_each do |coll_weapon| + # Collect IDs of grid items that will be orphaned + grid_weapon_ids = GridWeapon.where(collection_weapon_id: coll_weapon.id).pluck(:id) + orphaned_grid_item_ids.concat(grid_weapon_ids) + + # The before_destroy callback on CollectionWeapon will mark grid items as orphaned + coll_weapon.destroy + deleted_count += 1 + end + + { + deleted: deleted_count, + orphaned_grid_items: orphaned_grid_item_ids + } + end end diff --git a/config/routes.rb b/config/routes.rb index d417622..8676b00 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -275,6 +275,7 @@ Rails.application.routes.draw do post :batch delete :batch_destroy post :import + post :preview_sync end end resources :summons, only: [:create, :update, :destroy], controller: '/api/v1/collection_summons' do @@ -282,6 +283,7 @@ Rails.application.routes.draw do post :batch delete :batch_destroy post :import + post :preview_sync end end resources :job_accessories, controller: '/api/v1/collection_job_accessories', @@ -291,6 +293,7 @@ Rails.application.routes.draw do post :batch delete :batch_destroy post :import + post :preview_sync end end end diff --git a/db/migrate/20251223000000_add_orphaned_to_grid_items.rb b/db/migrate/20251223000000_add_orphaned_to_grid_items.rb new file mode 100644 index 0000000..04ede6d --- /dev/null +++ b/db/migrate/20251223000000_add_orphaned_to_grid_items.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddOrphanedToGridItems < ActiveRecord::Migration[8.0] + def change + add_column :grid_weapons, :orphaned, :boolean, default: false, null: false + add_column :grid_summons, :orphaned, :boolean, default: false, null: false + add_column :grid_artifacts, :orphaned, :boolean, default: false, null: false + + add_index :grid_weapons, :orphaned + add_index :grid_summons, :orphaned + add_index :grid_artifacts, :orphaned + end +end