actionwebpush 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CODE_OF_CONDUCT.md +84 -0
  4. data/CONTRIBUTING.md +256 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +569 -0
  7. data/Rakefile +17 -0
  8. data/app/controllers/actionwebpush/subscriptions_controller.rb +60 -0
  9. data/app/models/actionwebpush/subscription.rb +164 -0
  10. data/config/routes.rb +5 -0
  11. data/db/migrate/001_create_action_web_push_subscriptions.rb +17 -0
  12. data/lib/actionwebpush/analytics.rb +197 -0
  13. data/lib/actionwebpush/authorization.rb +236 -0
  14. data/lib/actionwebpush/base.rb +107 -0
  15. data/lib/actionwebpush/batch_delivery.rb +92 -0
  16. data/lib/actionwebpush/configuration.rb +91 -0
  17. data/lib/actionwebpush/delivery_job.rb +52 -0
  18. data/lib/actionwebpush/delivery_methods/base.rb +19 -0
  19. data/lib/actionwebpush/delivery_methods/test.rb +36 -0
  20. data/lib/actionwebpush/delivery_methods/web_push.rb +74 -0
  21. data/lib/actionwebpush/engine.rb +20 -0
  22. data/lib/actionwebpush/error_handler.rb +99 -0
  23. data/lib/actionwebpush/generators/campfire_migration_generator.rb +69 -0
  24. data/lib/actionwebpush/generators/install_generator.rb +47 -0
  25. data/lib/actionwebpush/generators/templates/campfire_compatibility.rb +173 -0
  26. data/lib/actionwebpush/generators/templates/campfire_data_migration.rb +98 -0
  27. data/lib/actionwebpush/generators/templates/create_action_web_push_subscriptions.rb +17 -0
  28. data/lib/actionwebpush/generators/templates/initializer.rb +16 -0
  29. data/lib/actionwebpush/generators/vapid_keys_generator.rb +38 -0
  30. data/lib/actionwebpush/instrumentation.rb +31 -0
  31. data/lib/actionwebpush/logging.rb +38 -0
  32. data/lib/actionwebpush/metrics.rb +67 -0
  33. data/lib/actionwebpush/notification.rb +97 -0
  34. data/lib/actionwebpush/pool.rb +167 -0
  35. data/lib/actionwebpush/railtie.rb +48 -0
  36. data/lib/actionwebpush/rate_limiter.rb +167 -0
  37. data/lib/actionwebpush/sentry_integration.rb +104 -0
  38. data/lib/actionwebpush/status_broadcaster.rb +62 -0
  39. data/lib/actionwebpush/status_channel.rb +21 -0
  40. data/lib/actionwebpush/tenant_configuration.rb +106 -0
  41. data/lib/actionwebpush/test_helper.rb +68 -0
  42. data/lib/actionwebpush/version.rb +5 -0
  43. data/lib/actionwebpush.rb +78 -0
  44. data/sig/actionwebpush.rbs +4 -0
  45. metadata +212 -0
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class Base
5
+ include ActionWebPush::Authorization
6
+ def self.default_params
7
+ @default_params ||= {}
8
+ end
9
+
10
+ def self.default_params=(value)
11
+ @default_params = value.dup.freeze
12
+ end
13
+
14
+ def self.inherited(subclass)
15
+ # Each subclass gets its own copy of default_params
16
+ subclass.instance_variable_set(:@default_params, default_params.dup)
17
+ super
18
+ end
19
+
20
+ attr_reader :params
21
+
22
+ def initialize(**params)
23
+ @params = self.class.default_params.merge(params)
24
+ end
25
+
26
+ def self.push(subscriptions, **notification_params)
27
+ new(**notification_params).push(subscriptions)
28
+ end
29
+
30
+ def push(subscriptions, current_user: nil, **notification_params)
31
+ current_user ||= ActionWebPush::Authorization::Utils.current_user_context
32
+
33
+ # Authorization check
34
+ if current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
35
+ authorize_notification_sending!(
36
+ current_user: current_user,
37
+ subscriptions: subscriptions
38
+ )
39
+ end
40
+
41
+ if notification_params.empty?
42
+ # Called with just subscriptions, use stored params
43
+ notifications = build_notifications(subscriptions, **@params)
44
+ else
45
+ # Called with both subscriptions and notification params
46
+ notifications = build_notifications(subscriptions, **notification_params)
47
+ end
48
+
49
+ deliver_notifications(notifications)
50
+ notifications.first # Return first notification for compatibility
51
+ end
52
+
53
+ def deliver_now(subscriptions)
54
+ push(subscriptions)
55
+ end
56
+
57
+ def deliver_later(subscriptions, wait: nil, wait_until: nil, queue: nil, priority: nil)
58
+ subscriptions = Array(subscriptions)
59
+
60
+ subscriptions.each do |subscription|
61
+ job = ActionWebPush::DeliveryJob.set(
62
+ wait: wait,
63
+ wait_until: wait_until,
64
+ queue: queue || :action_web_push,
65
+ priority: priority
66
+ )
67
+
68
+ job.perform_later(params, { id: subscription.id })
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def build_notifications(subscriptions, **notification_params)
75
+ subscriptions = subscriptions.is_a?(Array) ? subscriptions : [subscriptions]
76
+ subscriptions.map do |subscription|
77
+ if subscription.respond_to?(:build_notification)
78
+ subscription.build_notification(**notification_params)
79
+ else
80
+ # Handle hash-based subscription data
81
+ ActionWebPush::Notification.new(
82
+ endpoint: subscription.is_a?(Hash) ? subscription[:endpoint] : subscription.endpoint,
83
+ p256dh_key: subscription.is_a?(Hash) ? subscription[:p256dh_key] : subscription.p256dh_key,
84
+ auth_key: subscription.is_a?(Hash) ? subscription[:auth_key] : subscription.auth_key,
85
+ **notification_params
86
+ )
87
+ end
88
+ end
89
+ end
90
+
91
+ def deliver_notifications(notifications)
92
+ if defined?(Rails) && Rails.respond_to?(:configuration) && Rails.configuration.respond_to?(:x) && Rails.configuration.x.respond_to?(:action_web_push_pool)
93
+ Rails.configuration.x.action_web_push_pool.queue(notifications)
94
+ else
95
+ # Fallback to direct delivery
96
+ delivery_method = ActionWebPush::DeliveryMethods.for(ActionWebPush.config.delivery_method)
97
+ notifications.each { |notification| delivery_method.deliver!(notification) }
98
+ end
99
+ end
100
+
101
+ def self.default(**params)
102
+ # Create a new merged hash without mutating existing params
103
+ merged_params = default_params.merge(params)
104
+ self.default_params = merged_params
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class BatchDelivery
5
+ include ActionWebPush::Logging
6
+ include ActionWebPush::Authorization
7
+ attr_reader :notifications, :pool, :batch_size, :current_user
8
+
9
+ def initialize(notifications, pool: nil, batch_size: nil, current_user: nil)
10
+ @notifications = Array(notifications)
11
+ @pool = pool || (defined?(Rails) ? Rails.configuration.x.action_web_push_pool : nil)
12
+ @batch_size = batch_size || ActionWebPush.config.batch_size || 100
13
+ @current_user = current_user || ActionWebPush::Authorization::Utils.current_user_context
14
+
15
+ # Authorization check for batch operations
16
+ if @current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
17
+ # Extract subscriptions from notifications for authorization check
18
+ subscriptions = extract_subscriptions_from_notifications(@notifications)
19
+ authorize_batch_operation!(
20
+ current_user: @current_user,
21
+ subscriptions: subscriptions
22
+ )
23
+ end
24
+ end
25
+
26
+ def deliver_all
27
+ # Process notifications in batches to avoid overwhelming the system
28
+ notifications.each_slice(batch_size) do |batch|
29
+ if pool
30
+ batch_deliver_with_pool(batch)
31
+ else
32
+ direct_batch_deliver(batch)
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.deliver(notifications, **options)
38
+ new(notifications, **options).deliver_all
39
+ end
40
+
41
+ private
42
+
43
+ def batch_deliver_with_pool(batch_notifications)
44
+ # Group notifications by endpoint to avoid overwhelming single endpoints
45
+ grouped = batch_notifications.group_by(&:endpoint)
46
+
47
+ grouped.each do |endpoint, endpoint_notifications|
48
+ # Stagger delivery to same endpoint to avoid rate limiting
49
+ endpoint_notifications.each_with_index do |notification, index|
50
+ pool.delivery_pool.post do
51
+ sleep(index * 0.01) if index > 0 # Small delay between same endpoint
52
+ deliver_single(notification)
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def direct_batch_deliver(batch_notifications)
59
+ batch_notifications.each { |notification| deliver_single(notification) }
60
+ end
61
+
62
+ def deliver_single(notification)
63
+ notification.deliver_now
64
+ rescue ActionWebPush::ExpiredSubscriptionError => e
65
+ # Handle expired subscription cleanup if we can identify the subscription
66
+ handle_expired_subscription(notification)
67
+ rescue StandardError => e
68
+ logger.error "ActionWebPush batch delivery failed: #{e.class} #{e.message}"
69
+ end
70
+
71
+ def handle_expired_subscription(notification)
72
+ # Try to find and clean up the expired subscription
73
+ subscription = ActionWebPush::Subscription.find_by(endpoint: notification.endpoint)
74
+ subscription&.destroy
75
+ rescue StandardError => e
76
+ logger.warn "Failed to cleanup expired subscription: #{e.message}"
77
+ end
78
+
79
+ def extract_subscriptions_from_notifications(notifications)
80
+ # Extract subscription information from notifications for authorization
81
+ notifications.map do |notification|
82
+ if notification.respond_to?(:endpoint)
83
+ # Try to find the subscription by endpoint
84
+ ActionWebPush::Subscription.find_by(endpoint: notification.endpoint)
85
+ else
86
+ nil
87
+ end
88
+ end.compact
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class Configuration
5
+ attr_accessor :vapid_public_key, :vapid_private_key, :vapid_subject
6
+ attr_accessor :pool_size, :queue_size, :delivery_method, :connection_pool_size, :batch_size
7
+ attr_accessor :logger, :timeout, :max_retries, :async
8
+ attr_reader :delivery_methods
9
+
10
+ def initialize
11
+ @pool_size = 50
12
+ @queue_size = 10000
13
+ @connection_pool_size = 150
14
+ @batch_size = 100
15
+ @delivery_method = :web_push
16
+ @vapid_subject = "mailto:support@example.com"
17
+ @logger = nil
18
+ @timeout = 30
19
+ @max_retries = 3
20
+ @async = false
21
+ @delivery_methods = {
22
+ web_push: ActionWebPush::DeliveryMethods::WebPush,
23
+ test: ActionWebPush::DeliveryMethods::Test
24
+ }
25
+ end
26
+
27
+ def vapid_keys
28
+ {
29
+ public_key: vapid_public_key,
30
+ private_key: vapid_private_key
31
+ }
32
+ end
33
+
34
+ def valid?
35
+ validate!
36
+ true
37
+ rescue ConfigurationError
38
+ false
39
+ end
40
+
41
+ def validate!
42
+ errors = []
43
+
44
+ # VAPID keys validation
45
+ errors << "vapid_public_key is required" if vapid_public_key.blank?
46
+ errors << "vapid_private_key is required" if vapid_private_key.blank?
47
+
48
+ if vapid_public_key.present? && vapid_public_key.length != 87
49
+ errors << "vapid_public_key must be 87 characters long (Base64 encoded)"
50
+ end
51
+
52
+ if vapid_private_key.present? && vapid_private_key.length != 43
53
+ errors << "vapid_private_key must be 43 characters long (Base64 encoded)"
54
+ end
55
+
56
+ # Email validation for vapid_subject
57
+ if vapid_subject.present? && !vapid_subject.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\z/i)
58
+ errors << "vapid_subject must be a valid email address (format: mailto:email@domain.com)"
59
+ end
60
+
61
+ # Numeric validations with reasonable bounds
62
+ errors << "pool_size must be between 1 and 1000" unless pool_size.is_a?(Integer) && pool_size.between?(1, 1000)
63
+ errors << "queue_size must be between 1 and 100000" unless queue_size.is_a?(Integer) && queue_size.between?(1, 100000)
64
+ errors << "connection_pool_size must be between 1 and 1000" unless connection_pool_size.is_a?(Integer) && connection_pool_size.between?(1, 1000)
65
+ errors << "batch_size must be between 1 and 10000" unless batch_size.is_a?(Integer) && batch_size.between?(1, 10000)
66
+ errors << "timeout must be between 1 and 300 seconds" unless timeout.is_a?(Integer) && timeout.between?(1, 300)
67
+ errors << "max_retries must be between 0 and 10" unless max_retries.is_a?(Integer) && max_retries.between?(0, 10)
68
+
69
+ # Delivery method validation
70
+ unless delivery_methods.key?(delivery_method)
71
+ available = delivery_methods.keys.join(", ")
72
+ errors << "delivery_method must be one of: #{available}"
73
+ end
74
+
75
+ # Boolean validation
76
+ unless [true, false].include?(async)
77
+ errors << "async must be true or false"
78
+ end
79
+
80
+ raise ConfigurationError, "Configuration errors: #{errors.join('; ')}" if errors.any?
81
+ end
82
+
83
+ def delivery_method_class
84
+ delivery_methods[delivery_method] || raise(ConfigurationError, "Unknown delivery method: #{delivery_method}")
85
+ end
86
+
87
+ def add_delivery_method(name, klass)
88
+ delivery_methods[name] = klass
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class DeliveryJob < ActiveJob::Base
5
+ queue_as :action_web_push
6
+
7
+ # Retry on transient failures
8
+ retry_on ActionWebPush::DeliveryError, wait: :polynomially_longer, attempts: 5
9
+ retry_on Net::TimeoutError, wait: :exponentially_longer, attempts: 3
10
+ retry_on Errno::ECONNREFUSED, wait: :exponentially_longer, attempts: 3
11
+
12
+ # Don't retry on permanent failures
13
+ discard_on ActionWebPush::ExpiredSubscriptionError
14
+ discard_on ActiveRecord::RecordNotFound
15
+
16
+ def perform(notification_params, subscription_params = nil)
17
+ if subscription_params
18
+ # Single subscription with notification data
19
+ subscription = ActionWebPush::Subscription.find(subscription_params[:id])
20
+ notification = subscription.build_notification(**notification_params)
21
+ deliver_with_error_handling(notification, subscription)
22
+ else
23
+ # Direct notification delivery
24
+ notification = ActionWebPush::Notification.new(**notification_params)
25
+ deliver_with_error_handling(notification)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def deliver_with_error_handling(notification, subscription = nil)
32
+ notification.deliver_now
33
+
34
+ logger.info "ActionWebPush delivered: #{notification.title} to #{notification.endpoint[0..50]}..."
35
+ rescue ActionWebPush::ExpiredSubscriptionError => e
36
+ # Handle expired subscription
37
+ if subscription
38
+ subscription.destroy
39
+ logger.info "ActionWebPush destroyed expired subscription: #{subscription.id}"
40
+ end
41
+ raise # Re-raise to trigger discard_on
42
+ rescue ActionWebPush::DeliveryError => e
43
+ # Log delivery error and re-raise for retry
44
+ logger.warn "ActionWebPush delivery failed (will retry): #{e.message}"
45
+ raise
46
+ rescue StandardError => e
47
+ # Catch-all for unexpected errors
48
+ logger.error "ActionWebPush unexpected error: #{e.class} #{e.message}"
49
+ raise ActionWebPush::DeliveryError, "Unexpected delivery failure: #{e.message}"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ module DeliveryMethods
5
+ class Base
6
+ include ActionWebPush::Logging
7
+
8
+ attr_reader :settings
9
+
10
+ def initialize(settings = {})
11
+ @settings = settings
12
+ end
13
+
14
+ def deliver!(notification, connection: nil)
15
+ raise NotImplementedError, "Subclasses must implement deliver!"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ module DeliveryMethods
5
+ class Test < Base
6
+ @@deliveries = []
7
+
8
+ def self.deliveries
9
+ @@deliveries
10
+ end
11
+
12
+ def self.deliveries=(value)
13
+ @@deliveries = value
14
+ end
15
+
16
+ def deliver!(notification, connection: nil)
17
+ self.class.deliveries << {
18
+ title: notification.title,
19
+ body: notification.body,
20
+ data: notification.data,
21
+ endpoint: notification.endpoint,
22
+ p256dh_key: notification.p256dh_key,
23
+ auth_key: notification.auth_key,
24
+ options: notification.options,
25
+ delivered_at: Time.now
26
+ }
27
+
28
+ logger.info "ActionWebPush::Test delivered: #{notification.title}"
29
+ end
30
+
31
+ def self.clear_deliveries!
32
+ deliveries.clear
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "web-push"
4
+
5
+ module ActionWebPush
6
+ module DeliveryMethods
7
+ class WebPush < Base
8
+ def deliver!(notification, connection: nil)
9
+ ActionWebPush::Instrumentation.instrument("notification_delivery",
10
+ endpoint: notification.endpoint,
11
+ title: notification.title,
12
+ urgency: notification.options[:urgency] || "high"
13
+ ) do |payload|
14
+ response = ::WebPush.payload_send(
15
+ message: encoded_message(notification),
16
+ endpoint: notification.endpoint,
17
+ p256dh: notification.p256dh_key,
18
+ auth: notification.auth_key,
19
+ vapid: vapid_identification,
20
+ connection: connection,
21
+ urgency: notification.options[:urgency] || "high"
22
+ )
23
+
24
+ payload[:success] = response.success?
25
+ payload[:response_code] = response.code if response.respond_to?(:code)
26
+
27
+ response.success?
28
+ end
29
+ rescue ::WebPush::ExpiredSubscription => e
30
+ context = {
31
+ endpoint: notification.endpoint,
32
+ subscription_id: notification.respond_to?(:subscription_id) ? notification.subscription_id : nil
33
+ }
34
+ raise ActionWebPush::ErrorHandler.handle_expired_subscription_error(
35
+ ActionWebPush::ExpiredSubscriptionError.new(e.message), context
36
+ )
37
+ rescue StandardError => e
38
+ context = {
39
+ endpoint: notification.endpoint,
40
+ title: notification.title,
41
+ retry_count: 0
42
+ }
43
+ raise ActionWebPush::ErrorHandler.handle_delivery_failure(
44
+ ActionWebPush::DeliveryError.new("Failed to deliver push notification: #{e.message}"), context
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def vapid_identification
51
+ config = ActionWebPush.config
52
+ {
53
+ subject: config.vapid_subject,
54
+ public_key: config.vapid_public_key,
55
+ private_key: config.vapid_private_key
56
+ }
57
+ end
58
+
59
+ def encoded_message(notification)
60
+ payload = {
61
+ title: notification.title,
62
+ options: {
63
+ body: notification.body,
64
+ icon: notification.options[:icon],
65
+ badge: notification.options[:badge],
66
+ data: notification.data
67
+ }
68
+ }
69
+
70
+ JSON.generate(payload)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ActionWebPush
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace ActionWebPush
8
+
9
+ config.generators do |g|
10
+ g.test_framework :minitest
11
+ g.orm :active_record
12
+ end
13
+
14
+ initializer "action_web_push.routes" do |app|
15
+ app.routes.prepend do
16
+ mount ActionWebPush::Engine => "/action_web_push"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ module ErrorHandler
5
+ def self.handle_delivery_error(error, context = {})
6
+ case error
7
+ when ActionWebPush::ExpiredSubscriptionError
8
+ handle_expired_subscription_error(error, context)
9
+ when ActionWebPush::RateLimitExceeded
10
+ handle_rate_limit_error(error, context)
11
+ when ActionWebPush::DeliveryError
12
+ handle_delivery_failure(error, context)
13
+ when ActionWebPush::ConfigurationError
14
+ handle_configuration_error(error, context)
15
+ else
16
+ handle_unexpected_error(error, context)
17
+ end
18
+ end
19
+
20
+ def self.handle_expired_subscription_error(error, context)
21
+ ActionWebPush::Instrumentation.publish("subscription_expired",
22
+ error: error.message,
23
+ endpoint: context[:endpoint],
24
+ subscription_id: context[:subscription_id]
25
+ )
26
+
27
+ ActionWebPush.logger.info "Subscription expired: #{error.message}"
28
+
29
+ # Cleanup subscription if possible
30
+ cleanup_expired_subscription(context[:subscription_id]) if context[:subscription_id]
31
+
32
+ error
33
+ end
34
+
35
+ def self.handle_rate_limit_error(error, context)
36
+ ActionWebPush::Instrumentation.publish("rate_limit_exceeded",
37
+ error: error.message,
38
+ resource_type: context[:resource_type],
39
+ resource_id: context[:resource_id]
40
+ )
41
+
42
+ ActionWebPush.logger.warn "Rate limit exceeded: #{error.message}"
43
+ error
44
+ end
45
+
46
+ def self.handle_delivery_failure(error, context)
47
+ ActionWebPush::Instrumentation.publish("notification_delivery_failed",
48
+ error: error.message,
49
+ error_class: error.class.name,
50
+ endpoint: context[:endpoint],
51
+ notification_title: context[:title],
52
+ retry_count: context[:retry_count] || 0
53
+ )
54
+
55
+ retry_count = context[:retry_count]
56
+ log_level = (retry_count.is_a?(Integer) && retry_count > 2) ? :error : :warn
57
+ ActionWebPush.logger.send(log_level, "Delivery failed: #{error.message}")
58
+
59
+ error
60
+ end
61
+
62
+ def self.handle_configuration_error(error, context)
63
+ ActionWebPush::Instrumentation.publish("configuration_error",
64
+ error: error.message,
65
+ context: context
66
+ )
67
+
68
+ ActionWebPush.logger.error "Configuration error: #{error.message}"
69
+ error
70
+ end
71
+
72
+ def self.handle_unexpected_error(error, context)
73
+ ActionWebPush::Instrumentation.publish("unexpected_error",
74
+ error: error.message,
75
+ error_class: error.class.name,
76
+ backtrace: error.backtrace&.first(5),
77
+ context: context
78
+ )
79
+
80
+ ActionWebPush.logger.error "Unexpected error in ActionWebPush: #{error.class} #{error.message}"
81
+
82
+ # Wrap in ActionWebPush error for consistency
83
+ ActionWebPush::DeliveryError.new("Unexpected error: #{error.message}")
84
+ end
85
+
86
+ private
87
+
88
+ def self.cleanup_expired_subscription(subscription_id)
89
+ return unless defined?(ActionWebPush::Subscription)
90
+
91
+ subscription = ActionWebPush::Subscription.find_by(id: subscription_id)
92
+ subscription&.destroy
93
+
94
+ ActionWebPush.logger.debug "Cleaned up expired subscription #{subscription_id}"
95
+ rescue StandardError => e
96
+ ActionWebPush.logger.warn "Failed to cleanup expired subscription #{subscription_id}: #{e.message}"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module ActionWebPush
6
+ module Generators
7
+ class CampfireMigrationGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Generate migration from Campfire's existing push_subscriptions to ActionWebPush"
11
+
12
+ class_option :table_name, type: :string, default: "push_subscriptions",
13
+ desc: "Name of existing Campfire table"
14
+
15
+ class_option :preserve_data, type: :boolean, default: true,
16
+ desc: "Whether to preserve existing subscription data"
17
+
18
+ def create_data_migration
19
+ template(
20
+ "campfire_data_migration.rb",
21
+ "db/migrate/#{timestamp}_migrate_campfire_push_subscriptions_to_action_web_push.rb",
22
+ migration_version: migration_version
23
+ )
24
+ end
25
+
26
+ def create_compatibility_module
27
+ template(
28
+ "campfire_compatibility.rb",
29
+ "lib/action_web_push/campfire_compatibility.rb"
30
+ )
31
+ end
32
+
33
+ def show_migration_instructions
34
+ say "\n" + "="*70
35
+ say "Campfire to ActionWebPush Migration Generated!"
36
+ say "="*70
37
+ say "\nNext steps:"
38
+ say "1. Review the generated migration file"
39
+ say "2. Backup your existing push_subscriptions data"
40
+ say "3. Run: rails db:migrate"
41
+ say "4. Update your Campfire code to use ActionWebPush"
42
+ say "5. Test thoroughly in staging environment"
43
+ say "\nCompatibility module created at: lib/action_web_push/campfire_compatibility.rb"
44
+ say "This provides backward compatibility during the transition period."
45
+ say "\nIMPORTANT: Test this migration on a copy of production data first!"
46
+ end
47
+
48
+ private
49
+
50
+ def timestamp
51
+ Time.current.strftime("%Y%m%d%H%M%S")
52
+ end
53
+
54
+ def migration_version
55
+ if Rails::VERSION::MAJOR >= 5
56
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
57
+ end
58
+ end
59
+
60
+ def old_table_name
61
+ options[:table_name]
62
+ end
63
+
64
+ def preserve_data?
65
+ options[:preserve_data]
66
+ end
67
+ end
68
+ end
69
+ end