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,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>