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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "net/http/persistent"
5
+
6
+ module ActionWebPush
7
+ class Pool
8
+ include ActionWebPush::Logging
9
+ attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler
10
+ attr_accessor :overflow_count, :total_queued_count
11
+
12
+ def initialize(invalid_subscription_handler: nil)
13
+ config = ActionWebPush.config
14
+
15
+ @delivery_pool = Concurrent::ThreadPoolExecutor.new(
16
+ max_threads: config.pool_size,
17
+ queue_size: config.queue_size
18
+ )
19
+ @invalidation_pool = Concurrent::FixedThreadPool.new(1)
20
+ @connection = Net::HTTP::Persistent.new(name: "action_web_push", pool_size: config.connection_pool_size)
21
+ @invalid_subscription_handler = invalid_subscription_handler
22
+
23
+ # Initialize metrics
24
+ @overflow_count = 0
25
+ @total_queued_count = 0
26
+ @overflow_mutex = Mutex.new
27
+ end
28
+
29
+ def queue(notifications, subscriptions = nil)
30
+ if subscriptions
31
+ # Multiple subscriptions with same notification data
32
+ subscriptions.find_each do |subscription|
33
+ notification = subscription.build_notification(notifications)
34
+ deliver_later(notification, subscription.id)
35
+ end
36
+ elsif notifications.is_a?(Array)
37
+ # Array of notifications
38
+ notifications.each { |notification| deliver_later(notification) }
39
+ else
40
+ # Single notification
41
+ deliver_later(notifications)
42
+ end
43
+ end
44
+
45
+ def shutdown
46
+ connection.shutdown
47
+ shutdown_pool(delivery_pool)
48
+ shutdown_pool(invalidation_pool)
49
+ log_final_metrics
50
+ end
51
+
52
+ def metrics
53
+ @overflow_mutex.synchronize do
54
+ {
55
+ total_queued: @total_queued_count,
56
+ overflow_count: @overflow_count,
57
+ overflow_rate: @total_queued_count > 0 ? (@overflow_count.to_f / @total_queued_count * 100).round(2) : 0.0,
58
+ pool_queue_size: delivery_pool.queue_length,
59
+ pool_active_threads: delivery_pool.length,
60
+ pool_max_threads: delivery_pool.max_length
61
+ }
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def deliver_later(notification, subscription_id = nil)
68
+ @overflow_mutex.synchronize { @total_queued_count += 1 }
69
+
70
+ delivery_pool.post do
71
+ deliver(notification, subscription_id)
72
+ rescue Exception => e
73
+ logger.error "Error in ActionWebPush::Pool.deliver: #{e.class} #{e.message}"
74
+ end
75
+ rescue Concurrent::RejectedExecutionError
76
+ handle_queue_overflow(notification, subscription_id)
77
+ end
78
+
79
+ def deliver(notification, subscription_id = nil)
80
+ notification.deliver(connection: connection)
81
+ rescue ActionWebPush::ExpiredSubscriptionError => e
82
+ context = {
83
+ endpoint: notification.respond_to?(:endpoint) ? notification.endpoint : nil,
84
+ subscription_id: subscription_id
85
+ }
86
+ ActionWebPush::ErrorHandler.handle_expired_subscription_error(e, context)
87
+ invalidate_subscription_later(subscription_id) if subscription_id && invalid_subscription_handler
88
+ rescue ActionWebPush::RateLimitExceeded => e
89
+ context = {
90
+ resource_type: :endpoint,
91
+ resource_id: notification.respond_to?(:endpoint) ? notification.endpoint : nil
92
+ }
93
+ ActionWebPush::ErrorHandler.handle_rate_limit_error(e, context)
94
+ raise e
95
+ rescue ActionWebPush::DeliveryError => e
96
+ context = {
97
+ endpoint: notification.respond_to?(:endpoint) ? notification.endpoint : nil,
98
+ title: notification.respond_to?(:title) ? notification.title : nil,
99
+ retry_count: 0
100
+ }
101
+ ActionWebPush::ErrorHandler.handle_delivery_failure(e, context)
102
+ raise e
103
+ rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => e
104
+ # Handle legacy WebPush exceptions
105
+ context = {
106
+ endpoint: notification.respond_to?(:endpoint) ? notification.endpoint : nil,
107
+ subscription_id: subscription_id
108
+ }
109
+ error = ActionWebPush::ExpiredSubscriptionError.new(e.message)
110
+ ActionWebPush::ErrorHandler.handle_expired_subscription_error(error, context)
111
+ invalidate_subscription_later(subscription_id) if subscription_id && invalid_subscription_handler
112
+ rescue StandardError => e
113
+ context = {
114
+ endpoint: notification.respond_to?(:endpoint) ? notification.endpoint : nil,
115
+ title: notification.respond_to?(:title) ? notification.title : nil
116
+ }
117
+ handled_error = ActionWebPush::ErrorHandler.handle_unexpected_error(e, context)
118
+ raise handled_error
119
+ end
120
+
121
+ def invalidate_subscription_later(subscription_id)
122
+ invalidation_pool.post do
123
+ invalid_subscription_handler.call(subscription_id)
124
+ rescue Exception => e
125
+ logger.error "Error in ActionWebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}"
126
+ end
127
+ end
128
+
129
+ def handle_queue_overflow(notification, subscription_id = nil)
130
+ @overflow_mutex.synchronize { @overflow_count += 1 }
131
+
132
+ overflow_rate = @overflow_mutex.synchronize { (@overflow_count.to_f / @total_queued_count * 100).round(2) }
133
+
134
+ # Instrument the overflow event
135
+ ActionWebPush::Instrumentation.publish("pool_overflow",
136
+ overflow_count: @overflow_count,
137
+ total_queued: @total_queued_count,
138
+ overflow_rate: overflow_rate,
139
+ queue_length: delivery_pool.queue_length,
140
+ active_threads: delivery_pool.length,
141
+ max_threads: delivery_pool.max_length,
142
+ notification_title: notification.respond_to?(:title) ? notification.title : nil
143
+ )
144
+
145
+ logger.warn "ActionWebPush::Pool queue overflow (#{@overflow_count}/#{@total_queued_count}, #{overflow_rate}%): dropping notification"
146
+
147
+ # Log additional context for debugging
148
+ logger.warn "Pool stats: queue=#{delivery_pool.queue_length}, active=#{delivery_pool.length}/#{delivery_pool.max_length}"
149
+
150
+ # TODO: Could implement fallback strategies here:
151
+ # - Store in Redis for retry
152
+ # - Send to DLQ
153
+ # - Immediate synchronous delivery for critical notifications
154
+ end
155
+
156
+ def log_final_metrics
157
+ stats = metrics
158
+ logger.info "ActionWebPush::Pool shutdown metrics: #{stats}"
159
+ end
160
+
161
+ def shutdown_pool(pool)
162
+ pool.shutdown
163
+ pool.kill unless pool.wait_for_termination(1)
164
+ end
165
+
166
+ end
167
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActionWebPush
6
+ class Railtie < ::Rails::Railtie
7
+ config.action_web_push = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "action_web_push.set_configs" do |app|
10
+ options = app.config.action_web_push
11
+
12
+ ActionWebPush.configure do |config|
13
+ config.vapid_public_key = options.vapid_public_key if options.vapid_public_key
14
+ config.vapid_private_key = options.vapid_private_key if options.vapid_private_key
15
+ config.vapid_subject = options.vapid_subject if options.vapid_subject
16
+ config.pool_size = options.pool_size if options.pool_size
17
+ config.queue_size = options.queue_size if options.queue_size
18
+ config.delivery_method = options.delivery_method if options.delivery_method
19
+ end
20
+ end
21
+
22
+ initializer "action_web_push.initialize_pool" do |app|
23
+ app.config.x.action_web_push_pool = ActionWebPush::Pool.new(
24
+ invalid_subscription_handler: ->(subscription_id) do
25
+ Rails.application.executor.wrap do
26
+ Rails.logger.info "Destroying push subscription: #{subscription_id}"
27
+ ActionWebPush::Subscription.find_by(id: subscription_id)&.destroy
28
+ end
29
+ end
30
+ )
31
+
32
+ at_exit { app.config.x.action_web_push_pool.shutdown }
33
+ end
34
+
35
+ initializer "action_web_push.set_autoload_paths" do |app|
36
+ models_path = File.expand_path("../../app/models", __dir__)
37
+ controllers_path = File.expand_path("../../app/controllers", __dir__)
38
+
39
+ unless app.config.autoload_paths.include?(models_path)
40
+ app.config.autoload_paths += [models_path]
41
+ end
42
+
43
+ unless app.config.autoload_paths.include?(controllers_path)
44
+ app.config.autoload_paths += [controllers_path]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis" if defined?(Redis)
4
+
5
+ module ActionWebPush
6
+ class RateLimiter
7
+ class MemoryStore
8
+ CLEANUP_INTERVAL = 300 # 5 minutes
9
+
10
+ def initialize
11
+ @store = {}
12
+ @mutex = Mutex.new
13
+ @last_cleanup = Time.now
14
+ end
15
+
16
+ def increment(key, ttl)
17
+ @mutex.synchronize do
18
+ # Periodic automatic cleanup
19
+ auto_cleanup! if should_cleanup?
20
+
21
+ @store[key] ||= { count: 0, expires_at: Time.now + ttl }
22
+
23
+ if @store[key][:expires_at] < Time.now
24
+ @store[key] = { count: 1, expires_at: Time.now + ttl }
25
+ else
26
+ @store[key][:count] += 1
27
+ end
28
+
29
+ @store[key][:count]
30
+ end
31
+ end
32
+
33
+ def cleanup!
34
+ @mutex.synchronize do
35
+ auto_cleanup!
36
+ end
37
+ end
38
+
39
+ def get(key)
40
+ @mutex.synchronize do
41
+ entry = @store[key]
42
+ return 0 unless entry
43
+ return 0 if entry[:expires_at] < Time.now
44
+ entry[:count]
45
+ end
46
+ end
47
+
48
+ def size
49
+ @mutex.synchronize { @store.size }
50
+ end
51
+
52
+ private
53
+
54
+ def should_cleanup?
55
+ Time.now - @last_cleanup > CLEANUP_INTERVAL
56
+ end
57
+
58
+ def auto_cleanup!
59
+ before_count = @store.size
60
+ @store.reject! { |_, v| v[:expires_at] < Time.now }
61
+ @last_cleanup = Time.now
62
+
63
+ # Log significant cleanups
64
+ cleaned = before_count - @store.size
65
+ if cleaned > 0 && defined?(ActionWebPush) && ActionWebPush.respond_to?(:logger)
66
+ ActionWebPush.logger.debug "ActionWebPush::RateLimiter cleaned up #{cleaned} expired entries (#{@store.size} remaining)"
67
+ end
68
+ end
69
+ end
70
+
71
+ class RedisStore
72
+ def initialize(redis_client = nil)
73
+ @redis = redis_client || Redis.current
74
+ end
75
+
76
+ def increment(key, ttl)
77
+ result = @redis.multi do |multi|
78
+ multi.incr(key)
79
+ multi.expire(key, ttl.to_i)
80
+ end
81
+ result[0]
82
+ end
83
+
84
+ def get(key)
85
+ value = @redis.get(key)
86
+ value ? value.to_i : 0
87
+ end
88
+ end
89
+
90
+ attr_reader :store, :limits
91
+
92
+ def initialize(store: nil, limits: {})
93
+ @store = store || (defined?(Redis) ? RedisStore.new : MemoryStore.new)
94
+ @limits = default_limits.merge(limits)
95
+ end
96
+
97
+ def check_rate_limit!(resource_type, resource_id, user_id = nil)
98
+ limit_key = rate_limit_key(resource_type, resource_id, user_id)
99
+ limit_config = limits[resource_type]
100
+
101
+ return true unless limit_config
102
+
103
+ current_count = store.increment(limit_key, limit_config[:window])
104
+
105
+ if current_count > limit_config[:max_requests]
106
+ # Instrument rate limit exceeded event
107
+ ActionWebPush::Instrumentation.publish("rate_limit_exceeded",
108
+ resource_type: resource_type,
109
+ resource_id: resource_id,
110
+ user_id: user_id,
111
+ current_count: current_count,
112
+ max_requests: limit_config[:max_requests],
113
+ window: limit_config[:window]
114
+ )
115
+
116
+ raise ActionWebPush::RateLimitExceeded,
117
+ "Rate limit exceeded for #{resource_type}: #{current_count}/#{limit_config[:max_requests]} in #{limit_config[:window]}s"
118
+ end
119
+
120
+ true
121
+ end
122
+
123
+ def within_rate_limit?(resource_type, resource_id, user_id = nil)
124
+ check_rate_limit!(resource_type, resource_id, user_id)
125
+ true
126
+ rescue ActionWebPush::RateLimitExceeded
127
+ false
128
+ end
129
+
130
+ def rate_limit_info(resource_type, resource_id, user_id = nil)
131
+ limit_key = rate_limit_key(resource_type, resource_id, user_id)
132
+ limit_config = limits[resource_type]
133
+
134
+ return nil unless limit_config
135
+
136
+ # Use atomic read-only get instead of increment-subtract
137
+ current_count = store.get(limit_key)
138
+
139
+ {
140
+ limit: limit_config[:max_requests],
141
+ remaining: [limit_config[:max_requests] - current_count, 0].max,
142
+ window: limit_config[:window],
143
+ reset_at: Time.now + limit_config[:window]
144
+ }
145
+ end
146
+
147
+ private
148
+
149
+ def default_limits
150
+ {
151
+ endpoint: { max_requests: 100, window: 3600 }, # 100 per hour per endpoint
152
+ user: { max_requests: 1000, window: 3600 }, # 1000 per hour per user
153
+ global: { max_requests: 10000, window: 3600 }, # 10k per hour globally
154
+ subscription: { max_requests: 50, window: 3600 } # 50 per hour per subscription
155
+ }
156
+ end
157
+
158
+ def rate_limit_key(resource_type, resource_id, user_id = nil)
159
+ parts = ["action_web_push", "rate_limit", resource_type.to_s]
160
+ parts << "user_#{user_id}" if user_id
161
+ parts << resource_id.to_s
162
+ parts.join(":")
163
+ end
164
+ end
165
+
166
+ class RateLimitExceeded < Error; end
167
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class SentryIntegration
5
+ class << self
6
+ def configure
7
+ return unless defined?(Sentry)
8
+
9
+ Sentry.configure_scope do |scope|
10
+ scope.set_tag("component", "action_web_push")
11
+ end
12
+ end
13
+
14
+ def capture_delivery_error(notification, error, context = {})
15
+ return unless defined?(Sentry)
16
+
17
+ Sentry.with_scope do |scope|
18
+ scope.set_tag("action_web_push_event", "delivery_error")
19
+ scope.set_context("notification", notification_context(notification))
20
+ scope.set_context("delivery_context", context)
21
+
22
+ if error.is_a?(ActionWebPush::ExpiredSubscriptionError)
23
+ scope.set_level(:info)
24
+ scope.set_tag("error_type", "expired_subscription")
25
+ elsif error.is_a?(ActionWebPush::RateLimitExceeded)
26
+ scope.set_level(:warning)
27
+ scope.set_tag("error_type", "rate_limit_exceeded")
28
+ else
29
+ scope.set_level(:error)
30
+ scope.set_tag("error_type", "delivery_failure")
31
+ end
32
+
33
+ Sentry.capture_exception(error)
34
+ end
35
+ end
36
+
37
+ def capture_performance_metrics(metrics)
38
+ return unless defined?(Sentry)
39
+
40
+ Sentry.with_scope do |scope|
41
+ scope.set_tag("action_web_push_event", "performance_metrics")
42
+ scope.set_context("metrics", metrics)
43
+
44
+ if metrics[:success_rate] < 95.0
45
+ Sentry.capture_message(
46
+ "ActionWebPush success rate below threshold: #{metrics[:success_rate]}%",
47
+ level: :warning
48
+ )
49
+ end
50
+ end
51
+ end
52
+
53
+ def capture_subscription_event(event_type, subscription, details = {})
54
+ return unless defined?(Sentry)
55
+
56
+ Sentry.with_scope do |scope|
57
+ scope.set_tag("action_web_push_event", event_type.to_s)
58
+ scope.set_context("subscription", subscription_context(subscription))
59
+ scope.set_context("event_details", details)
60
+
61
+ case event_type
62
+ when :expired
63
+ scope.set_level(:info)
64
+ Sentry.capture_message("Push subscription expired", level: :info)
65
+ when :created
66
+ scope.set_level(:info)
67
+ Sentry.capture_message("Push subscription created", level: :info)
68
+ when :bulk_cleanup
69
+ scope.set_level(:info)
70
+ Sentry.capture_message("Bulk subscription cleanup performed", level: :info)
71
+ end
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def notification_context(notification)
78
+ {
79
+ title: notification.title&.truncate(100),
80
+ endpoint_domain: extract_domain(notification.endpoint),
81
+ has_data: notification.data.present?,
82
+ options_keys: notification.options.keys
83
+ }
84
+ end
85
+
86
+ def subscription_context(subscription)
87
+ {
88
+ id: subscription.id,
89
+ endpoint_domain: extract_domain(subscription.endpoint),
90
+ user_agent: subscription.user_agent&.truncate(100),
91
+ created_at: subscription.created_at,
92
+ updated_at: subscription.updated_at,
93
+ active: subscription.active?
94
+ }
95
+ end
96
+
97
+ def extract_domain(endpoint)
98
+ URI.parse(endpoint).host
99
+ rescue StandardError
100
+ "unknown"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class StatusBroadcaster
5
+ class << self
6
+ def broadcast_delivery_status(user_id, notification_id, status, details = {})
7
+ return unless defined?(ActionCable)
8
+
9
+ ActionCable.server.broadcast(
10
+ "action_web_push_status_#{user_id}",
11
+ {
12
+ type: "delivery_status",
13
+ notification_id: notification_id,
14
+ status: status,
15
+ timestamp: Time.current.iso8601,
16
+ details: details
17
+ }
18
+ )
19
+ end
20
+
21
+ def broadcast_delivery_attempt(user_id, notification_id, subscription_count)
22
+ broadcast_delivery_status(
23
+ user_id,
24
+ notification_id,
25
+ "attempting",
26
+ { subscription_count: subscription_count }
27
+ )
28
+ end
29
+
30
+ def broadcast_delivery_success(user_id, notification_id, delivered_count)
31
+ broadcast_delivery_status(
32
+ user_id,
33
+ notification_id,
34
+ "delivered",
35
+ { delivered_count: delivered_count }
36
+ )
37
+ end
38
+
39
+ def broadcast_delivery_failure(user_id, notification_id, error_message, failed_count = 1)
40
+ broadcast_delivery_status(
41
+ user_id,
42
+ notification_id,
43
+ "failed",
44
+ { error: error_message, failed_count: failed_count }
45
+ )
46
+ end
47
+
48
+ def broadcast_subscription_expired(user_id, subscription_id)
49
+ return unless defined?(ActionCable)
50
+
51
+ ActionCable.server.broadcast(
52
+ "action_web_push_status_#{user_id}",
53
+ {
54
+ type: "subscription_expired",
55
+ subscription_id: subscription_id,
56
+ timestamp: Time.current.iso8601
57
+ }
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class StatusChannel < ActionCable::Channel::Base
5
+ def subscribed
6
+ stream_from "action_web_push_status_#{current_user&.id}" if current_user
7
+ end
8
+
9
+ def unsubscribed
10
+ # Cleanup when channel is unsubscribed
11
+ end
12
+
13
+ private
14
+
15
+ def current_user
16
+ # This should be implemented based on your authentication system
17
+ # Example: connection.current_user
18
+ nil
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionWebPush
4
+ class TenantConfiguration
5
+ attr_accessor :tenant_id, :vapid_public_key, :vapid_private_key, :vapid_subject
6
+ attr_accessor :pool_size, :queue_size, :delivery_method, :rate_limits
7
+ attr_accessor :webhook_url, :custom_settings
8
+
9
+ def initialize(tenant_id, **options)
10
+ @tenant_id = tenant_id
11
+ @vapid_public_key = options[:vapid_public_key]
12
+ @vapid_private_key = options[:vapid_private_key]
13
+ @vapid_subject = options[:vapid_subject] || "mailto:support@example.com"
14
+ @pool_size = options[:pool_size] || 50
15
+ @queue_size = options[:queue_size] || 10000
16
+ @delivery_method = options[:delivery_method] || :web_push
17
+ @rate_limits = options[:rate_limits] || {}
18
+ @webhook_url = options[:webhook_url]
19
+ @custom_settings = options[:custom_settings] || {}
20
+ end
21
+
22
+ def valid?
23
+ vapid_public_key.present? && vapid_private_key.present?
24
+ end
25
+
26
+ def vapid_keys
27
+ {
28
+ public_key: vapid_public_key,
29
+ private_key: vapid_private_key,
30
+ subject: vapid_subject
31
+ }
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ tenant_id: tenant_id,
37
+ vapid_public_key: vapid_public_key,
38
+ vapid_private_key: vapid_private_key,
39
+ vapid_subject: vapid_subject,
40
+ pool_size: pool_size,
41
+ queue_size: queue_size,
42
+ delivery_method: delivery_method,
43
+ rate_limits: rate_limits,
44
+ webhook_url: webhook_url,
45
+ custom_settings: custom_settings
46
+ }
47
+ end
48
+ end
49
+
50
+ class TenantManager
51
+ class << self
52
+ attr_accessor :configurations
53
+
54
+ def configure_tenant(tenant_id, **options)
55
+ self.configurations ||= {}
56
+ configurations[tenant_id] = TenantConfiguration.new(tenant_id, **options)
57
+ end
58
+
59
+ def configuration_for(tenant_id)
60
+ configurations&.[](tenant_id) || raise(ConfigurationError, "No configuration found for tenant: #{tenant_id}")
61
+ end
62
+
63
+ def tenant_exists?(tenant_id)
64
+ configurations&.key?(tenant_id) || false
65
+ end
66
+
67
+ def all_tenants
68
+ configurations&.keys || []
69
+ end
70
+
71
+ def reset!
72
+ self.configurations = {}
73
+ end
74
+ end
75
+ end
76
+
77
+ module TenantAware
78
+ extend ActiveSupport::Concern
79
+
80
+ included do
81
+ class_attribute :tenant_column, default: :tenant_id
82
+
83
+ scope :for_tenant, ->(tenant_id) { where(tenant_column => tenant_id) }
84
+
85
+ before_validation :set_tenant_id, if: :should_set_tenant_id?
86
+ end
87
+
88
+ class_methods do
89
+ def tenant_aware(column: :tenant_id)
90
+ self.tenant_column = column
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def should_set_tenant_id?
97
+ respond_to?(tenant_column) &&
98
+ public_send(tenant_column).blank? &&
99
+ ActionWebPush.current_tenant_id.present?
100
+ end
101
+
102
+ def set_tenant_id
103
+ public_send("#{tenant_column}=", ActionWebPush.current_tenant_id)
104
+ end
105
+ end
106
+ end