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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +29 -0
  4. data/README.md +322 -0
  5. data/app/controllers/spree/admin/notification_channel_providers_controller.rb +152 -0
  6. data/app/controllers/spree/admin/notification_deliveries_controller.rb +69 -0
  7. data/app/controllers/spree/admin/notification_providers_controller.rb +31 -0
  8. data/app/controllers/spree/webhooks/gupshup_controller.rb +61 -0
  9. data/app/helpers/spree/admin/notification_masking_helper.rb +65 -0
  10. data/app/jobs/spree/notification_deliveries/purge_job.rb +26 -0
  11. data/app/models/concerns/spree/notification_provider_settings.rb +81 -0
  12. data/app/models/spree/notification_channel_provider.rb +165 -0
  13. data/app/models/spree/notification_delivery.rb +39 -0
  14. data/app/models/spree/notification_provider_config.rb +32 -0
  15. data/app/models/spree/notification_providers_store_decorator.rb +16 -0
  16. data/app/models/spree/permission_sets/notification_provider_management.rb +20 -0
  17. data/app/views/spree/admin/notification_channel_providers/_input_field.html.erb +51 -0
  18. data/app/views/spree/admin/notification_channel_providers/edit.html.erb +118 -0
  19. data/app/views/spree/admin/notification_deliveries/index.html.erb +127 -0
  20. data/app/views/spree/admin/notification_deliveries/show.html.erb +52 -0
  21. data/app/views/spree/admin/notification_providers/index.html.erb +114 -0
  22. data/config/locales/en.yml +92 -0
  23. data/config/routes.rb +17 -0
  24. data/db/migrate/20261019000001_create_spree_notification_provider_configs.rb +19 -0
  25. data/db/migrate/20261019000002_create_spree_notification_channel_providers.rb +23 -0
  26. data/db/migrate/20261019000003_create_spree_notification_deliveries.rb +29 -0
  27. data/lib/generators/spree_notification_providers/import_env/import_env_generator.rb +23 -0
  28. data/lib/generators/spree_notification_providers/install/install_generator.rb +41 -0
  29. data/lib/generators/spree_notification_providers/install/templates/initializer.rb +14 -0
  30. data/lib/spree_notification_providers/adapters/base.rb +76 -0
  31. data/lib/spree_notification_providers/adapters/gupshup.rb +87 -0
  32. data/lib/spree_notification_providers/adapters/http_outcome.rb +61 -0
  33. data/lib/spree_notification_providers/adapters/log.rb +26 -0
  34. data/lib/spree_notification_providers/adapters/msg91.rb +117 -0
  35. data/lib/spree_notification_providers/adapters/smtp.rb +77 -0
  36. data/lib/spree_notification_providers/bulk_message.rb +33 -0
  37. data/lib/spree_notification_providers/configuration.rb +32 -0
  38. data/lib/spree_notification_providers/engine.rb +59 -0
  39. data/lib/spree_notification_providers/http.rb +42 -0
  40. data/lib/spree_notification_providers/importers/env_importer.rb +43 -0
  41. data/lib/spree_notification_providers/importers/writer.rb +132 -0
  42. data/lib/spree_notification_providers/input_definition.rb +75 -0
  43. data/lib/spree_notification_providers/mail_delivery_method.rb +78 -0
  44. data/lib/spree_notification_providers/message.rb +55 -0
  45. data/lib/spree_notification_providers/message_type.rb +26 -0
  46. data/lib/spree_notification_providers/message_types.rb +8 -0
  47. data/lib/spree_notification_providers/provider_definition.rb +103 -0
  48. data/lib/spree_notification_providers/providers/gupshup.rb +26 -0
  49. data/lib/spree_notification_providers/providers/log.rb +16 -0
  50. data/lib/spree_notification_providers/providers/msg91.rb +27 -0
  51. data/lib/spree_notification_providers/providers/smtp.rb +18 -0
  52. data/lib/spree_notification_providers/registry.rb +79 -0
  53. data/lib/spree_notification_providers/result.rb +73 -0
  54. data/lib/spree_notification_providers/router.rb +205 -0
  55. data/lib/spree_notification_providers/version.rb +5 -0
  56. data/lib/spree_notification_providers.rb +96 -0
  57. data/spec/factories/notification_providers.rb +91 -0
  58. data/spec/support/message_types.rb +15 -0
  59. data/spec/support/provider_http_helpers.rb +46 -0
  60. data/spree_notification_providers.gemspec +44 -0
  61. metadata +189 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ module Adapters
