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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +256 -0
- data/LICENSE.txt +21 -0
- data/README.md +569 -0
- data/Rakefile +17 -0
- data/app/controllers/actionwebpush/subscriptions_controller.rb +60 -0
- data/app/models/actionwebpush/subscription.rb +164 -0
- data/config/routes.rb +5 -0
- data/db/migrate/001_create_action_web_push_subscriptions.rb +17 -0
- data/lib/actionwebpush/analytics.rb +197 -0
- data/lib/actionwebpush/authorization.rb +236 -0
- data/lib/actionwebpush/base.rb +107 -0
- data/lib/actionwebpush/batch_delivery.rb +92 -0
- data/lib/actionwebpush/configuration.rb +91 -0
- data/lib/actionwebpush/delivery_job.rb +52 -0
- data/lib/actionwebpush/delivery_methods/base.rb +19 -0
- data/lib/actionwebpush/delivery_methods/test.rb +36 -0
- data/lib/actionwebpush/delivery_methods/web_push.rb +74 -0
- data/lib/actionwebpush/engine.rb +20 -0
- data/lib/actionwebpush/error_handler.rb +99 -0
- data/lib/actionwebpush/generators/campfire_migration_generator.rb +69 -0
- data/lib/actionwebpush/generators/install_generator.rb +47 -0
- data/lib/actionwebpush/generators/templates/campfire_compatibility.rb +173 -0
- data/lib/actionwebpush/generators/templates/campfire_data_migration.rb +98 -0
- data/lib/actionwebpush/generators/templates/create_action_web_push_subscriptions.rb +17 -0
- data/lib/actionwebpush/generators/templates/initializer.rb +16 -0
- data/lib/actionwebpush/generators/vapid_keys_generator.rb +38 -0
- data/lib/actionwebpush/instrumentation.rb +31 -0
- data/lib/actionwebpush/logging.rb +38 -0
- data/lib/actionwebpush/metrics.rb +67 -0
- data/lib/actionwebpush/notification.rb +97 -0
- data/lib/actionwebpush/pool.rb +167 -0
- data/lib/actionwebpush/railtie.rb +48 -0
- data/lib/actionwebpush/rate_limiter.rb +167 -0
- data/lib/actionwebpush/sentry_integration.rb +104 -0
- data/lib/actionwebpush/status_broadcaster.rb +62 -0
- data/lib/actionwebpush/status_channel.rb +21 -0
- data/lib/actionwebpush/tenant_configuration.rb +106 -0
- data/lib/actionwebpush/test_helper.rb +68 -0
- data/lib/actionwebpush/version.rb +5 -0
- data/lib/actionwebpush.rb +78 -0
- data/sig/actionwebpush.rbs +4 -0
- 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
|