spree_notification_providers 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/CHANGELOG.md +24 -0
- data/LICENSE +29 -0
- data/README.md +322 -0
- data/app/controllers/spree/admin/notification_channel_providers_controller.rb +152 -0
- data/app/controllers/spree/admin/notification_deliveries_controller.rb +69 -0
- data/app/controllers/spree/admin/notification_providers_controller.rb +31 -0
- data/app/controllers/spree/webhooks/gupshup_controller.rb +61 -0
- data/app/helpers/spree/admin/notification_masking_helper.rb +65 -0
- data/app/jobs/spree/notification_deliveries/purge_job.rb +26 -0
- data/app/models/concerns/spree/notification_provider_settings.rb +81 -0
- data/app/models/spree/notification_channel_provider.rb +165 -0
- data/app/models/spree/notification_delivery.rb +39 -0
- data/app/models/spree/notification_provider_config.rb +32 -0
- data/app/models/spree/notification_providers_store_decorator.rb +16 -0
- data/app/models/spree/permission_sets/notification_provider_management.rb +20 -0
- data/app/views/spree/admin/notification_channel_providers/_input_field.html.erb +51 -0
- data/app/views/spree/admin/notification_channel_providers/edit.html.erb +118 -0
- data/app/views/spree/admin/notification_deliveries/index.html.erb +127 -0
- data/app/views/spree/admin/notification_deliveries/show.html.erb +52 -0
- data/app/views/spree/admin/notification_providers/index.html.erb +114 -0
- data/config/locales/en.yml +92 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20261019000001_create_spree_notification_provider_configs.rb +19 -0
- data/db/migrate/20261019000002_create_spree_notification_channel_providers.rb +23 -0
- data/db/migrate/20261019000003_create_spree_notification_deliveries.rb +29 -0
- data/lib/generators/spree_notification_providers/import_env/import_env_generator.rb +23 -0
- data/lib/generators/spree_notification_providers/install/install_generator.rb +41 -0
- data/lib/generators/spree_notification_providers/install/templates/initializer.rb +14 -0
- data/lib/spree_notification_providers/adapters/base.rb +76 -0
- data/lib/spree_notification_providers/adapters/gupshup.rb +87 -0
- data/lib/spree_notification_providers/adapters/http_outcome.rb +61 -0
- data/lib/spree_notification_providers/adapters/log.rb +26 -0
- data/lib/spree_notification_providers/adapters/msg91.rb +117 -0
- data/lib/spree_notification_providers/adapters/smtp.rb +77 -0
- data/lib/spree_notification_providers/bulk_message.rb +33 -0
- data/lib/spree_notification_providers/configuration.rb +32 -0
- data/lib/spree_notification_providers/engine.rb +59 -0
- data/lib/spree_notification_providers/http.rb +42 -0
- data/lib/spree_notification_providers/importers/env_importer.rb +43 -0
- data/lib/spree_notification_providers/importers/writer.rb +132 -0
- data/lib/spree_notification_providers/input_definition.rb +75 -0
- data/lib/spree_notification_providers/mail_delivery_method.rb +78 -0
- data/lib/spree_notification_providers/message.rb +55 -0
- data/lib/spree_notification_providers/message_type.rb +26 -0
- data/lib/spree_notification_providers/message_types.rb +8 -0
- data/lib/spree_notification_providers/provider_definition.rb +103 -0
- data/lib/spree_notification_providers/providers/gupshup.rb +26 -0
- data/lib/spree_notification_providers/providers/log.rb +16 -0
- data/lib/spree_notification_providers/providers/msg91.rb +27 -0
- data/lib/spree_notification_providers/providers/smtp.rb +18 -0
- data/lib/spree_notification_providers/registry.rb +79 -0
- data/lib/spree_notification_providers/result.rb +73 -0
- data/lib/spree_notification_providers/router.rb +205 -0
- data/lib/spree_notification_providers/version.rb +5 -0
- data/lib/spree_notification_providers.rb +96 -0
- data/spec/factories/notification_providers.rb +91 -0
- data/spec/support/message_types.rb +15 -0
- data/spec/support/provider_http_helpers.rb +46 -0
- data/spree_notification_providers.gemspec +44 -0
- metadata +189 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
# What one adapter call returned.
|
|
5
|
+
#
|
|
6
|
+
# :delivered - the provider accepted the message.
|
|
7
|
+
# :failed - the provider definitely did not accept it; safe to try the next.
|
|
8
|
+
# :unknown - it may have reached the provider; trying another could duplicate it.
|
|
9
|
+
# :skipped - recorded by the router for providers it did not call.
|
|
10
|
+
#
|
|
11
|
+
# `reason` is a short code or provider message. Never put credentials or full
|
|
12
|
+
# response bodies in it.
|
|
13
|
+
class Result
|
|
14
|
+
OUTCOMES = %i[delivered failed unknown skipped].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :outcome, :provider_message_id, :reason
|
|
17
|
+
|
|
18
|
+
def self.delivered(provider_message_id = nil) = new(outcome: :delivered, provider_message_id: provider_message_id)
|
|
19
|
+
def self.failed(reason) = new(outcome: :failed, reason: reason)
|
|
20
|
+
def self.unknown(reason) = new(outcome: :unknown, reason: reason)
|
|
21
|
+
def self.skipped(reason) = new(outcome: :skipped, reason: reason)
|
|
22
|
+
|
|
23
|
+
def initialize(outcome:, provider_message_id: nil, reason: nil)
|
|
24
|
+
raise ArgumentError, "unknown outcome #{outcome.inspect}" unless OUTCOMES.include?(outcome)
|
|
25
|
+
|
|
26
|
+
@outcome = outcome
|
|
27
|
+
@provider_message_id = provider_message_id.presence&.to_s
|
|
28
|
+
@reason = reason.presence&.to_s&.truncate(255)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
OUTCOMES.each do |name|
|
|
32
|
+
define_method(:"#{name}?") { outcome == name }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# What `SpreeNotificationProviders.deliver` returns.
|
|
37
|
+
class DeliveryResult
|
|
38
|
+
attr_reader :outcome, :provider_key, :provider_message_id, :message_ref, :attempts
|
|
39
|
+
|
|
40
|
+
def initialize(outcome:, message_ref:, attempts:, provider_key: nil, provider_message_id: nil)
|
|
41
|
+
@outcome = outcome
|
|
42
|
+
@message_ref = message_ref
|
|
43
|
+
@attempts = attempts
|
|
44
|
+
@provider_key = provider_key
|
|
45
|
+
@provider_message_id = provider_message_id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def success? = outcome == :delivered
|
|
49
|
+
|
|
50
|
+
# True only when no provider can have taken the message, so sending again
|
|
51
|
+
# (or through another channel) cannot duplicate it.
|
|
52
|
+
def retryable? = %i[failed no_provider].include?(outcome)
|
|
53
|
+
|
|
54
|
+
def reasons
|
|
55
|
+
attempts.filter_map { |attempt| "#{attempt[:provider_key] || 'none'}: #{attempt[:reason]}" if attempt[:reason] }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# What `SpreeNotificationProviders.deliver_bulk` returns.
|
|
60
|
+
class BulkResult
|
|
61
|
+
attr_reader :outcome, :provider_key, :message_ref, :delivered_count, :failed_recipients
|
|
62
|
+
|
|
63
|
+
def initialize(outcome:, message_ref:, delivered_count:, failed_recipients:, provider_key: nil)
|
|
64
|
+
@outcome = outcome
|
|
65
|
+
@message_ref = message_ref
|
|
66
|
+
@delivered_count = delivered_count
|
|
67
|
+
@failed_recipients = failed_recipients
|
|
68
|
+
@provider_key = provider_key
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def success? = failed_recipients.empty? && outcome != :no_provider
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpreeNotificationProviders
|
|
4
|
+
# Sends messages through a store's providers in priority order.
|
|
5
|
+
#
|
|
6
|
+
# One-to-one: tries each enabled provider; providers that are not ready,
|
|
7
|
+
# have no template for the message type, or cannot send its kind are
|
|
8
|
+
# recorded as skipped. Stops at the first :delivered or :unknown, so only a
|
|
9
|
+
# definite failure moves on to the next provider.
|
|
10
|
+
#
|
|
11
|
+
# Bulk: the first enabled, ready, bulk-capable provider only. No failover.
|
|
12
|
+
#
|
|
13
|
+
# Every attempt (and every bulk recipient) is a NotificationDelivery row.
|
|
14
|
+
class Router
|
|
15
|
+
attr_reader :store
|
|
16
|
+
|
|
17
|
+
def initialize(store)
|
|
18
|
+
@store = store || SpreeNotificationProviders.configuration.default_store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def entries(channel)
|
|
22
|
+
Spree::NotificationChannelProvider
|
|
23
|
+
.where(store_id: store.id, channel: channel.to_s)
|
|
24
|
+
.includes(:store)
|
|
25
|
+
.order(:position)
|
|
26
|
+
.to_a
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def deliver(message)
|
|
30
|
+
message_ref = SecureRandom.uuid
|
|
31
|
+
attempts = []
|
|
32
|
+
final_entry = nil
|
|
33
|
+
final_result = nil
|
|
34
|
+
|
|
35
|
+
entries(message.channel).select(&:enabled?).each do |entry|
|
|
36
|
+
skip = entry.skip_reason_for(message)
|
|
37
|
+
result = if skip
|
|
38
|
+
Result.skipped(skip)
|
|
39
|
+
else
|
|
40
|
+
call_adapter(entry, template: entry.template_for(message.type, message)) do |adapter, ctx|
|
|
41
|
+
adapter.deliver(message, ctx)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
attempts << record(message, message_ref, attempts.size + 1, entry.provider_key, result)
|
|
45
|
+
next unless result.delivered? || result.unknown?
|
|
46
|
+
|
|
47
|
+
final_entry = entry
|
|
48
|
+
final_result = result
|
|
49
|
+
break
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if attempts.empty?
|
|
53
|
+
attempts << record(message, message_ref, 1, nil, Result.skipped('no_provider'))
|
|
54
|
+
outcome = :no_provider
|
|
55
|
+
elsif final_result
|
|
56
|
+
outcome = final_result.outcome
|
|
57
|
+
else
|
|
58
|
+
outcome = attempts.any? { |a| a[:outcome] == :failed } ? :failed : :no_provider
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result = DeliveryResult.new(
|
|
62
|
+
outcome: outcome, message_ref: message_ref, attempts: attempts,
|
|
63
|
+
provider_key: final_entry&.provider_key&.to_sym, provider_message_id: final_result&.provider_message_id
|
|
64
|
+
)
|
|
65
|
+
publish(result, message)
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One attempt through one entry, ignoring priority and the enabled flag.
|
|
70
|
+
# Used by the admin "Send test" action.
|
|
71
|
+
def deliver_via(entry, message)
|
|
72
|
+
message_ref = SecureRandom.uuid
|
|
73
|
+
missing = entry.missing_inputs
|
|
74
|
+
result = if entry.definition.nil?
|
|
75
|
+
Result.skipped('unavailable')
|
|
76
|
+
elsif missing.any?
|
|
77
|
+
Result.skipped("not_ready: missing #{missing.join(', ')}")
|
|
78
|
+
else
|
|
79
|
+
call_adapter(entry, template: entry.test_template) do |adapter, ctx|
|
|
80
|
+
adapter.test_message(message.channel, message.to, ctx)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
attempt = record(message, message_ref, 1, entry.provider_key, result)
|
|
84
|
+
DeliveryResult.new(outcome: result.outcome, message_ref: message_ref, attempts: [attempt],
|
|
85
|
+
provider_key: entry.provider_key.to_sym, provider_message_id: result.provider_message_id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def deliver_bulk(bulk)
|
|
89
|
+
message_ref = SecureRandom.uuid
|
|
90
|
+
entry = entries(bulk.channel).find { |candidate| candidate.enabled? && candidate.skip_reason_for(bulk).nil? }
|
|
91
|
+
|
|
92
|
+
unless entry
|
|
93
|
+
insert_bulk_rows(bulk, bulk.recipients, message_ref, nil, Result.skipped('no_provider'))
|
|
94
|
+
return BulkResult.new(outcome: :no_provider, message_ref: message_ref, delivered_count: 0,
|
|
95
|
+
failed_recipients: bulk.recipients.map { |r| { recipient: r, reason: 'no_provider' } })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
delivered = 0
|
|
99
|
+
failed = []
|
|
100
|
+
bulk.recipients.each_slice(SpreeNotificationProviders.configuration.bulk_batch_size) do |batch|
|
|
101
|
+
result = call_adapter(entry, template: entry.template_for(bulk.type, bulk)) do |adapter, ctx|
|
|
102
|
+
adapter.deliver_bulk(bulk.with_recipients(batch), ctx)
|
|
103
|
+
end
|
|
104
|
+
insert_bulk_rows(bulk, batch, message_ref, entry.provider_key, result)
|
|
105
|
+
if result.delivered?
|
|
106
|
+
delivered += batch.size
|
|
107
|
+
elsif result.failed?
|
|
108
|
+
failed.concat(batch.map { |recipient| { recipient: recipient, reason: result.reason } })
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
outcome = if failed.empty? then :delivered
|
|
113
|
+
elsif delivered.zero? then :failed
|
|
114
|
+
else :partial
|
|
115
|
+
end
|
|
116
|
+
BulkResult.new(outcome: outcome, message_ref: message_ref, delivered_count: delivered,
|
|
117
|
+
failed_recipients: failed, provider_key: entry.provider_key.to_sym)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ready_for?(channel, message_type)
|
|
121
|
+
entries(channel).any? { |entry| entry.ready_for?(message_type) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def channel_status(channel)
|
|
125
|
+
entries(channel).map do |entry|
|
|
126
|
+
{ provider_key: entry.provider_key.to_sym, position: entry.position, status: entry.status,
|
|
127
|
+
missing: entry.missing_inputs }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def call_adapter(entry, template: nil)
|
|
134
|
+
adapter = entry.definition.adapter_class.new(entry.definition)
|
|
135
|
+
context = entry.context(template: template)
|
|
136
|
+
result = yield(adapter, context)
|
|
137
|
+
result.is_a?(Result) ? result : Result.failed('invalid_adapter_result')
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
Rails.logger.error("[NotificationProviders] #{entry.provider_key} raised #{e.class}: #{e.message}")
|
|
140
|
+
Result.failed("exception:#{e.class.name}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def record(message, message_ref, attempt_number, provider_key, result)
|
|
144
|
+
write_delivery(message, message_ref, attempt_number, provider_key, result)
|
|
145
|
+
{ provider_key: provider_key&.to_sym, outcome: result.outcome, reason: result.reason }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_delivery(message, message_ref, attempt_number, provider_key, result)
|
|
149
|
+
Spree::NotificationDelivery.create!(
|
|
150
|
+
store_id: store.id,
|
|
151
|
+
message_ref: message_ref,
|
|
152
|
+
channel: message.channel.to_s,
|
|
153
|
+
provider_key: provider_key&.to_s,
|
|
154
|
+
message_type: message.type.to_s,
|
|
155
|
+
recipient: message.to.presence || '(none)',
|
|
156
|
+
outcome: result.outcome.to_s,
|
|
157
|
+
reason: result.reason,
|
|
158
|
+
provider_message_id: result.provider_message_id,
|
|
159
|
+
attempt_number: attempt_number,
|
|
160
|
+
bulk: false,
|
|
161
|
+
source: message.source.is_a?(ActiveRecord::Base) ? message.source : nil
|
|
162
|
+
)
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
# A logging failure must never stop a message from being sent.
|
|
165
|
+
Rails.logger.error("[NotificationProviders] could not record delivery: #{e.class}: #{e.message}")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def insert_bulk_rows(bulk, recipients, message_ref, provider_key, result)
|
|
169
|
+
return if recipients.empty?
|
|
170
|
+
|
|
171
|
+
now = Time.current
|
|
172
|
+
source = bulk.source.is_a?(ActiveRecord::Base) ? bulk.source : nil
|
|
173
|
+
rows = recipients.map do |recipient|
|
|
174
|
+
{
|
|
175
|
+
store_id: store.id, message_ref: message_ref, channel: bulk.channel.to_s,
|
|
176
|
+
provider_key: provider_key&.to_s, message_type: bulk.type.to_s, recipient: recipient,
|
|
177
|
+
outcome: result.outcome.to_s, reason: result.reason, provider_message_id: result.provider_message_id,
|
|
178
|
+
attempt_number: 1, bulk: true, source_type: source&.class&.base_class&.name, source_id: source&.id,
|
|
179
|
+
created_at: now
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
Spree::NotificationDelivery.insert_all(rows)
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
Rails.logger.error("[NotificationProviders] could not record bulk deliveries: #{e.class}: #{e.message}")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def publish(result, message)
|
|
188
|
+
return unless defined?(Spree::Events)
|
|
189
|
+
|
|
190
|
+
payload = {
|
|
191
|
+
'store_id' => store.id, 'message_ref' => result.message_ref, 'channel' => message.channel.to_s,
|
|
192
|
+
'provider_key' => result.provider_key&.to_s, 'message_type' => message.type.to_s,
|
|
193
|
+
'source_type' => message.source.is_a?(ActiveRecord::Base) ? message.source.class.base_class.name : nil,
|
|
194
|
+
'source_id' => message.source.is_a?(ActiveRecord::Base) ? message.source.id : nil
|
|
195
|
+
}
|
|
196
|
+
if result.success?
|
|
197
|
+
Spree::Events.publish('notification.delivered', payload)
|
|
198
|
+
elsif result.outcome != :unknown
|
|
199
|
+
Spree::Events.publish('notification.failed', payload.merge('reasons' => result.reasons))
|
|
200
|
+
end
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
Rails.logger.error("[NotificationProviders] could not publish event: #{e.class}: #{e.message}")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spree/core'
|
|
4
|
+
# Loaded first so its after_initialize (which creates the admin navigation)
|
|
5
|
+
# runs before ours registers menu entries.
|
|
6
|
+
require 'spree_admin'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
|
|
9
|
+
require 'spree_notification_providers/version'
|
|
10
|
+
|
|
11
|
+
# Per-channel notification providers with priority and failover.
|
|
12
|
+
#
|
|
13
|
+
# Providers (MSG91, SMTP, Gupshup, or one an app registers) declare the
|
|
14
|
+
# channels they serve and the inputs they need. Each store enables providers
|
|
15
|
+
# per channel and orders them; `deliver` tries them in that order and moves on
|
|
16
|
+
# only when a provider definitely did not take the message. See README.
|
|
17
|
+
module SpreeNotificationProviders
|
|
18
|
+
CHANNELS = %i[email sms whatsapp].freeze
|
|
19
|
+
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
class DuplicateProvider < Error; end
|
|
22
|
+
class UnknownChannel < Error; end
|
|
23
|
+
class DeliveryFailed < Error; end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require 'spree_notification_providers/configuration'
|
|
27
|
+
require 'spree_notification_providers/input_definition'
|
|
28
|
+
require 'spree_notification_providers/provider_definition'
|
|
29
|
+
require 'spree_notification_providers/message_type'
|
|
30
|
+
require 'spree_notification_providers/registry'
|
|
31
|
+
require 'spree_notification_providers/result'
|
|
32
|
+
require 'spree_notification_providers/message'
|
|
33
|
+
require 'spree_notification_providers/bulk_message'
|
|
34
|
+
require 'spree_notification_providers/http'
|
|
35
|
+
require 'spree_notification_providers/adapters/base'
|
|
36
|
+
require 'spree_notification_providers/adapters/http_outcome'
|
|
37
|
+
require 'spree_notification_providers/adapters/log'
|
|
38
|
+
require 'spree_notification_providers/adapters/msg91'
|
|
39
|
+
require 'spree_notification_providers/adapters/smtp'
|
|
40
|
+
require 'spree_notification_providers/adapters/gupshup'
|
|
41
|
+
require 'spree_notification_providers/router'
|
|
42
|
+
require 'spree_notification_providers/mail_delivery_method'
|
|
43
|
+
require 'spree_notification_providers/importers/env_importer'
|
|
44
|
+
|
|
45
|
+
module SpreeNotificationProviders
|
|
46
|
+
class << self
|
|
47
|
+
def configuration
|
|
48
|
+
@configuration ||= Configuration.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def configure
|
|
52
|
+
yield configuration
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def registry
|
|
56
|
+
@registry ||= Registry.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
delegate :register_provider, :unregister_provider, :provider, :providers, :providers_for,
|
|
60
|
+
:register_message_type, :message_type, :message_types, :message_types_for,
|
|
61
|
+
:register_message_resolver, :resolve_message_store, to: :registry
|
|
62
|
+
|
|
63
|
+
# Sends a one-to-one message through the store's providers for its channel,
|
|
64
|
+
# in priority order. Never raises for provider failures.
|
|
65
|
+
def deliver(store:, message:)
|
|
66
|
+
Router.new(store).deliver(message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Sends to many recipients through the top-priority ready provider only.
|
|
70
|
+
def deliver_bulk(store:, message:)
|
|
71
|
+
Router.new(store).deliver_bulk(message)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ready_for?(store, channel, message_type)
|
|
75
|
+
Router.new(store).ready_for?(channel, message_type)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def channel_status(store, channel)
|
|
79
|
+
Router.new(store).channel_status(channel)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def channel!(value)
|
|
83
|
+
channel = value.to_s.to_sym
|
|
84
|
+
raise UnknownChannel, "unknown channel #{value.inspect}" unless CHANNELS.include?(channel)
|
|
85
|
+
|
|
86
|
+
channel
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
require 'spree_notification_providers/providers/msg91'
|
|
92
|
+
require 'spree_notification_providers/providers/smtp'
|
|
93
|
+
require 'spree_notification_providers/providers/gupshup'
|
|
94
|
+
require 'spree_notification_providers/providers/log'
|
|
95
|
+
require 'spree_notification_providers/message_types'
|
|
96
|
+
require 'spree_notification_providers/engine'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FactoryBot.define do
|
|
4
|
+
factory :notification_provider_config, class: 'Spree::NotificationProviderConfig' do
|
|
5
|
+
store { Spree::Store.default || association(:store) }
|
|
6
|
+
provider_key { 'msg91' }
|
|
7
|
+
settings { {} }
|
|
8
|
+
|
|
9
|
+
transient do
|
|
10
|
+
secrets { {} }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
after(:build) do |config, evaluator|
|
|
14
|
+
config.secret_settings = evaluator.secrets.stringify_keys.to_json if evaluator.secrets.present?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
factory :notification_channel_provider, class: 'Spree::NotificationChannelProvider' do
|
|
19
|
+
store { Spree::Store.default || association(:store) }
|
|
20
|
+
channel { 'sms' }
|
|
21
|
+
provider_key { 'msg91' }
|
|
22
|
+
enabled { true }
|
|
23
|
+
settings { {} }
|
|
24
|
+
templates { {} }
|
|
25
|
+
|
|
26
|
+
transient do
|
|
27
|
+
secrets { {} }
|
|
28
|
+
shared { {} }
|
|
29
|
+
shared_secrets { {} }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
after(:build) do |entry, evaluator|
|
|
33
|
+
entry.secret_settings = evaluator.secrets.stringify_keys.to_json if evaluator.secrets.present?
|
|
34
|
+
if evaluator.shared.present? || evaluator.shared_secrets.present?
|
|
35
|
+
config = Spree::NotificationProviderConfig.find_or_initialize_by(store_id: entry.store.id,
|
|
36
|
+
provider_key: entry.provider_key)
|
|
37
|
+
config.settings = config.plain_values.merge(evaluator.shared.stringify_keys)
|
|
38
|
+
if evaluator.shared_secrets.present?
|
|
39
|
+
config.secret_settings = config.secret_values.merge(evaluator.shared_secrets.stringify_keys).to_json
|
|
40
|
+
end
|
|
41
|
+
config.save!
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# MSG91 SMS, ready to send OTPs.
|
|
46
|
+
trait :msg91_sms do
|
|
47
|
+
channel { 'sms' }
|
|
48
|
+
provider_key { 'msg91' }
|
|
49
|
+
shared_secrets { { auth_key: 'msg91-test-auth-key-123456' } }
|
|
50
|
+
templates { { 'otp_code' => 'otp-template-1' } }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
trait :msg91_email do
|
|
54
|
+
channel { 'email' }
|
|
55
|
+
provider_key { 'msg91' }
|
|
56
|
+
shared_secrets { { auth_key: 'msg91-test-auth-key-123456' } }
|
|
57
|
+
settings { { 'domain' => 'mail.example.com', 'default_from_email' => 'no-reply@mail.example.com' } }
|
|
58
|
+
templates { { 'template_email' => 'msg91-email-template' } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
trait :smtp do
|
|
62
|
+
channel { 'email' }
|
|
63
|
+
provider_key { 'smtp' }
|
|
64
|
+
settings { { 'host' => 'smtp.example.com', 'port' => '587', 'username' => 'mailer@example.com' } }
|
|
65
|
+
secrets { { password: 'smtp-test-password' } }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
trait :gupshup do
|
|
69
|
+
channel { 'whatsapp' }
|
|
70
|
+
provider_key { 'gupshup' }
|
|
71
|
+
shared_secrets { { api_key: 'gupshup-test-api-key-1234', webhook_secret: 'hook-secret-abcdef' } }
|
|
72
|
+
settings { { 'source_number' => '919876543210', 'app_name' => 'shopapp', 'app_id' => 'app-123' } }
|
|
73
|
+
templates { { 'otp_code' => 'c6aecef6-bcb0-4fb1-8100-28c094e3bc6b' } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
trait :log do
|
|
77
|
+
provider_key { 'log' }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
factory :notification_delivery, class: 'Spree::NotificationDelivery' do
|
|
82
|
+
store { Spree::Store.default || association(:store) }
|
|
83
|
+
message_ref { SecureRandom.uuid }
|
|
84
|
+
channel { 'email' }
|
|
85
|
+
provider_key { 'smtp' }
|
|
86
|
+
message_type { 'transactional_email' }
|
|
87
|
+
recipient { 'customer@example.com' }
|
|
88
|
+
outcome { 'delivered' }
|
|
89
|
+
attempt_number { 1 }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Message types the specs use. In the host app they are registered by its
|
|
4
|
+
# initializer and by spree_otp_auth; a standalone run registers them here.
|
|
5
|
+
{
|
|
6
|
+
otp_code: ['Login code', %i[email sms whatsapp], false],
|
|
7
|
+
template_email: ['Email template', %i[email], false],
|
|
8
|
+
back_in_stock: ['Back in stock', %i[email sms], false],
|
|
9
|
+
order_notification: ['Order notification', %i[email], false],
|
|
10
|
+
customer_blast: ['Customer group blast', %i[email sms], true]
|
|
11
|
+
}.each do |key, (label, channels, bulk)|
|
|
12
|
+
next if SpreeNotificationProviders.message_type(key)
|
|
13
|
+
|
|
14
|
+
SpreeNotificationProviders.register_message_type(key, label: label, channels: channels, bulk: bulk)
|
|
15
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fakes SpreeNotificationProviders::Http for adapter and router specs.
|
|
4
|
+
#
|
|
5
|
+
# stub_provider_http(status: 200, body: { 'type' => 'success' })
|
|
6
|
+
# stub_provider_http(error: Net::ReadTimeout)
|
|
7
|
+
# stub_provider_http { |method, url, opts| [200, {...}] } # per-request
|
|
8
|
+
#
|
|
9
|
+
# Every request is recorded in `provider_http_requests` as
|
|
10
|
+
# { method:, url:, headers:, query:, form:, json: }.
|
|
11
|
+
module ProviderHttpHelpers
|
|
12
|
+
FakeResponse = Struct.new(:code, :body)
|
|
13
|
+
|
|
14
|
+
def provider_http_requests
|
|
15
|
+
@provider_http_requests ||= []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stub_provider_http(status: 200, body: {}, error: nil, &block)
|
|
19
|
+
recorder = provider_http_requests
|
|
20
|
+
allow(SpreeNotificationProviders::Http).to receive(:request) do |method, url, **opts|
|
|
21
|
+
recorder << opts.merge(method: method, url: url)
|
|
22
|
+
raise error if error
|
|
23
|
+
|
|
24
|
+
code, payload = block ? block.call(method, url, opts) : [status, body]
|
|
25
|
+
raise payload if payload.is_a?(Class) && payload <= Exception
|
|
26
|
+
|
|
27
|
+
FakeResponse.new(code.to_s, payload.is_a?(String) ? payload : payload.to_json)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
RSpec.configure do |config|
|
|
33
|
+
config.include ProviderHttpHelpers
|
|
34
|
+
|
|
35
|
+
# Bundled adapters must never reach a real provider from a spec.
|
|
36
|
+
config.before do
|
|
37
|
+
allow(SpreeNotificationProviders::Http).to receive(:request)
|
|
38
|
+
.and_raise('Real provider HTTP call in a spec; use stub_provider_http')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The host app does not load engine factories itself; a standalone run (and
|
|
43
|
+
# apps using this gem's helpers) may already have them through factory_bot.
|
|
44
|
+
unless FactoryBot.factories.registered?(:notification_provider_config)
|
|
45
|
+
Dir[File.expand_path('../factories/**/*.rb', __dir__)].each { |file| require file }
|
|
46
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/spree_notification_providers/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'spree_notification_providers'
|
|
7
|
+
spec.version = SpreeNotificationProviders::VERSION
|
|
8
|
+
spec.authors = ['ONTODI']
|
|
9
|
+
spec.summary = 'Per-channel notification providers with priority and failover for Spree Commerce'
|
|
10
|
+
spec.description = <<~DESC.strip
|
|
11
|
+
Lets each store configure Email, SMS and WhatsApp providers (MSG91, SMTP, Gupshup or your own),
|
|
12
|
+
enter only the inputs each provider needs, and rank them by priority. Messages fail over to the
|
|
13
|
+
next provider on definite failures only, every attempt is recorded in an admin Delivery log, and
|
|
14
|
+
secrets are stored encrypted. Ships an Action Mailer delivery method so existing mailers route
|
|
15
|
+
through the same priority list.
|
|
16
|
+
DESC
|
|
17
|
+
spec.license = 'BSD-3-Clause'
|
|
18
|
+
|
|
19
|
+
spec.required_ruby_version = '>= 3.3'
|
|
20
|
+
|
|
21
|
+
spec.files = Dir[
|
|
22
|
+
'{app,config,db,lib}/**/*',
|
|
23
|
+
# Test helpers for apps and extensions that build on this gem.
|
|
24
|
+
'spec/support/**/*',
|
|
25
|
+
'spec/factories/**/*',
|
|
26
|
+
'README.md',
|
|
27
|
+
'CHANGELOG.md',
|
|
28
|
+
'LICENSE',
|
|
29
|
+
'spree_notification_providers.gemspec'
|
|
30
|
+
]
|
|
31
|
+
spec.require_paths = ['lib']
|
|
32
|
+
|
|
33
|
+
spec.add_dependency 'acts_as_list', '>= 1.0'
|
|
34
|
+
spec.add_dependency 'pagy', '>= 43.0'
|
|
35
|
+
spec.add_dependency 'rails', '>= 8.0'
|
|
36
|
+
spec.add_dependency 'ransack', '>= 4.0'
|
|
37
|
+
spec.add_dependency 'spree_admin', '>= 5.5'
|
|
38
|
+
spec.add_dependency 'spree_core', '>= 5.5'
|
|
39
|
+
|
|
40
|
+
spec.homepage = 'https://github.com/kalanjiyaVishnu/spree_notification_providers'
|
|
41
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
42
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
43
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
44
|
+
end
|