5
+ # WhatsApp template messages through Gupshup (https://gupshup.io).
6
+ #
7
+ # The send endpoint takes the template UUID. A template configured by name
8
+ # is resolved through the app's template list (needs the app ID) and cached.
9
+ # A "submitted" reply only means Gupshup accepted the message; delivery
10
+ # failures arrive later on the webhook (Spree::Webhooks::GupshupController).
11
+ class Gupshup < Base
12
+ HOST = 'https://api.gupshup.io'
13
+ SEND_PATH = '/wa/api/v1/template/msg'
14
+ TEMPLATE_CACHE_TTL = 12.hours
15
+ UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
16
+
17
+ def self.template_cache_key(store)
18
+ "notification_providers:gupshup_template:#{store.id}"
19
+ end
20
+
21
+ def self.clear_template_cache(store)
22
+ Rails.cache.delete(template_cache_key(store))
23
+ end
24
+
25
+ def deliver(message, context)
26
+ template_id = begin
27
+ resolve_template_id(context)
28
+ rescue StandardError => e
29
+ # Nothing has been sent yet, so any lookup error is a definite failure.
30
+ return Result.failed("template_lookup_failed: #{HttpOutcome.from_error(e).reason}")
31
+ end
32
+ return Result.failed('template_not_found') if template_id.blank?
33
+
34
+ params = message.kind == :otp ? [message.code.to_s] : Array(message.variables.to_h.values).map(&:to_s)
35
+ response = Http.request(
36
+ :post, "#{HOST}#{SEND_PATH}",
37
+ headers: { 'apikey' => context[:api_key].to_s },
38
+ form: {
39
+ 'channel' => 'whatsapp',
40
+ 'source' => context[:source_number],
41
+ 'destination' => message.to.gsub(/\D/, ''),
42
+ 'src.name' => context[:app_name],
43
+ 'template' => { id: template_id, params: params }.to_json
44
+ }
45
+ )
46
+ body = Http.parse_json(response.body)
47
+ accepted = body['status'] == 'submitted'
48
+ provider_message = accepted ? nil : scrub("status #{body['status'].presence || 'unknown'}", context)
49
+ HttpOutcome.from_response(response, body, accepted: accepted, message_id: body['messageId'],
50
+ provider_message: provider_message)
51
+ rescue StandardError => e
52
+ HttpOutcome.from_error(e)
53
+ end
54
+
55
+ private
56
+
57
+ def resolve_template_id(context)
58
+ template = context.template.to_s.strip
59
+ return template if template.match?(UUID_FORMAT)
60
+ return nil if template.blank? || context[:app_id].blank?
61
+
62
+ cache_key = self.class.template_cache_key(context.store)
63
+ cached = Rails.cache.read(cache_key)
64
+ return cached[:id] if cached.is_a?(Hash) && cached[:name] == template && cached[:app_id] == context[:app_id]
65
+
66
+ id = lookup_template_id(template, context)
67
+ if id
68
+ Rails.cache.write(cache_key, { name: template, app_id: context[:app_id], id: id },
69
+ expires_in: TEMPLATE_CACHE_TTL)
70
+ end
71
+ id
72
+ end
73
+
74
+ def lookup_template_id(name, context)
75
+ response = Http.request(
76
+ :get, "#{HOST}/wa/app/#{ERB::Util.url_encode(context[:app_id])}/template",
77
+ headers: { 'apikey' => context[:api_key].to_s }, query: { templateStatus: 'APPROVED' }
78
+ )
79
+ return nil unless response.code.to_i.between?(200, 299)
80
+
81
+ templates = Array(Http.parse_json(response.body)['templates'])
82
+ match = templates.find { |t| t['elementName'] == name && t['status'].to_s.casecmp('APPROVED').zero? }
83
+ match&.dig('id')
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'socket'
5
+
6
+ module SpreeNotificationProviders
7
+ module Adapters
8
+ # Turns an HTTP response or transport error into a Result.
9
+ #
10
+ # The router fails over only on :failed, so anything that might have
11
+ # reached the provider is :unknown. See research R5.
12
+ module HttpOutcome
13
+ # The request never reached the provider.
14
+ DEFINITE_ERRORS = {
15
+ Net::OpenTimeout => 'connect_timeout',
16
+ Errno::ECONNREFUSED => 'connection_refused',
17
+ Errno::EHOSTUNREACH => 'host_unreachable',
18
+ Errno::ENETUNREACH => 'network_unreachable',
19
+ SocketError => 'dns_error',
20
+ OpenSSL::SSL::SSLError => 'tls_error'
21
+ }.freeze
22
+
23
+ # The request may have reached the provider.
24
+ AMBIGUOUS_ERRORS = {
25
+ Net::ReadTimeout => 'read_timeout',
26
+ Net::WriteTimeout => 'write_timeout',
27
+ Errno::ECONNRESET => 'connection_reset',
28
+ Errno::EPIPE => 'broken_pipe',
29
+ EOFError => 'connection_closed'
30
+ }.freeze
31
+
32
+ module_function
33
+
34
+ def from_error(error)
35
+ DEFINITE_ERRORS.each { |klass, code| return Result.failed(code) if error.is_a?(klass) }
36
+ AMBIGUOUS_ERRORS.each { |klass, code| return Result.unknown(code) if error.is_a?(klass) }
37
+
38
+ Result.failed("exception:#{error.class.name}")
39
+ end
40
+
41
+ # @param accepted [Boolean] the adapter's reading of a 2xx body
42
+ # @param provider_message [String, nil] short provider error text (already scrubbed)
43
+ def from_response(response, body, accepted:, message_id: nil, provider_message: nil)
44
+ code = response.code.to_i
45
+ return Result.delivered(message_id) if code.between?(200, 299) && accepted
46
+
47
+ detail = provider_message.presence
48
+ if code.between?(200, 299)
49
+ Result.failed(['rejected', detail].compact.join(': '))
50
+ elsif code.between?(400, 499)
51
+ Result.failed(["http_#{code}", detail].compact.join(': '))
52
+ elsif body.present?
53
+ # A 5xx that explains itself was a rejection.
54
+ Result.failed(["http_#{code}", detail].compact.join(': '))
55
+ else
56
+ Result.unknown("http_#{code}")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ module Adapters
5
+ # Writes the message to the Rails log instead of sending it. Registered
6
+ # only when `config.enable_log_provider` is true (non-production default).
7
+ class Log < Base
8
+ def deliver(message, context)
9
+ Rails.logger.info(
10
+ "[NotificationProviders::Log] store=#{context.store&.id} channel=#{message.channel} " \
11
+ "type=#{message.type} kind=#{message.kind} to=#{message.to}" \
12
+ "#{" code=#{message.code}" if message.code}#{" subject=#{message.subject.inspect}" if message.subject}"
13
+ )
14
+ Result.delivered("sim-#{SecureRandom.hex(8)}")
15
+ end
16
+
17
+ def deliver_bulk(bulk, context)
18
+ Rails.logger.info(
19
+ "[NotificationProviders::Log] store=#{context.store&.id} channel=#{bulk.channel} type=#{bulk.type} " \
20
+ "bulk recipients=#{bulk.recipients.size}"
21
+ )
22
+ Result.delivered("sim-#{SecureRandom.hex(8)}")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ module Adapters
5
+ # MSG91 (https://msg91.com):
6
+ # email - Email API, provider-side template + variables
7
+ # sms - OTP API for codes, Flow API for templates (DLT-approved)
8
+ # whatsapp - OTP API with whatsapp=1
9
+ class Msg91 < Base
10
+ OTP_URL = 'https://control.msg91.com/api/v5/otp'
11
+ FLOW_URL = 'https://control.msg91.com/api/v5/flow'
12
+ EMAIL_URL = 'https://control.msg91.com/api/v5/email/send'
13
+
14
+ def deliver(message, context)
15
+ case message.channel
16
+ when :email then send_email(message.to, message.to_name, message, context)
17
+ when :sms then message.kind == :otp ? send_otp(message, context) : send_flow([message.to], message, context)
18
+ when :whatsapp then send_otp(message, context, whatsapp: true)
19
+ end
20
+ rescue StandardError => e
21
+ HttpOutcome.from_error(e)
22
+ end
23
+
24
+ def deliver_bulk(bulk, context)
25
+ case bulk.channel
26
+ when :email then send_email_batch(bulk.recipients, bulk, context)
27
+ when :sms then send_flow(bulk.recipients, bulk, context)
28
+ else Result.failed('unsupported')
29
+ end
30
+ rescue StandardError => e
31
+ HttpOutcome.from_error(e)
32
+ end
33
+
34
+ private
35
+
36
+ def send_otp(message, context, whatsapp: false)
37
+ query = {
38
+ template_id: context.template,
39
+ mobile: international(message.to, context),
40
+ otp: message.code,
41
+ otp_expiry: message.expiry_minutes || 10
42
+ }
43
+ query[:sender] = context[:sender_id] if !whatsapp && context[:sender_id].present?
44
+ query[:whatsapp] = 1 if whatsapp
45
+
46
+ response = Http.request(:post, OTP_URL, headers: auth(context), query: query)
47
+ body = Http.parse_json(response.body)
48
+ accepted = body['type'] == 'success' || body['message_id'].present?
49
+ outcome(response, body, accepted, body['message_id'] || body['request_id'], context)
50
+ end
51
+
52
+ def send_flow(phones, message, context)
53
+ variables = message.variables.to_h.stringify_keys
54
+ payload = {
55
+ template_id: context.template,
56
+ short_url: '1',
57
+ recipients: phones.map { |phone| { 'mobiles' => international(phone, context) }.merge(variables) }
58
+ }
59
+ response = Http.request(:post, FLOW_URL, headers: auth(context), json: payload)
60
+ body = Http.parse_json(response.body)
61
+ # A successful Flow reply carries the request id in "message".
62
+ message_id = body['type'] == 'success' ? body['message'] : body['request_id']
63
+ outcome(response, body, body['type'] != 'error', message_id, context)
64
+ end
65
+
66
+ def send_email(to, to_name, message, context)
67
+ send_email_payload([{ to: [{ email: to, name: to_name.presence || to }.compact], variables: message.variables }],
68
+ message, context)
69
+ end
70
+
71
+ def send_email_batch(emails, bulk, context)
72
+ send_email_payload(emails.map { |email| { to: [{ email: email }], variables: bulk.variables } }, bulk, context)
73
+ end
74
+
75
+ def send_email_payload(recipients, message, context)
76
+ from_email = message.from_email || context[:default_from_email]
77
+ payload = {
78
+ recipients: recipients,
79
+ from: { name: message.from_name || context.store&.name, email: from_email }.compact,
80
+ domain: sending_domain(message, context),
81
+ template_id: context.template
82
+ }
83
+ reply_to = message.respond_to?(:reply_to) ? message.reply_to : nil
84
+ payload[:reply_to] = [{ email: reply_to }] if reply_to.present?
85
+
86
+ response = Http.request(:post, EMAIL_URL, headers: auth(context), json: payload)
87
+ body = Http.parse_json(response.body)
88
+ accepted = body['hasError'] != true && body['status'] != 'fail'
89
+ outcome(response, body, accepted, body.dig('data', 'unique_id') || body['request_id'], context)
90
+ end
91
+
92
+ # The domain of the message's own sender (it must be verified in MSG91),
93
+ # else the configured sending domain.
94
+ def sending_domain(message, context)
95
+ own = message.from_email.to_s.split('@', 2).last if message.from_email.to_s.include?('@')
96
+ own.presence || context[:domain].presence || context[:default_from_email].to_s.split('@', 2).last
97
+ end
98
+
99
+ def outcome(response, body, accepted, message_id, context)
100
+ provider_message = scrub(body['message'].presence || body['errors'].to_s.presence, context)
101
+ HttpOutcome.from_response(response, body, accepted: accepted, message_id: message_id,
102
+ provider_message: provider_message)
103
+ end
104
+
105
+ def auth(context)
106
+ { 'authkey' => context[:auth_key].to_s, 'accept' => 'application/json' }
107
+ end
108
+
109
+ # MSG91 wants country-code-prefixed numbers; a national 10-digit number
110
+ # gets the channel's default country code.
111
+ def international(phone, context)
112
+ digits = phone.to_s.gsub(/\D/, '')
113
+ digits.length == 10 ? "#{context[:default_country_code].presence || '91'}#{digits}" : digits
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+ require 'net/smtp'
5
+
6
+ module SpreeNotificationProviders
7
+ module Adapters
8
+ # Sends rendered email over the store's own SMTP account. Never touches
9
+ # the global Action Mailer configuration.
10
+ class Smtp < Base
11
+ DEFINITE_SMTP_ERRORS = {
12
+ 'Net::SMTPAuthenticationError' => 'smtp_auth',
13
+ 'Net::SMTPFatalError' => 'smtp_rejected',
14
+ 'Net::SMTPServerBusy' => 'smtp_busy',
15
+ 'Net::SMTPSyntaxError' => 'smtp_syntax',
16
+ 'Net::SMTPUnknownError' => 'smtp_unknown_reply'
17
+ }.freeze
18
+
19
+ def deliver(message, context)
20
+ mail = build_mail(message, context)
21
+ mail.delivery_method(:smtp, smtp_settings(context))
22
+ mail.deliver!
23
+ Result.delivered(mail.message_id)
24
+ rescue StandardError => e
25
+ classify(e, context)
26
+ end
27
+
28
+ private
29
+
30
+ def build_mail(message, context)
31
+ mail = message.mail ? message.mail.dup : Mail.new
32
+ unless message.mail
33
+ mail.to = message.to
34
+ mail.subject = message.subject
35
+ mail.html_part = Mail::Part.new do
36
+ content_type 'text/html; charset=UTF-8'
37
+ body message.html.to_s
38
+ end
39
+ end
40
+
41
+ if message.from_email
42
+ mail.from = message.from_name ? "#{message.from_name} <#{message.from_email}>" : message.from_email
43
+ elsif mail.from.blank? && (address = default_from(context))
44
+ mail.from = context.store&.name.present? ? "#{context.store.name} <#{address}>" : address
45
+ end
46
+ mail.reply_to = message.reply_to if message.reply_to
47
+ mail
48
+ end
49
+
50
+ # The from address input, else the SMTP username when it is an address.
51
+ def default_from(context)
52
+ context[:from_address].presence ||
53
+ (context[:username] if context[:username].to_s.match?(InputDefinition::EMAIL_FORMAT))
54
+ end
55
+
56
+ def smtp_settings(context)
57
+ {
58
+ address: context[:host],
59
+ port: context[:port],
60
+ user_name: context[:username],
61
+ password: context[:password],
62
+ authentication: context[:authentication].presence&.to_sym || :plain,
63
+ enable_starttls_auto: context[:starttls] != false,
64
+ open_timeout: SpreeNotificationProviders.configuration.http_timeout_seconds,
65
+ read_timeout: SpreeNotificationProviders.configuration.http_timeout_seconds
66
+ }
67
+ end
68
+
69
+ def classify(error, context)
70
+ code = DEFINITE_SMTP_ERRORS[error.class.name]
71
+ return Result.failed([code, scrub(error.message, context)].compact.join(': ')) if code
72
+
73
+ HttpOutcome.from_error(error)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # One template message to many recipients (e.g. a customer group blast).
5
+ # The same `variables` apply to every recipient.
6
+ class BulkMessage
7
+ attr_reader :channel, :type, :recipients, :variables, :templates, :source, :from_email, :from_name
8
+
9
+ def initialize(channel:, type:, recipients:, variables: {}, templates: {}, source: nil, from_email: nil,
10
+ from_name: nil)
11
+ @channel = SpreeNotificationProviders.channel!(channel)
12
+ @type = type.to_sym
13
+ @recipients = Array(recipients).map { |r| r.to_s.strip }.compact_blank.uniq
14
+ @variables = variables || {}
15
+ @templates = (templates || {}).transform_keys(&:to_s).compact_blank
16
+ @source = source
17
+ @from_email = from_email.presence
18
+ @from_name = from_name.presence
19
+ end
20
+
21
+ def kind = :bulk
22
+ def bulk? = true
23
+
24
+ def template_override(provider_key)
25
+ templates[provider_key.to_s]
26
+ end
27
+
28
+ def with_recipients(batch)
29
+ self.class.new(channel: channel, type: type, recipients: batch, variables: variables, templates: templates,
30
+ source: source, from_email: from_email, from_name: from_name)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ # Set in config/initializers/spree_notification_providers.rb:
5
+ #
6
+ # SpreeNotificationProviders.configure do |config|
7
+ # config.delivery_retention_days = 90
8
+ # end
9
+ class Configuration
10
+ attr_reader :delivery_retention_days
11
+ attr_accessor :enable_log_provider, :http_timeout_seconds, :default_store_resolver, :bulk_batch_size
12
+
13
+ def initialize
14
+ @delivery_retention_days = 90
15
+ @enable_log_provider = !(defined?(Rails) && Rails.env.production?)
16
+ @http_timeout_seconds = 5
17
+ @bulk_batch_size = 100
18
+ @default_store_resolver = -> { Spree::Current.store || Spree::Store.default }
19
+ end
20
+
21
+ def delivery_retention_days=(value)
22
+ days = Integer(value)
23
+ raise ArgumentError, 'delivery_retention_days must be >= 1' if days < 1
24
+
25
+ @delivery_retention_days = days
26
+ end
27
+
28
+ def default_store
29
+ default_store_resolver.call
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ class Engine < ::Rails::Engine
5
+ engine_name 'spree_notification_providers'
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ # Secrets arrive in these params; keep them out of the log lines even in
12
+ # apps that trimmed Rails' default filter list.
13
+ initializer 'spree_notification_providers.filter_parameters' do |app|
14
+ app.config.filter_parameters += %i[secret_settings shared_secret_settings secret]
15
+ end
16
+
17
+ initializer 'spree_notification_providers.mail_delivery_method' do
18
+ ActiveSupport.on_load(:action_mailer) do
19
+ add_delivery_method :spree_notification_providers, SpreeNotificationProviders::MailDeliveryMethod
20
+ end
21
+ end
22
+
23
+ # After the app's initializers, so `enable_log_provider` set there counts.
24
+ config.after_initialize do
25
+ SpreeNotificationProviders.register_log_provider! if SpreeNotificationProviders.configuration.enable_log_provider
26
+ SpreeNotificationProviders.register_admin_navigation!
27
+ end
28
+
29
+ def self.activate
30
+ Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c|
31
+ Rails.configuration.cache_classes ? require(c) : load(c)
32
+ end
33
+ end
34
+
35
+ config.to_prepare(&method(:activate).to_proc)
36
+ end
37
+
38
+ def self.register_admin_navigation!
39
+ return unless Spree.respond_to?(:admin) && Spree.admin.respond_to?(:navigation)
40
+
41
+ nav = Spree.admin.navigation
42
+ return if nav.nil?
43
+
44
+ nav.settings.add :notification_providers,
45
+ label: :notification_providers,
46
+ icon: 'send',
47
+ url: :admin_notification_providers_path,
48
+ position: 76,
49
+ active: -> { controller_path.in?(%w[spree/admin/notification_providers spree/admin/notification_channel_providers]) },
50
+ if: -> { can?(:manage, Spree::NotificationChannelProvider) }
51
+ nav.settings.add :notification_deliveries,
52
+ label: :notification_deliveries,
53
+ icon: 'list-check',
54
+ url: :admin_notification_deliveries_path,
55
+ position: 77,
56
+ active: -> { controller_path == 'spree/admin/notification_deliveries' },
57
+ if: -> { can?(:manage, Spree::NotificationDelivery) }
58
+ end
59
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module SpreeNotificationProviders
8
+ # The one place bundled adapters make HTTP calls, so timeouts are uniform
9
+ # and specs can stub a single method.
10
+ module Http
11
+ module_function
12
+
13
+ # @param form [Hash] sent as application/x-www-form-urlencoded
14
+ # @param json [Hash] sent as application/json
15
+ def request(method, url, headers: {}, query: nil, form: nil, json: nil)
16
+ uri = URI(url)
17
+ uri.query = URI.encode_www_form(query) if query.present?
18
+ klass = method.to_sym == :get ? Net::HTTP::Get : Net::HTTP::Post
19
+ req = klass.new(uri.request_uri)
20
+ headers.each { |name, value| req[name] = value }
21
+ if form
22
+ req.set_form_data(form)
23
+ elsif json
24
+ req['Content-Type'] = 'application/json'
25
+ req.body = json.to_json
26
+ end
27
+
28
+ timeout = SpreeNotificationProviders.configuration.http_timeout_seconds
29
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: timeout,
30
+ read_timeout: timeout, write_timeout: timeout) do |http|
31
+ http.request(req)
32
+ end
33
+ end
34
+
35
+ def parse_json(raw)
36
+ parsed = JSON.parse(raw.to_s)
37
+ parsed.is_a?(Hash) ? parsed : {}
38
+ rescue JSON::ParserError
39
+ {}
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spree_notification_providers/importers/writer'
4
+
5
+ module SpreeNotificationProviders
6
+ module Importers
7
+ # Copies the environment variables named by each provider input's `env:`
8
+ # into one store's configuration. Fills blanks only; safe to rerun.
9
+ # A provider with any value found gets an enabled entry on each of its
10
+ # channels (an entry missing inputs or templates simply shows as not ready).
11
+ #
12
+ # bin/rails g spree_notification_providers:import_env --store=<code>
13
+ class EnvImporter
14
+ def initialize(store, env: ENV, dry_run: false)
15
+ @env = env
16
+ @writer = Writer.new(store, dry_run: dry_run)
17
+ end
18
+
19
+ def call
20
+ SpreeNotificationProviders.providers.each do |definition|
21
+ shared_found = import_inputs(definition, :shared, definition.shared_inputs)
22
+ definition.channels.each do |channel, capability|
23
+ channel_found = import_inputs(definition, channel, capability.inputs)
24
+ @writer.ensure_entry(definition.key, channel, enabled: true) if shared_found || channel_found
25
+ end
26
+ end
27
+ @writer.save!
28
+ @writer
29
+ end
30
+
31
+ private
32
+
33
+ # @return [Boolean] whether any input had a value in the environment
34
+ def import_inputs(definition, scope, inputs)
35
+ inputs.map do |input|
36
+ value = input.env.present? ? @env[input.env].to_s.strip.presence : nil
37
+ @writer.set_input(definition.key, scope, input.name, value, source: "ENV #{input.env}") if value
38
+ value.present?
39
+ end.any?
40
+ end
41
+ end
42
+ end
43
+ end