hensei-api/app/services/artifact_import_service.rb
Justin Edmund b828bbeba3 collection sync with orphan handling
- 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
2025-12-23 22:42:58 -08:00

272 lines
7.9 KiB
Ruby

# frozen_string_literal: true
##
# Service for importing artifacts from game JSON data.
# Matches skills by name (EN or JP) and stores quality tier for later strength calculation.
#
# @example Import artifacts for a user
# service = ArtifactImportService.new(user, game_data)
# result = service.import
# if result.success?
# puts "Imported #{result.created_count} artifacts"
# end
#
class ArtifactImportService
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
# Ours: wind=1, fire=2, water=3, earth=4, dark=5, light=6
ELEMENT_MAPPING = {
'1' => 2, # Fire
'2' => 3, # Water
'3' => 4, # Earth
'4' => 1, # Wind
'5' => 6, # Light
'6' => 5 # Dark
}.freeze
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<CollectionArtifact>] 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
##
# Imports artifacts from game data.
#
# @return [Result] Import result with counts and errors
def import
items = extract_items
if items.empty?
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
preload_artifacts(items)
preload_existing_collection_artifacts(items)
ActiveRecord::Base.transaction do
items.each_with_index do |item, index|
import_item(item, index)
rescue StandardError => e
@errors << { index: index, game_id: item['id'], error: e.message }
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,
reconciliation: reconciliation_result
)
end
private
def extract_items
return @game_data if @game_data.is_a?(Array)
return @game_data['list'] if @game_data.is_a?(Hash) && @game_data['list'].is_a?(Array)
[]
end
def preload_artifacts(items)
artifact_ids = items.map { |item| (item['artifact_id'] || item[:artifact_id]).to_s }.uniq
artifacts = Artifact.where(granblue_id: artifact_ids).index_by(&:granblue_id)
@artifacts_cache = artifacts
end
def preload_existing_collection_artifacts(items)
game_ids = items.map { |item| (item['id'] || item[:id]).to_s }.uniq
existing = @user.collection_artifacts.where(game_id: game_ids).index_by(&:game_id)
@existing_cache = existing
end
def import_item(item, _index)
# 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' }
return
end
# Check for existing collection artifact with same game ID
existing = @existing_cache[data['id'].to_s]
if existing
if @update_existing
update_existing_artifact(existing, data, artifact)
else
@skipped << { game_id: data['id'], reason: 'Already exists' }
end
return
end
create_collection_artifact(data, artifact)
end
def find_artifact(artifact_id)
@artifacts_cache[artifact_id.to_s]
end
def create_collection_artifact(item, artifact)
attrs = build_collection_artifact_attrs(item, artifact)
collection_artifact = @user.collection_artifacts.build(attrs)
if collection_artifact.save
@created << collection_artifact
else
@errors << {
game_id: item['id'],
artifact_id: item['artifact_id'],
error: collection_artifact.errors.full_messages.join(', ')
}
end
end
def update_existing_artifact(existing, item, artifact)
attrs = build_collection_artifact_attrs(item, artifact)
if existing.update(attrs)
@updated << existing
else
@errors << {
game_id: item['id'],
artifact_id: item['artifact_id'],
error: existing.errors.full_messages.join(', ')
}
end
end
def build_collection_artifact_attrs(item, artifact)
# Handle both string and symbol keys from params
data = item.is_a?(Hash) ? item.with_indifferent_access : item
{
artifact: artifact,
game_id: data['id'].to_s,
element: map_element(data['attribute']),
proficiency: artifact.quirk? ? map_proficiency(data['kind']) : nil,
level: data['level'].to_i,
skill1: parse_skill(data['skill1_info']),
skill2: parse_skill(data['skill2_info']),
skill3: parse_skill(data['skill3_info']),
skill4: parse_skill(data['skill4_info'])
}
end
def map_element(game_element)
ELEMENT_MAPPING[game_element.to_s]
end
def map_proficiency(kind)
# Game 'kind' field maps directly to proficiency enum (1-10)
kind.to_i
end
def parse_skill(skill_info)
return {} if skill_info.blank?
# Handle both string and symbol keys from params
info = skill_info.is_a?(Hash) ? skill_info.with_indifferent_access : skill_info
name = info['name']
quality = info['skill_quality'] || info['level'] || 1
level = info['level'] || 1
# Look up skill by game name (supports both EN and JP)
skill = ArtifactSkill.find_by_game_name(name)
return {} unless skill
{
'modifier' => skill.modifier,
'quality' => quality.to_i,
'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