diff --git a/app/models/import.rb b/app/models/import.rb index c9000b75..9d23aaff 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -6,7 +6,10 @@ class Import < ApplicationRecord has_one_attached :file - after_commit -> { Import::ProcessJob.perform_later(id) }, on: :create + # Flag to skip background processing during user data import + attr_accessor :skip_background_processing + + after_commit -> { Import::ProcessJob.perform_later(id) unless skip_background_processing }, on: :create after_commit :remove_attached_file, on: :destroy validates :name, presence: true, uniqueness: { scope: :user_id } diff --git a/app/services/users/export_data.rb b/app/services/users/export_data.rb index a7e3c61a..7f9932d8 100644 --- a/app/services/users/export_data.rb +++ b/app/services/users/export_data.rb @@ -336,10 +336,10 @@ class Users::ExportData counts end - def create_zip_archive(export_directory, zip_file_path) - # Set global compression level for better file size reduction + def create_zip_archive(export_directory, zip_file_path) + # Set global compression for better file size reduction original_compression = Zip.default_compression - Zip.default_compression = Zlib::BEST_COMPRESSION + Zip.default_compression = Zip::Entry::DEFLATED # Create zip archive with optimized compression Zip::File.open(zip_file_path, Zip::File::CREATE) do |zipfile| @@ -353,7 +353,7 @@ class Users::ExportData end end ensure - # Restore original compression level + # Restore original compression setting Zip.default_compression = original_compression if original_compression end @@ -368,7 +368,15 @@ class Users::ExportData def create_success_notification counts = calculate_entity_counts - summary = "#{counts[:points]} points, #{counts[:visits]} visits, #{counts[:places]} places, #{counts[:trips]} trips" + summary = "#{counts[:points]} points, " \ + "#{counts[:visits]} visits, " \ + "#{counts[:places]} places, " \ + "#{counts[:trips]} trips, " \ + "#{counts[:areas]} areas, " \ + "#{counts[:imports]} imports, " \ + "#{counts[:exports]} exports, " \ + "#{counts[:stats]} stats, " \ + "#{counts[:notifications]} notifications" ::Notifications::Create.new( user: user, diff --git a/app/services/users/export_data/notifications.rb b/app/services/users/export_data/notifications.rb index 361f1d37..9efceb9f 100644 --- a/app/services/users/export_data/notifications.rb +++ b/app/services/users/export_data/notifications.rb @@ -1,12 +1,23 @@ # frozen_string_literal: true class Users::ExportData::Notifications + # System-generated notification titles that should not be exported + SYSTEM_NOTIFICATION_TITLES = [ + 'Data import completed', + 'Data import failed', + 'Export completed', + 'Export failed' + ].freeze + def initialize(user) @user = user end def call - user.notifications.as_json(except: %w[user_id id]) + # Export only user-generated notifications, not system-generated ones + user.notifications + .where.not(title: SYSTEM_NOTIFICATION_TITLES) + .as_json(except: %w[user_id id]) end private diff --git a/app/services/users/import_data.rb b/app/services/users/import_data.rb index f456c577..5a5b3cc0 100644 --- a/app/services/users/import_data.rb +++ b/app/services/users/import_data.rb @@ -103,6 +103,9 @@ class Users::ImportData Rails.logger.info "Expected entity counts from export: #{data['counts']}" end + # Debug: Log what data keys are available + Rails.logger.debug "Available data keys: #{data.keys.inspect}" + # Import in dependency order import_settings(data['settings']) if data['settings'] import_areas(data['areas']) if data['areas'] @@ -119,55 +122,84 @@ class Users::ImportData end def import_settings(settings_data) + Rails.logger.debug "Importing settings: #{settings_data.inspect}" Users::ImportData::Settings.new(user, settings_data).call @import_stats[:settings_updated] = true end def import_areas(areas_data) + Rails.logger.debug "Importing #{areas_data&.size || 0} areas" areas_created = Users::ImportData::Areas.new(user, areas_data).call @import_stats[:areas_created] = areas_created end def import_places(places_data) + Rails.logger.debug "Importing #{places_data&.size || 0} places" places_created = Users::ImportData::Places.new(user, places_data).call @import_stats[:places_created] = places_created end def import_imports(imports_data) + Rails.logger.debug "Importing #{imports_data&.size || 0} imports" imports_created, files_restored = Users::ImportData::Imports.new(user, imports_data, @import_directory.join('files')).call @import_stats[:imports_created] = imports_created @import_stats[:files_restored] += files_restored end def import_exports(exports_data) + Rails.logger.debug "Importing #{exports_data&.size || 0} exports" exports_created, files_restored = Users::ImportData::Exports.new(user, exports_data, @import_directory.join('files')).call @import_stats[:exports_created] = exports_created @import_stats[:files_restored] += files_restored end def import_trips(trips_data) + Rails.logger.debug "Importing #{trips_data&.size || 0} trips" trips_created = Users::ImportData::Trips.new(user, trips_data).call @import_stats[:trips_created] = trips_created end def import_stats(stats_data) + Rails.logger.debug "Importing #{stats_data&.size || 0} stats" stats_created = Users::ImportData::Stats.new(user, stats_data).call @import_stats[:stats_created] = stats_created end def import_notifications(notifications_data) + Rails.logger.debug "Importing #{notifications_data&.size || 0} notifications" notifications_created = Users::ImportData::Notifications.new(user, notifications_data).call @import_stats[:notifications_created] = notifications_created end def import_visits(visits_data) + Rails.logger.debug "Importing #{visits_data&.size || 0} visits" visits_created = Users::ImportData::Visits.new(user, visits_data).call @import_stats[:visits_created] = visits_created end def import_points(points_data) - points_created = Users::ImportData::Points.new(user, points_data).call - @import_stats[:points_created] = points_created + puts "=== POINTS IMPORT DEBUG ===" + puts "About to import #{points_data&.size || 0} points" + puts "Points data present: #{points_data.present?}" + puts "First point sample: #{points_data&.first&.slice('timestamp', 'longitude', 'latitude') if points_data&.first}" + puts "=== END POINTS IMPORT DEBUG ===" + + Rails.logger.info "About to import #{points_data&.size || 0} points" + Rails.logger.info "Points data present: #{points_data.present?}" + Rails.logger.info "First point sample: #{points_data&.first&.slice('timestamp', 'longitude', 'latitude') if points_data&.first}" + + begin + points_created = Users::ImportData::Points.new(user, points_data).call + Rails.logger.info "Points import returned: #{points_created}" + puts "Points import returned: #{points_created}" + + @import_stats[:points_created] = points_created + rescue StandardError => e + Rails.logger.error "Points import failed: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}" + puts "Points import failed: #{e.message}" + @import_stats[:points_created] = 0 + end end def cleanup_temporary_files(import_directory) @@ -180,8 +212,26 @@ class Users::ImportData end def create_success_notification - summary = "#{@import_stats[:points_created]} points, #{@import_stats[:visits_created]} visits, " \ - "#{@import_stats[:places_created]} places, #{@import_stats[:trips_created]} trips" + # Check if we already have a recent import success notification to avoid duplicates + recent_import_notification = user.notifications.where( + title: 'Data import completed' + ).where('created_at > ?', 5.minutes.ago).first + + if recent_import_notification + Rails.logger.debug "Skipping duplicate import success notification" + return + end + + summary = "#{@import_stats[:points_created]} points, " \ + "#{@import_stats[:visits_created]} visits, " \ + "#{@import_stats[:places_created]} places, " \ + "#{@import_stats[:trips_created]} trips, " \ + "#{@import_stats[:areas_created]} areas, " \ + "#{@import_stats[:imports_created]} imports, " \ + "#{@import_stats[:exports_created]} exports, " \ + "#{@import_stats[:stats_created]} stats, " \ + "#{@import_stats[:files_restored]} files restored, " \ + "#{@import_stats[:notifications_created]} notifications" ::Notifications::Create.new( user: user, diff --git a/app/services/users/import_data/areas.rb b/app/services/users/import_data/areas.rb index 4fa6f000..b9fcbdc7 100644 --- a/app/services/users/import_data/areas.rb +++ b/app/services/users/import_data/areas.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Users::ImportData::Areas + BATCH_SIZE = 1000 + def initialize(user, areas_data) @user = user @areas_data = areas_data @@ -11,43 +13,141 @@ class Users::ImportData::Areas Rails.logger.info "Importing #{areas_data.size} areas for user: #{user.email}" - areas_created = 0 + # Filter valid areas and prepare for bulk import + valid_areas = filter_and_prepare_areas - areas_data.each do |area_data| - next unless area_data.is_a?(Hash) - - # Skip if area already exists (match by name and coordinates) - existing_area = user.areas.find_by( - name: area_data['name'], - latitude: area_data['latitude'], - longitude: area_data['longitude'] - ) - - if existing_area - Rails.logger.debug "Area already exists: #{area_data['name']}" - next - end - - # Create new area - area_attributes = area_data.merge(user: user) - # Ensure radius is present (required by model validation) - area_attributes['radius'] ||= 100 # Default radius if not provided - - area = user.areas.create!(area_attributes) - areas_created += 1 - - Rails.logger.debug "Created area: #{area.name}" - rescue ActiveRecord::RecordInvalid => e - ExceptionReporter.call(e, "Failed to create area") - - next + if valid_areas.empty? + Rails.logger.info "Areas import completed. Created: 0" + return 0 end - Rails.logger.info "Areas import completed. Created: #{areas_created}" - areas_created + # Remove existing areas to avoid duplicates + deduplicated_areas = filter_existing_areas(valid_areas) + + if deduplicated_areas.size < valid_areas.size + Rails.logger.debug "Skipped #{valid_areas.size - deduplicated_areas.size} duplicate areas" + end + + # Bulk import in batches + total_created = bulk_import_areas(deduplicated_areas) + + Rails.logger.info "Areas import completed. Created: #{total_created}" + total_created end private attr_reader :user, :areas_data + + def filter_and_prepare_areas + valid_areas = [] + skipped_count = 0 + + areas_data.each do |area_data| + next unless area_data.is_a?(Hash) + + # Skip areas with missing required data + unless valid_area_data?(area_data) + skipped_count += 1 + next + end + + # Prepare area attributes for bulk insert + prepared_attributes = prepare_area_attributes(area_data) + valid_areas << prepared_attributes if prepared_attributes + end + + if skipped_count > 0 + Rails.logger.warn "Skipped #{skipped_count} areas with invalid or missing required data" + end + + valid_areas + end + + def prepare_area_attributes(area_data) + # Start with base attributes, excluding timestamp fields + attributes = area_data.except('created_at', 'updated_at') + + # Add required attributes for bulk insert + attributes['user_id'] = user.id + attributes['created_at'] = Time.current + attributes['updated_at'] = Time.current + + # Ensure radius is present (required by model validation) + attributes['radius'] ||= 100 # Default radius if not provided + + # Convert string keys to symbols for consistency + attributes.symbolize_keys + rescue StandardError => e + Rails.logger.error "Failed to prepare area attributes: #{e.message}" + Rails.logger.error "Area data: #{area_data.inspect}" + nil + end + + def filter_existing_areas(areas) + return areas if areas.empty? + + # Build lookup hash of existing areas for this user + existing_areas_lookup = {} + user.areas.select(:name, :latitude, :longitude).each do |area| + # Normalize decimal values for consistent comparison + key = [area.name, area.latitude.to_f, area.longitude.to_f] + existing_areas_lookup[key] = true + end + + # Filter out areas that already exist + filtered_areas = areas.reject do |area| + # Normalize decimal values for consistent comparison + key = [area[:name], area[:latitude].to_f, area[:longitude].to_f] + if existing_areas_lookup[key] + Rails.logger.debug "Area already exists: #{area[:name]}" + true + else + false + end + end + + filtered_areas + end + + def bulk_import_areas(areas) + total_created = 0 + + areas.each_slice(BATCH_SIZE) do |batch| + begin + # Use upsert_all to efficiently bulk insert areas + result = Area.upsert_all( + batch, + returning: %w[id], + on_duplicate: :skip + ) + + batch_created = result.count + total_created += batch_created + + Rails.logger.debug "Processed batch of #{batch.size} areas, created #{batch_created}, total created: #{total_created}" + + rescue StandardError => e + Rails.logger.error "Failed to process area batch: #{e.message}" + Rails.logger.error "Batch size: #{batch.size}" + Rails.logger.error "Backtrace: #{e.backtrace.first(3).join('\n')}" + # Continue with next batch instead of failing completely + end + end + + total_created + end + + def valid_area_data?(area_data) + # Check for required fields + return false unless area_data.is_a?(Hash) + return false unless area_data['name'].present? + return false unless area_data['latitude'].present? + return false unless area_data['longitude'].present? + + true + rescue StandardError => e + Rails.logger.debug "Area validation failed: #{e.message} for data: #{area_data.inspect}" + false + end end diff --git a/app/services/users/import_data/imports.rb b/app/services/users/import_data/imports.rb index 167e55bb..49343427 100644 --- a/app/services/users/import_data/imports.rb +++ b/app/services/users/import_data/imports.rb @@ -54,7 +54,10 @@ class Users::ImportData::Imports import_attributes = prepare_import_attributes(import_data) begin - import_record = user.imports.create!(import_attributes) + import_record = user.imports.build(import_attributes) + # Skip background processing since we're importing user data directly + import_record.skip_background_processing = true + import_record.save! Rails.logger.debug "Created import: #{import_record.name}" import_record rescue ActiveRecord::RecordInvalid => e diff --git a/app/services/users/import_data/notifications.rb b/app/services/users/import_data/notifications.rb index 842435b8..60742074 100644 --- a/app/services/users/import_data/notifications.rb +++ b/app/services/users/import_data/notifications.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Users::ImportData::Notifications + BATCH_SIZE = 1000 + def initialize(user, notifications_data) @user = user @notifications_data = notifications_data @@ -11,39 +13,177 @@ class Users::ImportData::Notifications Rails.logger.info "Importing #{notifications_data.size} notifications for user: #{user.email}" - notifications_created = 0 + # Filter valid notifications and prepare for bulk import + valid_notifications = filter_and_prepare_notifications - notifications_data.each do |notification_data| - next unless notification_data.is_a?(Hash) - - # Check if notification already exists (match by title, content, and created_at) - existing_notification = user.notifications.find_by( - title: notification_data['title'], - content: notification_data['content'], - created_at: notification_data['created_at'] - ) - - if existing_notification - Rails.logger.debug "Notification already exists: #{notification_data['title']}" - next - end - - # Create new notification - notification_attributes = notification_data.except('created_at', 'updated_at') - notification = user.notifications.create!(notification_attributes) - notifications_created += 1 - - Rails.logger.debug "Created notification: #{notification.title}" - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "Failed to create notification: #{e.message}" - next + if valid_notifications.empty? + Rails.logger.info "Notifications import completed. Created: 0" + return 0 end - Rails.logger.info "Notifications import completed. Created: #{notifications_created}" - notifications_created + # Remove existing notifications to avoid duplicates + deduplicated_notifications = filter_existing_notifications(valid_notifications) + + if deduplicated_notifications.size < valid_notifications.size + Rails.logger.debug "Skipped #{valid_notifications.size - deduplicated_notifications.size} duplicate notifications" + end + + # Bulk import in batches + total_created = bulk_import_notifications(deduplicated_notifications) + + Rails.logger.info "Notifications import completed. Created: #{total_created}" + total_created end private attr_reader :user, :notifications_data + + def filter_and_prepare_notifications + valid_notifications = [] + skipped_count = 0 + + notifications_data.each do |notification_data| + next unless notification_data.is_a?(Hash) + + # Skip notifications with missing required data + unless valid_notification_data?(notification_data) + skipped_count += 1 + next + end + + # Prepare notification attributes for bulk insert + prepared_attributes = prepare_notification_attributes(notification_data) + valid_notifications << prepared_attributes if prepared_attributes + end + + if skipped_count > 0 + Rails.logger.warn "Skipped #{skipped_count} notifications with invalid or missing required data" + end + + valid_notifications + end + + def prepare_notification_attributes(notification_data) + # Start with base attributes, excluding only updated_at (preserve created_at for duplicate logic) + attributes = notification_data.except('updated_at') + + # Add required attributes for bulk insert + attributes['user_id'] = user.id + + # Preserve original created_at if present, otherwise use current time + unless attributes['created_at'].present? + attributes['created_at'] = Time.current + end + + attributes['updated_at'] = Time.current + + # Convert string keys to symbols for consistency + attributes.symbolize_keys + rescue StandardError => e + Rails.logger.error "Failed to prepare notification attributes: #{e.message}" + Rails.logger.error "Notification data: #{notification_data.inspect}" + nil + end + + def filter_existing_notifications(notifications) + return notifications if notifications.empty? + + # Build lookup hash of existing notifications for this user + # Use title and content as the primary deduplication key + existing_notifications_lookup = {} + user.notifications.select(:title, :content, :created_at, :kind).each do |notification| + # Primary key: title + content + primary_key = [notification.title.strip, notification.content.strip] + + # Secondary key: include timestamp for exact matches + exact_key = [notification.title.strip, notification.content.strip, normalize_timestamp(notification.created_at)] + + existing_notifications_lookup[primary_key] = true + existing_notifications_lookup[exact_key] = true + end + + # Filter out notifications that already exist + filtered_notifications = notifications.reject do |notification| + title = notification[:title]&.strip + content = notification[:content]&.strip + + # Check both primary key (title + content) and exact key (with timestamp) + primary_key = [title, content] + exact_key = [title, content, normalize_timestamp(notification[:created_at])] + + if existing_notifications_lookup[primary_key] || existing_notifications_lookup[exact_key] + Rails.logger.debug "Notification already exists: #{notification[:title]}" + true + else + false + end + end + + filtered_notifications + end + + def normalize_timestamp(timestamp) + case timestamp + when String + # Parse string and convert to unix timestamp for consistent comparison + Time.parse(timestamp).to_i + when Time, DateTime + # Convert time objects to unix timestamp for consistent comparison + timestamp.to_i + else + timestamp.to_s + end + rescue StandardError => e + Rails.logger.debug "Failed to normalize timestamp #{timestamp}: #{e.message}" + timestamp.to_s + end + + def bulk_import_notifications(notifications) + total_created = 0 + + notifications.each_slice(BATCH_SIZE) do |batch| + begin + # Use upsert_all to efficiently bulk insert notifications + result = Notification.upsert_all( + batch, + returning: %w[id], + on_duplicate: :skip + ) + + batch_created = result.count + total_created += batch_created + + Rails.logger.debug "Processed batch of #{batch.size} notifications, created #{batch_created}, total created: #{total_created}" + + rescue StandardError => e + Rails.logger.error "Failed to process notification batch: #{e.message}" + Rails.logger.error "Batch size: #{batch.size}" + Rails.logger.error "Backtrace: #{e.backtrace.first(3).join('\n')}" + # Continue with next batch instead of failing completely + end + end + + total_created + end + + def valid_notification_data?(notification_data) + # Check for required fields + return false unless notification_data.is_a?(Hash) + + unless notification_data['title'].present? + Rails.logger.error "Failed to create notification: Validation failed: Title can't be blank" + return false + end + + unless notification_data['content'].present? + Rails.logger.error "Failed to create notification: Validation failed: Content can't be blank" + return false + end + + true + rescue StandardError => e + Rails.logger.debug "Notification validation failed: #{e.message} for data: #{notification_data.inspect}" + false + end end diff --git a/app/services/users/import_data/points.rb b/app/services/users/import_data/points.rb index 1f472169..b053db3b 100644 --- a/app/services/users/import_data/points.rb +++ b/app/services/users/import_data/points.rb @@ -11,7 +11,12 @@ class Users::ImportData::Points def call return 0 unless points_data.is_a?(Array) + puts "=== POINTS SERVICE DEBUG ===" + puts "Points data is array: #{points_data.is_a?(Array)}" + puts "Points data size: #{points_data.size}" + Rails.logger.info "Importing #{points_data.size} points for user: #{user.email}" + Rails.logger.debug "First point sample: #{points_data.first.inspect}" # Pre-load reference data for efficient bulk processing preload_reference_data @@ -19,19 +24,27 @@ class Users::ImportData::Points # Filter valid points and prepare for bulk import valid_points = filter_and_prepare_points + puts "Valid points after filtering: #{valid_points.size}" + if valid_points.empty? - Rails.logger.info "No valid points to import" + puts "No valid points after filtering - returning 0" + Rails.logger.warn "No valid points to import after filtering" + Rails.logger.debug "Original points_data size: #{points_data.size}" return 0 end # Remove duplicates based on unique constraint deduplicated_points = deduplicate_points(valid_points) + puts "Deduplicated points: #{deduplicated_points.size}" + Rails.logger.info "Prepared #{deduplicated_points.size} unique valid points (#{points_data.size - deduplicated_points.size} duplicates/invalid skipped)" # Bulk import in batches total_created = bulk_import_points(deduplicated_points) + puts "Total created by bulk import: #{total_created}" + Rails.logger.info "Points import completed. Created: #{total_created}" total_created end @@ -45,6 +58,7 @@ class Users::ImportData::Points @imports_lookup = user.imports.index_by { |import| [import.name, import.source, import.created_at.to_s] } + Rails.logger.debug "Loaded #{@imports_lookup.size} imports for lookup" # Pre-load all countries for efficient lookup @countries_lookup = {} @@ -53,23 +67,26 @@ class Users::ImportData::Points @countries_lookup[[country.name, country.iso_a2, country.iso_a3]] = country @countries_lookup[country.name] = country end + Rails.logger.debug "Loaded #{Country.count} countries for lookup" # Pre-load visits for this user @visits_lookup = user.visits.index_by { |visit| [visit.name, visit.started_at.to_s, visit.ended_at.to_s] } + Rails.logger.debug "Loaded #{@visits_lookup.size} visits for lookup" end def filter_and_prepare_points valid_points = [] skipped_count = 0 - points_data.each do |point_data| + points_data.each_with_index do |point_data, index| next unless point_data.is_a?(Hash) # Skip points with invalid or missing required data unless valid_point_data?(point_data) skipped_count += 1 + Rails.logger.debug "Skipped point #{index}: invalid data - #{point_data.slice('timestamp', 'longitude', 'latitude', 'lonlat')}" next end @@ -77,6 +94,7 @@ class Users::ImportData::Points prepared_attributes = prepare_point_attributes(point_data) unless prepared_attributes skipped_count += 1 + Rails.logger.debug "Skipped point #{index}: failed to prepare attributes" next end @@ -87,6 +105,7 @@ class Users::ImportData::Points Rails.logger.warn "Skipped #{skipped_count} points with invalid or missing required data" end + Rails.logger.debug "Filtered #{valid_points.size} valid points from #{points_data.size} total" valid_points end @@ -119,7 +138,10 @@ class Users::ImportData::Points resolve_visit_reference(attributes, point_data['visit_reference']) # Convert string keys to symbols for consistency with Point model - attributes.symbolize_keys + result = attributes.symbolize_keys + + Rails.logger.debug "Prepared point attributes: #{result.slice(:lonlat, :timestamp, :import_id, :country_id, :visit_id)}" + result rescue StandardError => e Rails.logger.error "Failed to prepare point attributes: #{e.message}" Rails.logger.error "Point data: #{point_data.inspect}" @@ -136,7 +158,13 @@ class Users::ImportData::Points ] import = imports_lookup[import_key] - attributes['import_id'] = import.id if import + if import + attributes['import_id'] = import.id + Rails.logger.debug "Resolved import reference: #{import_reference['name']} -> #{import.id}" + else + Rails.logger.debug "Import not found for reference: #{import_reference.inspect}" + Rails.logger.debug "Available imports: #{imports_lookup.keys.inspect}" + end end def resolve_country_reference(attributes, country_info) @@ -159,7 +187,12 @@ class Users::ImportData::Points @countries_lookup[[country.name, country.iso_a2, country.iso_a3]] = country end - attributes['country_id'] = country.id if country + if country + attributes['country_id'] = country.id + Rails.logger.debug "Resolved country reference: #{country_info['name']} -> #{country.id}" + else + Rails.logger.debug "Country not found for: #{country_info.inspect}" + end end def create_missing_country(country_info) @@ -183,21 +216,55 @@ class Users::ImportData::Points ] visit = visits_lookup[visit_key] - attributes['visit_id'] = visit.id if visit + if visit + attributes['visit_id'] = visit.id + Rails.logger.debug "Resolved visit reference: #{visit_reference['name']} -> #{visit.id}" + else + Rails.logger.debug "Visit not found for reference: #{visit_reference.inspect}" + Rails.logger.debug "Available visits: #{visits_lookup.keys.inspect}" + end end def deduplicate_points(points) points.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] } end + # Ensure all points have the same keys for upsert_all compatibility + def normalize_point_keys(points) + # Get all possible keys from all points + all_keys = points.flat_map(&:keys).uniq + + # Normalize each point to have all keys (with nil for missing ones) + points.map do |point| + normalized = {} + all_keys.each do |key| + normalized[key] = point[key] + end + normalized + end + end + def bulk_import_points(points) total_created = 0 + puts "=== BULK IMPORT DEBUG ===" + puts "About to bulk import #{points.size} points" + puts "First point for import: #{points.first.inspect}" + points.each_slice(BATCH_SIZE) do |batch| begin + Rails.logger.debug "Processing batch of #{batch.size} points" + Rails.logger.debug "First point in batch: #{batch.first.inspect}" + + puts "Processing batch of #{batch.size} points" + puts "Sample point attributes: #{batch.first.slice(:lonlat, :timestamp, :user_id, :import_id, :country_id, :visit_id)}" + + # Normalize all points to have the same keys for upsert_all compatibility + normalized_batch = normalize_point_keys(batch) + # Use upsert_all to efficiently bulk insert/update points result = Point.upsert_all( - batch, + normalized_batch, unique_by: %i[lonlat timestamp user_id], returning: %w[id], on_duplicate: :skip @@ -206,17 +273,24 @@ class Users::ImportData::Points batch_created = result.count total_created += batch_created + puts "Batch result count: #{batch_created}" + Rails.logger.debug "Processed batch of #{batch.size} points, created #{batch_created}, total created: #{total_created}" rescue StandardError => e + puts "Batch import failed: #{e.message}" + puts "Backtrace: #{e.backtrace.first(3).join('\n')}" Rails.logger.error "Failed to process point batch: #{e.message}" Rails.logger.error "Batch size: #{batch.size}" - Rails.logger.error "Backtrace: #{e.backtrace.first(3).join('\n')}" + Rails.logger.error "First point in failed batch: #{batch.first.inspect}" + Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}" # Continue with next batch instead of failing completely end end - total_created + puts "Total created across all batches: #{total_created}" + + total_created end def valid_point_data?(point_data) @@ -242,6 +316,7 @@ class Users::ImportData::Points longitude = point_data['longitude'].to_f latitude = point_data['latitude'].to_f attributes['lonlat'] = "POINT(#{longitude} #{latitude})" + Rails.logger.debug "Reconstructed lonlat: #{attributes['lonlat']}" end end end diff --git a/app/services/users/import_data/stats.rb b/app/services/users/import_data/stats.rb index 3ad22bb6..f62872c1 100644 --- a/app/services/users/import_data/stats.rb +++ b/app/services/users/import_data/stats.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Users::ImportData::Stats + BATCH_SIZE = 1000 + def initialize(user, stats_data) @user = user @stats_data = stats_data @@ -11,38 +13,148 @@ class Users::ImportData::Stats Rails.logger.info "Importing #{stats_data.size} stats for user: #{user.email}" - stats_created = 0 + # Filter valid stats and prepare for bulk import + valid_stats = filter_and_prepare_stats - stats_data.each do |stat_data| - next unless stat_data.is_a?(Hash) - - # Check if stat already exists (match by year and month) - existing_stat = user.stats.find_by( - year: stat_data['year'], - month: stat_data['month'] - ) - - if existing_stat - Rails.logger.debug "Stat already exists: #{stat_data['year']}-#{stat_data['month']}" - next - end - - # Create new stat - stat_attributes = stat_data.except('created_at', 'updated_at') - stat = user.stats.create!(stat_attributes) - stats_created += 1 - - Rails.logger.debug "Created stat: #{stat.year}-#{stat.month}" - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "Failed to create stat: #{e.message}" - next + if valid_stats.empty? + Rails.logger.info "Stats import completed. Created: 0" + return 0 end - Rails.logger.info "Stats import completed. Created: #{stats_created}" - stats_created + # Remove existing stats to avoid duplicates + deduplicated_stats = filter_existing_stats(valid_stats) + + if deduplicated_stats.size < valid_stats.size + Rails.logger.debug "Skipped #{valid_stats.size - deduplicated_stats.size} duplicate stats" + end + + # Bulk import in batches + total_created = bulk_import_stats(deduplicated_stats) + + Rails.logger.info "Stats import completed. Created: #{total_created}" + total_created end private attr_reader :user, :stats_data + + def filter_and_prepare_stats + valid_stats = [] + skipped_count = 0 + + stats_data.each do |stat_data| + next unless stat_data.is_a?(Hash) + + # Skip stats with missing required data + unless valid_stat_data?(stat_data) + skipped_count += 1 + next + end + + # Prepare stat attributes for bulk insert + prepared_attributes = prepare_stat_attributes(stat_data) + valid_stats << prepared_attributes if prepared_attributes + end + + if skipped_count > 0 + Rails.logger.warn "Skipped #{skipped_count} stats with invalid or missing required data" + end + + valid_stats + end + + def prepare_stat_attributes(stat_data) + # Start with base attributes, excluding timestamp fields + attributes = stat_data.except('created_at', 'updated_at') + + # Add required attributes for bulk insert + attributes['user_id'] = user.id + attributes['created_at'] = Time.current + attributes['updated_at'] = Time.current + + # Convert string keys to symbols for consistency + attributes.symbolize_keys + rescue StandardError => e + Rails.logger.error "Failed to prepare stat attributes: #{e.message}" + Rails.logger.error "Stat data: #{stat_data.inspect}" + nil + end + + def filter_existing_stats(stats) + return stats if stats.empty? + + # Build lookup hash of existing stats for this user + existing_stats_lookup = {} + user.stats.select(:year, :month).each do |stat| + key = [stat.year, stat.month] + existing_stats_lookup[key] = true + end + + # Filter out stats that already exist + filtered_stats = stats.reject do |stat| + key = [stat[:year], stat[:month]] + if existing_stats_lookup[key] + Rails.logger.debug "Stat already exists: #{stat[:year]}-#{stat[:month]}" + true + else + false + end + end + + filtered_stats + end + + def bulk_import_stats(stats) + total_created = 0 + + stats.each_slice(BATCH_SIZE) do |batch| + begin + # Use upsert_all to efficiently bulk insert stats + result = Stat.upsert_all( + batch, + returning: %w[id], + on_duplicate: :skip + ) + + batch_created = result.count + total_created += batch_created + + Rails.logger.debug "Processed batch of #{batch.size} stats, created #{batch_created}, total created: #{total_created}" + + rescue StandardError => e + Rails.logger.error "Failed to process stat batch: #{e.message}" + Rails.logger.error "Batch size: #{batch.size}" + Rails.logger.error "Backtrace: #{e.backtrace.first(3).join('\n')}" + # Continue with next batch instead of failing completely + end + end + + total_created + end + + def valid_stat_data?(stat_data) + # Check for required fields + return false unless stat_data.is_a?(Hash) + + unless stat_data['year'].present? + Rails.logger.error "Failed to create stat: Validation failed: Year can't be blank" + return false + end + + unless stat_data['month'].present? + Rails.logger.error "Failed to create stat: Validation failed: Month can't be blank" + return false + end + + unless stat_data['distance'].present? + Rails.logger.error "Failed to create stat: Validation failed: Distance can't be blank" + return false + end + + true + rescue StandardError => e + Rails.logger.debug "Stat validation failed: #{e.message} for data: #{stat_data.inspect}" + false + end end diff --git a/app/services/users/import_data/trips.rb b/app/services/users/import_data/trips.rb index 7f8d3f72..219dc416 100644 --- a/app/services/users/import_data/trips.rb +++ b/app/services/users/import_data/trips.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Users::ImportData::Trips + BATCH_SIZE = 1000 + def initialize(user, trips_data) @user = user @trips_data = trips_data @@ -11,39 +13,165 @@ class Users::ImportData::Trips Rails.logger.info "Importing #{trips_data.size} trips for user: #{user.email}" - trips_created = 0 + # Filter valid trips and prepare for bulk import + valid_trips = filter_and_prepare_trips - trips_data.each do |trip_data| - next unless trip_data.is_a?(Hash) - - # Check if trip already exists (match by name and timestamps) - existing_trip = user.trips.find_by( - name: trip_data['name'], - started_at: trip_data['started_at'], - ended_at: trip_data['ended_at'] - ) - - if existing_trip - Rails.logger.debug "Trip already exists: #{trip_data['name']}" - next - end - - # Create new trip - trip_attributes = trip_data.except('created_at', 'updated_at') - trip = user.trips.create!(trip_attributes) - trips_created += 1 - - Rails.logger.debug "Created trip: #{trip.name}" - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "Failed to create trip: #{e.message}" - next + if valid_trips.empty? + Rails.logger.info "Trips import completed. Created: 0" + return 0 end - Rails.logger.info "Trips import completed. Created: #{trips_created}" - trips_created + # Remove existing trips to avoid duplicates + deduplicated_trips = filter_existing_trips(valid_trips) + + if deduplicated_trips.size < valid_trips.size + Rails.logger.debug "Skipped #{valid_trips.size - deduplicated_trips.size} duplicate trips" + end + + # Bulk import in batches + total_created = bulk_import_trips(deduplicated_trips) + + Rails.logger.info "Trips import completed. Created: #{total_created}" + total_created end private attr_reader :user, :trips_data + + def filter_and_prepare_trips + valid_trips = [] + skipped_count = 0 + + trips_data.each do |trip_data| + next unless trip_data.is_a?(Hash) + + # Skip trips with missing required data + unless valid_trip_data?(trip_data) + skipped_count += 1 + next + end + + # Prepare trip attributes for bulk insert + prepared_attributes = prepare_trip_attributes(trip_data) + valid_trips << prepared_attributes if prepared_attributes + end + + if skipped_count > 0 + Rails.logger.warn "Skipped #{skipped_count} trips with invalid or missing required data" + end + + valid_trips + end + + def prepare_trip_attributes(trip_data) + # Start with base attributes, excluding timestamp fields + attributes = trip_data.except('created_at', 'updated_at') + + # Add required attributes for bulk insert + attributes['user_id'] = user.id + attributes['created_at'] = Time.current + attributes['updated_at'] = Time.current + + # Convert string keys to symbols for consistency + attributes.symbolize_keys + rescue StandardError => e + Rails.logger.error "Failed to prepare trip attributes: #{e.message}" + Rails.logger.error "Trip data: #{trip_data.inspect}" + nil + end + + def filter_existing_trips(trips) + return trips if trips.empty? + + # Build lookup hash of existing trips for this user + existing_trips_lookup = {} + user.trips.select(:name, :started_at, :ended_at).each do |trip| + # Normalize timestamp values for consistent comparison + key = [trip.name, normalize_timestamp(trip.started_at), normalize_timestamp(trip.ended_at)] + existing_trips_lookup[key] = true + end + + # Filter out trips that already exist + filtered_trips = trips.reject do |trip| + # Normalize timestamp values for consistent comparison + key = [trip[:name], normalize_timestamp(trip[:started_at]), normalize_timestamp(trip[:ended_at])] + if existing_trips_lookup[key] + Rails.logger.debug "Trip already exists: #{trip[:name]}" + true + else + false + end + end + + filtered_trips + end + + def normalize_timestamp(timestamp) + case timestamp + when String + # Parse string and convert to iso8601 format for consistent comparison + Time.parse(timestamp).utc.iso8601 + when Time, DateTime + # Convert time objects to iso8601 format for consistent comparison + timestamp.utc.iso8601 + else + timestamp.to_s + end + rescue StandardError + timestamp.to_s + end + + def bulk_import_trips(trips) + total_created = 0 + + trips.each_slice(BATCH_SIZE) do |batch| + begin + # Use upsert_all to efficiently bulk insert trips + result = Trip.upsert_all( + batch, + returning: %w[id], + on_duplicate: :skip + ) + + batch_created = result.count + total_created += batch_created + + Rails.logger.debug "Processed batch of #{batch.size} trips, created #{batch_created}, total created: #{total_created}" + + rescue StandardError => e + Rails.logger.error "Failed to process trip batch: #{e.message}" + Rails.logger.error "Batch size: #{batch.size}" + Rails.logger.error "Backtrace: #{e.backtrace.first(3).join('\n')}" + # Continue with next batch instead of failing completely + end + end + + total_created + end + + def valid_trip_data?(trip_data) + # Check for required fields + return false unless trip_data.is_a?(Hash) + + unless trip_data['name'].present? + Rails.logger.error "Failed to create trip: Validation failed: Name can't be blank" + return false + end + + unless trip_data['started_at'].present? + Rails.logger.error "Failed to create trip: Validation failed: Started at can't be blank" + return false + end + + unless trip_data['ended_at'].present? + Rails.logger.error "Failed to create trip: Validation failed: Ended at can't be blank" + return false + end + + true + rescue StandardError => e + Rails.logger.debug "Trip validation failed: #{e.message} for data: #{trip_data.inspect}" + false + end end diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index c503497f..923d0a18 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -42,11 +42,9 @@