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,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Receives Gupshup message events. Gupshup only learns that a number isn't
|
|
6
|
+
# on WhatsApp after accepting the send, so a "failed" event marks the
|
|
7
|
+
# delivery failed and publishes `notification.delivery_failed` (which, for
|
|
8
|
+
# login codes, triggers the email backup).
|
|
9
|
+
#
|
|
10
|
+
# Callbacks are unsigned; the URL carries the store's Gupshup webhook
|
|
11
|
+
# secret (Notification providers → Gupshup → Webhook secret), filtered
|
|
12
|
+
# from logs:
|
|
13
|
+
# POST /webhooks/gupshup?secret=<webhook secret>
|
|
14
|
+
#
|
|
15
|
+
# The secret is checked against the store that owns the message, found
|
|
16
|
+
# from its delivery row or, for messages sent before this gem, through a
|
|
17
|
+
# registered message resolver.
|
|
18
|
+
class GupshupController < ActionController::API
|
|
19
|
+
def create
|
|
20
|
+
event = request.request_parameters.presence || parse_body
|
|
21
|
+
return head(:ok) unless failure_event?(event)
|
|
22
|
+
|
|
23
|
+
message_ids = [event.dig('payload', 'gsId'), event.dig('payload', 'id')].compact_blank
|
|
24
|
+
delivery = Spree::NotificationDelivery.where(provider_key: 'gupshup', provider_message_id: message_ids).first
|
|
25
|
+
store = delivery&.store || message_ids.lazy.filter_map do |id|
|
|
26
|
+
SpreeNotificationProviders.resolve_message_store(:gupshup, id)
|
|
27
|
+
end.first
|
|
28
|
+
return head(:not_found) unless store && secret_matches?(store)
|
|
29
|
+
|
|
30
|
+
reason = event.dig('payload', 'payload', 'reason').to_s.truncate(120)
|
|
31
|
+
delivery&.mark_failed!(reason)
|
|
32
|
+
Spree::Events.publish('notification.delivery_failed', {
|
|
33
|
+
'store_id' => store.id, 'delivery_id' => delivery&.id,
|
|
34
|
+
'provider_message_id' => delivery&.provider_message_id || message_ids.first,
|
|
35
|
+
'reason' => reason
|
|
36
|
+
})
|
|
37
|
+
head :ok
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def failure_event?(event)
|
|
43
|
+
event.is_a?(Hash) && event['type'] == 'message-event' &&
|
|
44
|
+
event['payload'].is_a?(Hash) && event['payload']['type'] == 'failed'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def secret_matches?(store)
|
|
48
|
+
entry = Spree::NotificationProviderConfig.find_by(store_id: store.id, provider_key: 'gupshup')
|
|
49
|
+
input = SpreeNotificationProviders.provider(:gupshup)&.shared_inputs&.find { |i| i.name == :webhook_secret }
|
|
50
|
+
expected = entry && input ? entry.raw_value(input).to_s : ''
|
|
51
|
+
expected.present? && ActiveSupport::SecurityUtils.secure_compare(expected, params[:secret].to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_body
|
|
55
|
+
JSON.parse(request.raw_post.presence || '{}')
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Admin
|
|
5
|
+
module NotificationMaskingHelper
|
|
6
|
+
MASK = '•••'
|
|
7
|
+
|
|
8
|
+
# "Set ••••1234" for long values, "Set" for short ones, so a short secret
|
|
9
|
+
# is never mostly revealed.
|
|
10
|
+
def secret_hint(value)
|
|
11
|
+
return nil if value.blank?
|
|
12
|
+
|
|
13
|
+
value.to_s.length >= 12 ? "••••#{value.to_s[-4..]}" : ''
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# ka•••@gmail.com, +91•••••3210
|
|
17
|
+
def mask_recipient(value)
|
|
18
|
+
value = value.to_s
|
|
19
|
+
return value if value.blank? || value.start_with?('(')
|
|
20
|
+
|
|
21
|
+
if value.include?('@')
|
|
22
|
+
value.split(',').map { |address| mask_email(address.strip) }.join(', ')
|
|
23
|
+
else
|
|
24
|
+
mask_phone(value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def notification_status_badge(status)
|
|
29
|
+
classes = {
|
|
30
|
+
ready: 'bg-green-100 text-green-800',
|
|
31
|
+
delivered: 'bg-green-100 text-green-800',
|
|
32
|
+
not_ready: 'bg-amber-100 text-amber-800',
|
|
33
|
+
unknown: 'bg-amber-100 text-amber-800',
|
|
34
|
+
failed: 'bg-red-100 text-red-800',
|
|
35
|
+
unavailable: 'bg-red-100 text-red-800',
|
|
36
|
+
disabled: 'bg-gray-100 text-gray-600',
|
|
37
|
+
skipped: 'bg-gray-100 text-gray-600'
|
|
38
|
+
}
|
|
39
|
+
key = status.to_sym
|
|
40
|
+
scope = %i[ready not_ready disabled unavailable].include?(key) ? 'statuses' : 'outcomes'
|
|
41
|
+
content_tag(:span, t("spree_notification_providers.#{scope}.#{key}"),
|
|
42
|
+
class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium #{classes[key]}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def notification_channel_label(channel)
|
|
46
|
+
t("spree_notification_providers.channels.#{channel}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def mask_email(address)
|
|
52
|
+
local, domain = address.split('@', 2)
|
|
53
|
+
"#{local.to_s.first(2)}#{MASK}@#{domain}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def mask_phone(value)
|
|
57
|
+
digits = value.gsub(/\D/, '')
|
|
58
|
+
return "#{MASK}#{digits.last(2)}" if digits.length < 8
|
|
59
|
+
|
|
60
|
+
country = digits.length > 10 ? digits[0...(digits.length - 10)] : ''
|
|
61
|
+
"#{"+#{country}" if country.present?}•••••#{digits.last(4)}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module NotificationDeliveries
|
|
5
|
+
# Removes Delivery log rows older than `delivery_retention_days`.
|
|
6
|
+
# Schedule daily (e.g. sidekiq-cron: `0 4 * * *`).
|
|
7
|
+
class PurgeJob < ActiveJob::Base
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
BATCH_SIZE = 1_000
|
|
11
|
+
|
|
12
|
+
def perform
|
|
13
|
+
days = SpreeNotificationProviders.configuration.delivery_retention_days
|
|
14
|
+
deleted = 0
|
|
15
|
+
loop do
|
|
16
|
+
ids = Spree::NotificationDelivery.older_than(days).limit(BATCH_SIZE).pluck(:id)
|
|
17
|
+
break if ids.empty?
|
|
18
|
+
|
|
19
|
+
deleted += Spree::NotificationDelivery.where(id: ids).delete_all
|
|
20
|
+
end
|
|
21
|
+
Rails.logger.info("[NotificationProviders] purged #{deleted} delivery rows older than #{days} days")
|
|
22
|
+
deleted
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# Input storage shared by provider configs and channel entries: plain
|
|
5
|
+
# values in the `settings` jsonb column, secret values as a JSON object in
|
|
6
|
+
# the encrypted `secret_settings` text column.
|
|
7
|
+
module NotificationProviderSettings
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
encrypts :secret_settings
|
|
12
|
+
attr_reader :input_errors
|
|
13
|
+
|
|
14
|
+
after_initialize { @input_errors = {} }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def plain_values
|
|
18
|
+
(settings || {}).to_h.stringify_keys
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def secret_values
|
|
22
|
+
parsed = JSON.parse(secret_settings.presence || '{}')
|
|
23
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def raw_value(input)
|
|
29
|
+
(input.secret? ? secret_values : plain_values)[input.key].presence
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_raw(input, value)
|
|
33
|
+
if input.secret?
|
|
34
|
+
values = secret_values
|
|
35
|
+
value.present? ? values[input.key] = value.to_s : values.delete(input.key)
|
|
36
|
+
self.secret_settings = values.to_json
|
|
37
|
+
else
|
|
38
|
+
values = plain_values
|
|
39
|
+
value.present? ? values[input.key] = value.to_s : values.delete(input.key)
|
|
40
|
+
self.settings = values
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Applies a form submit. A blank secret keeps the stored value; a secret
|
|
45
|
+
# listed in `clear` is removed. Plain inputs not present in `values` are
|
|
46
|
+
# left alone.
|
|
47
|
+
def assign_inputs(inputs, values: {}, secrets: {}, clear: [])
|
|
48
|
+
values = values.to_h.stringify_keys
|
|
49
|
+
secrets = secrets.to_h.stringify_keys
|
|
50
|
+
clear = Array(clear).map(&:to_s)
|
|
51
|
+
|
|
52
|
+
inputs.each do |input|
|
|
53
|
+
if input.secret?
|
|
54
|
+
if clear.include?(input.key)
|
|
55
|
+
write_raw(input, nil)
|
|
56
|
+
elsif secrets[input.key].to_s.strip.present?
|
|
57
|
+
write_raw(input, secrets[input.key].to_s.strip)
|
|
58
|
+
end
|
|
59
|
+
elsif values.key?(input.key)
|
|
60
|
+
write_raw(input, values[input.key].to_s.strip)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def secret_set?(input)
|
|
66
|
+
raw_value(input).present?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate_inputs(inputs)
|
|
72
|
+
inputs.each do |input|
|
|
73
|
+
message = input.validate(raw_value(input))
|
|
74
|
+
next unless message
|
|
75
|
+
|
|
76
|
+
input_errors[input.key] = message
|
|
77
|
+
errors.add(:base, "#{input.label} #{message}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# A store's use of one provider on one channel: on/off, priority position,
|
|
5
|
+
# channel inputs and templates per message type.
|
|
6
|
+
class NotificationChannelProvider < Spree::Base
|
|
7
|
+
include Spree::NotificationProviderSettings
|
|
8
|
+
|
|
9
|
+
has_prefix_id :nchp
|
|
10
|
+
|
|
11
|
+
CHANNELS = %w[email sms whatsapp].freeze
|
|
12
|
+
STATUSES = %i[ready not_ready disabled unavailable].freeze
|
|
13
|
+
|
|
14
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
15
|
+
|
|
16
|
+
acts_as_list scope: %i[store_id channel], add_new_at: :bottom
|
|
17
|
+
|
|
18
|
+
validates :channel, inclusion: { in: CHANNELS }
|
|
19
|
+
validates :provider_key, presence: true, uniqueness: { scope: %i[store_id channel] }
|
|
20
|
+
validate :provider_supports_channel
|
|
21
|
+
validate { validate_inputs(channel_inputs) if capability }
|
|
22
|
+
validate :templates_valid
|
|
23
|
+
|
|
24
|
+
scope :enabled, -> { where(enabled: true) }
|
|
25
|
+
scope :for_channel, ->(channel) { where(channel: channel.to_s).order(:position) }
|
|
26
|
+
|
|
27
|
+
# Registry -------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def definition
|
|
30
|
+
SpreeNotificationProviders.provider(provider_key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def capability
|
|
34
|
+
definition&.capability(channel)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def channel_inputs
|
|
38
|
+
capability&.inputs || []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def shared_inputs
|
|
42
|
+
definition&.shared_inputs || []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def provider_config
|
|
46
|
+
@provider_config ||= Spree::NotificationProviderConfig.find_or_create_by!(store_id: store_id,
|
|
47
|
+
provider_key: provider_key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def provider_config=(config)
|
|
51
|
+
@provider_config = config
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def template_capable?
|
|
55
|
+
capability.present? && (capability.template_required? || capability.supports_kind?(:template) ||
|
|
56
|
+
capability.supports_kind?(:otp))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Values ---------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
# Shared + channel inputs, secrets decrypted, defaults applied.
|
|
62
|
+
def values
|
|
63
|
+
config = provider_config
|
|
64
|
+
shared = shared_inputs.to_h { |input| [input.name, input.resolve(config.raw_value(input))] }
|
|
65
|
+
own = channel_inputs.to_h { |input| [input.name, input.resolve(raw_value(input))] }
|
|
66
|
+
shared.merge(own)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def template_for(message_type, message = nil)
|
|
70
|
+
message&.template_override(provider_key).presence || templates.to_h[message_type.to_s].presence
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Used by "Send test": the test template if set, else any configured one.
|
|
74
|
+
def test_template
|
|
75
|
+
templates.to_h['provider_test'].presence || templates.to_h.values.find(&:present?)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def context(template: nil)
|
|
79
|
+
SpreeNotificationProviders::Adapters::Context.new(
|
|
80
|
+
store: store, channel: channel.to_sym, settings: values, template: template,
|
|
81
|
+
templates: templates.to_h
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Readiness ------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def missing_inputs
|
|
88
|
+
return [] unless definition
|
|
89
|
+
|
|
90
|
+
config = provider_config
|
|
91
|
+
missing = shared_inputs.select { |i| i.required? && i.resolve(config.raw_value(i)).blank? }.map(&:label)
|
|
92
|
+
missing += channel_inputs.select { |i| i.required? && i.resolve(raw_value(i)).blank? }.map(&:label)
|
|
93
|
+
missing += capability.extra_missing(values, templates.to_h) if capability
|
|
94
|
+
missing.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def ready?
|
|
98
|
+
enabled? && definition.present? && capability.present? && missing_inputs.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ready_for?(message_type, message = nil)
|
|
102
|
+
ready? && skip_reason_for(message, message_type: message_type).nil?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Why the router would skip this entry for the message, or nil.
|
|
106
|
+
def skip_reason_for(message, message_type: nil)
|
|
107
|
+
return 'unavailable' if definition.nil? || capability.nil?
|
|
108
|
+
|
|
109
|
+
missing = missing_inputs
|
|
110
|
+
return "not_ready: missing #{missing.join(', ')}" if missing.any?
|
|
111
|
+
|
|
112
|
+
kind = effective_kind(message)
|
|
113
|
+
return 'unsupported' if kind && !capability.supports_kind?(kind)
|
|
114
|
+
|
|
115
|
+
type = message&.type || message_type
|
|
116
|
+
return 'no_template' if capability.template_required? && template_for(type, message).blank?
|
|
117
|
+
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# An HTML email that names this provider's template is sent as that
|
|
122
|
+
# template (e.g. an email template with a MSG91 template id).
|
|
123
|
+
def effective_kind(message)
|
|
124
|
+
kind = message&.kind
|
|
125
|
+
if kind == :html && message.template_override(provider_key).present? && capability&.supports_kind?(:template)
|
|
126
|
+
:template
|
|
127
|
+
else
|
|
128
|
+
kind
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def status
|
|
133
|
+
return :unavailable if definition.nil? || capability.nil?
|
|
134
|
+
return :disabled unless enabled?
|
|
135
|
+
|
|
136
|
+
missing_inputs.empty? ? :ready : :not_ready
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Used by: the channels whose entries share this provider's config.
|
|
140
|
+
def shared_with_channels
|
|
141
|
+
Spree::NotificationChannelProvider.where(store_id: store_id, provider_key: provider_key)
|
|
142
|
+
.order(:channel).pluck(:channel)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def provider_supports_channel
|
|
148
|
+
if definition.nil?
|
|
149
|
+
errors.add(:provider_key, 'is not a registered provider')
|
|
150
|
+
elsif !definition.supports?(channel)
|
|
151
|
+
errors.add(:channel, "is not supported by #{definition.label}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def templates_valid
|
|
156
|
+
return unless capability&.template_format
|
|
157
|
+
|
|
158
|
+
templates.to_h.each do |type, value|
|
|
159
|
+
next if value.blank? || capability.template_valid?(value)
|
|
160
|
+
|
|
161
|
+
errors.add(:base, "Template for #{type} is not in the expected format")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# One attempt to send a message (or one recipient of a bulk send).
|
|
5
|
+
class NotificationDelivery < Spree::Base
|
|
6
|
+
has_prefix_id :ndel
|
|
7
|
+
|
|
8
|
+
OUTCOMES = %w[delivered failed unknown skipped].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
11
|
+
belongs_to :source, polymorphic: true, optional: true
|
|
12
|
+
|
|
13
|
+
validates :channel, :message_type, :recipient, :message_ref, presence: true
|
|
14
|
+
validates :outcome, inclusion: { in: OUTCOMES }
|
|
15
|
+
|
|
16
|
+
before_validation { self.reason = reason.to_s.truncate(255).presence }
|
|
17
|
+
|
|
18
|
+
scope :older_than, ->(days) { where(created_at: ...days.days.ago) }
|
|
19
|
+
scope :newest_first, -> { order(created_at: :desc, id: :desc) }
|
|
20
|
+
|
|
21
|
+
# `recipient` is deliberately not searchable: it is masked on screen.
|
|
22
|
+
self.whitelisted_ransackable_attributes = %w[channel provider_key outcome message_type created_at bulk]
|
|
23
|
+
|
|
24
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
25
|
+
whitelisted_ransackable_attributes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Only a provider callback may turn a delivered message into a failure.
|
|
33
|
+
def mark_failed!(reason)
|
|
34
|
+
return false unless outcome == 'delivered'
|
|
35
|
+
|
|
36
|
+
update!(outcome: 'failed', reason: reason.presence || 'provider_reported_failure')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# A store's shared inputs for one provider (e.g. the MSG91 auth key that
|
|
5
|
+
# every MSG91 channel uses).
|
|
6
|
+
class NotificationProviderConfig < Spree::Base
|
|
7
|
+
include Spree::NotificationProviderSettings
|
|
8
|
+
|
|
9
|
+
has_prefix_id :nprov
|
|
10
|
+
|
|
11
|
+
IMPORT_SOURCES = %w[legacy_columns env mixed].freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
14
|
+
|
|
15
|
+
validates :provider_key, presence: true, uniqueness: { scope: :store_id }
|
|
16
|
+
validates :import_source, inclusion: { in: IMPORT_SOURCES }, allow_nil: true
|
|
17
|
+
validate { validate_inputs(shared_inputs) }
|
|
18
|
+
|
|
19
|
+
# nil when the provider is no longer registered ("Unavailable").
|
|
20
|
+
def definition
|
|
21
|
+
SpreeNotificationProviders.provider(provider_key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def shared_inputs
|
|
25
|
+
definition&.shared_inputs || []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def channel_providers
|
|
29
|
+
Spree::NotificationChannelProvider.where(store_id: store_id, provider_key: provider_key)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# File name matches the constant so Zeitwerk can eager-load it in any app.
|
|
5
|
+
module NotificationProvidersStoreDecorator
|
|
6
|
+
def self.prepended(base)
|
|
7
|
+
base.has_many :notification_provider_configs, class_name: 'Spree::NotificationProviderConfig',
|
|
8
|
+
dependent: :destroy
|
|
9
|
+
base.has_many :notification_channel_providers, class_name: 'Spree::NotificationChannelProvider',
|
|
10
|
+
dependent: :destroy
|
|
11
|
+
base.has_many :notification_deliveries, class_name: 'Spree::NotificationDelivery', dependent: :delete_all
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Spree::Store.prepend(NotificationProvidersStoreDecorator) unless Spree::Store.ancestors.include?(NotificationProvidersStoreDecorator)
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PermissionSets
|
|
5
|
+
# Grants the Notification providers and Delivery log screens to roles that
|
|
6
|
+
# are not full super users. Assign in the app's Spree initializer:
|
|
7
|
+
#
|
|
8
|
+
# Spree.permissions.assign(:admin, [Spree::PermissionSets::NotificationProviderManagement])
|
|
9
|
+
#
|
|
10
|
+
# Delivery rows are written by the router, never by an admin, so :manage
|
|
11
|
+
# on them means "may open the Delivery log".
|
|
12
|
+
class NotificationProviderManagement < Base
|
|
13
|
+
def activate!
|
|
14
|
+
can :manage, Spree::NotificationProviderConfig
|
|
15
|
+
can :manage, Spree::NotificationChannelProvider
|
|
16
|
+
can :manage, Spree::NotificationDelivery
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<%# locals: (input:, record:, field_prefix:, secret_prefix:, clear_key:, errors: {}) %>
|
|
2
|
+
<% field_id = "#{field_prefix.parameterize(separator: '_')}_#{input.key}" %>
|
|
3
|
+
<% error = errors[input.key] %>
|
|
4
|
+
<div class="form-group">
|
|
5
|
+
<%= label_tag field_id do %>
|
|
6
|
+
<%= input.label %><% if input.required? %> <span class="text-red-600" aria-hidden="true">*</span><% end %>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<% if input.secret? %>
|
|
10
|
+
<% stored = record.raw_value(input) %>
|
|
11
|
+
<%= password_field_tag "#{secret_prefix}[#{input.key}]", nil,
|
|
12
|
+
id: field_id, class: "form-control #{'is-invalid' if error}",
|
|
13
|
+
autocomplete: 'new-password',
|
|
14
|
+
placeholder: (stored.present? ? t('spree_notification_providers.admin.configure.secret_placeholder') : nil) %>
|
|
15
|
+
<div class="text-xs text-gray-500 mt-1 flex items-center gap-3">
|
|
16
|
+
<span>
|
|
17
|
+
<%= stored.present? ? t('spree_notification_providers.admin.configure.secret_set', hint: secret_hint(stored)).strip : t('spree_notification_providers.admin.configure.secret_not_set') %>
|
|
18
|
+
</span>
|
|
19
|
+
<% if stored.present? %>
|
|
20
|
+
<label class="inline-flex items-center gap-1">
|
|
21
|
+
<%= check_box_tag "#{clear_key}[]", input.key, false, id: "#{field_id}_clear" %>
|
|
22
|
+
<%= t('spree_notification_providers.admin.configure.clear') %>
|
|
23
|
+
</label>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
26
|
+
<% else %>
|
|
27
|
+
<% value = record.raw_value(input) %>
|
|
28
|
+
<% case input.type %>
|
|
29
|
+
<% when :select %>
|
|
30
|
+
<%= select_tag "#{field_prefix}[#{input.key}]",
|
|
31
|
+
options_for_select(Array(input.options).map { |o| [o.to_s.humanize, o] }, value || input.default),
|
|
32
|
+
id: field_id, class: "form-select #{'is-invalid' if error}" %>
|
|
33
|
+
<% when :boolean %>
|
|
34
|
+
<%= select_tag "#{field_prefix}[#{input.key}]",
|
|
35
|
+
options_for_select([[Spree.t(:say_yes), 'true'], [Spree.t(:say_no), 'false']], (value || input.default).to_s),
|
|
36
|
+
id: field_id, class: "form-select #{'is-invalid' if error}" %>
|
|
37
|
+
<% else %>
|
|
38
|
+
<%= text_field_tag "#{field_prefix}[#{input.key}]", value,
|
|
39
|
+
id: field_id, class: "form-control #{'is-invalid' if error}",
|
|
40
|
+
placeholder: input.default,
|
|
41
|
+
inputmode: (input.type == :integer || input.type == :phone ? 'numeric' : nil) %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<% if error %>
|
|
46
|
+
<div class="text-xs text-red-600 mt-1"><%= input.label %> <%= error %></div>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% if input.help.present? %>
|
|
49
|
+
<div class="text-xs text-gray-500 mt-1"><%= input.help %></div>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<% scope = 'spree_notification_providers.admin.configure' %>
|
|
2
|
+
<% provider_label = @entry.definition&.label || @entry.provider_key.titleize %>
|
|
3
|
+
<% channel_label = notification_channel_label(@entry.channel) %>
|
|
4
|
+
<% content_for :page_title do %>
|
|
5
|
+
<%= t("#{scope}.title", provider: provider_label, channel: channel_label) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<% content_for :page_actions do %>
|
|
9
|
+
<%= link_to t("#{scope}.back"), spree.admin_notification_providers_path(channel: @entry.channel), class: 'btn btn-light' %>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="grid grid-cols-12 gap-6">
|
|
13
|
+
<div class="col-span-12 lg:col-span-8">
|
|
14
|
+
<% if @entry.definition.nil? %>
|
|
15
|
+
<div class="alert alert-danger mb-4">
|
|
16
|
+
<%= t('spree_notification_providers.statuses.unavailable') %>: <%= @entry.provider_key %>
|
|
17
|
+
</div>
|
|
18
|
+
<% else %>
|
|
19
|
+
<% missing = @entry.missing_inputs %>
|
|
20
|
+
<div class="mb-4 flex items-center gap-2">
|
|
21
|
+
<%= notification_status_badge(@entry.status) %>
|
|
22
|
+
<% if missing.any? %>
|
|
23
|
+
<span class="text-sm text-amber-700"><%= t('spree_notification_providers.admin.providers.missing', inputs: missing.join(', ')) %></span>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<%= form_with url: spree.admin_notification_channel_provider_path(@entry), method: :patch, local: true,
|
|
28
|
+
id: 'notification-provider-form' do %>
|
|
29
|
+
<div class="card mb-6">
|
|
30
|
+
<div class="card-body flex items-center justify-between">
|
|
31
|
+
<label for="entry_enabled" class="font-medium mb-0"><%= t('spree_notification_providers.admin.providers.enabled') %></label>
|
|
32
|
+
<%= hidden_field_tag 'entry[enabled]', '0', id: nil %>
|
|
33
|
+
<%= check_box_tag 'entry[enabled]', '1', @entry.enabled?, id: 'entry_enabled' %>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<% if @entry.shared_inputs.any? %>
|
|
38
|
+
<div class="card mb-6">
|
|
39
|
+
<div class="card-header">
|
|
40
|
+
<h5 class="card-title"><%= t("#{scope}.shared_header") %></h5>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="card-body space-y-4">
|
|
43
|
+
<p class="text-xs text-gray-500">
|
|
44
|
+
<%= t("#{scope}.shared_used_by",
|
|
45
|
+
channels: (@entry.shared_with_channels | [@entry.channel]).map { |c| notification_channel_label(c) }.to_sentence) %>
|
|
46
|
+
</p>
|
|
47
|
+
<% @entry.shared_inputs.each do |input| %>
|
|
48
|
+
<%= render 'input_field', input: input, record: @entry.provider_config,
|
|
49
|
+
field_prefix: 'entry[shared_settings]', secret_prefix: 'entry[shared_secret_settings]',
|
|
50
|
+
clear_key: 'entry[clear_shared_secrets]', errors: @config_errors || {} %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
|
|
56
|
+
<% if @entry.channel_inputs.any? %>
|
|
57
|
+
<div class="card mb-6">
|
|
58
|
+
<div class="card-header">
|
|
59
|
+
<h5 class="card-title"><%= t("#{scope}.channel_header", channel: channel_label) %></h5>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="card-body space-y-4">
|
|
62
|
+
<% @entry.channel_inputs.each do |input| %>
|
|
63
|
+
<%= render 'input_field', input: input, record: @entry,
|
|
64
|
+
field_prefix: 'entry[settings]', secret_prefix: 'entry[secret_settings]',
|
|
65
|
+
clear_key: 'entry[clear_secrets]', errors: @entry.input_errors %>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<% if @entry.template_capable? %>
|
|
72
|
+
<div class="card mb-6">
|
|
73
|
+
<div class="card-header">
|
|
74
|
+
<h5 class="card-title"><%= t("#{scope}.templates_header") %></h5>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="card-body space-y-4">
|
|
77
|
+
<p class="text-xs text-gray-500"><%= t("#{scope}.templates_help") %></p>
|
|
78
|
+
<% SpreeNotificationProviders.message_types_for(@entry.channel).each do |type| %>
|
|
79
|
+
<div class="form-group">
|
|
80
|
+
<%= label_tag "entry_templates_#{type.key}", type.label %>
|
|
81
|
+
<%= text_field_tag "entry[templates][#{type.key}]", @entry.templates.to_h[type.key.to_s],
|
|
82
|
+
id: "entry_templates_#{type.key}", class: 'form-control font-mono text-sm' %>
|
|
83
|
+
</div>
|
|
84
|
+
<% end %>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<% end %>
|
|
88
|
+
|
|
89
|
+
<div class="flex justify-end">
|
|
90
|
+
<%= submit_tag t("#{scope}.save"), class: 'btn btn-primary' %>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
93
|
+
<% end %>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="col-span-12 lg:col-span-4">
|
|
97
|
+
<% if @entry.definition %>
|
|
98
|
+
<div class="card">
|
|
99
|
+
<div class="card-header">
|
|
100
|
+
<h5 class="card-title"><%= t("#{scope}.test_header") %></h5>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="card-body">
|
|
103
|
+
<p class="text-xs text-gray-500 mb-3"><%= t("#{scope}.test_help") %></p>
|
|
104
|
+
<%= form_with url: spree.test_admin_notification_channel_provider_path(@entry), method: :post, local: true,
|
|
105
|
+
id: 'notification-provider-test-form' do %>
|
|
106
|
+
<div class="form-group">
|
|
107
|
+
<% label = @entry.channel == 'email' ? t("#{scope}.test_to_email") : t("#{scope}.test_to_phone") %>
|
|
108
|
+
<%= label_tag :test_to, label %>
|
|
109
|
+
<%= (@entry.channel == 'email' ? method(:email_field_tag) : method(:telephone_field_tag))
|
|
110
|
+
.call(:to, nil, id: 'test_to', class: 'form-control', required: true) %>
|
|
111
|
+
</div>
|
|
112
|
+
<%= submit_tag t("#{scope}.test_send"), class: 'btn btn-light w-full' %>
|
|
113
|
+
<% end %>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<% end %>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|