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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2c2f8625e5b892ebfe95c3ce69935440f18fa7afb28dcf65fc255ac76f50dfc7
4
+ data.tar.gz: 12517886161486ee64ba223c9b69c723a70ecd494b18cad8a0a85a6d6c4eb04e
5
+ SHA512:
6
+ metadata.gz: 52a68681db0269d172a4b36a71d1833ec3a37861588cf2d7cea470e10ff9c92c139b77ed6bfe51f32381ab83a01569aac62f7a696b2036867cb29a46c7f534a8
7
+ data.tar.gz: e026d7aa779bdf9ad8d0aad7a887d606ac26f92cab648bfc7c15c4356343493b2fffae849bd4502ac83b61f7cbf7d6af28ba3f5c8feae998e3bf4252a0aae6c1
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the gem uses
5
+ [Semantic Versioning](https://semver.org/). While the version is 0.x, a minor
6
+ bump may change the public API described in the README; such changes are listed
7
+ under **Breaking**.
8
+
9
+ ## 0.1.0 — 2026-10-19
10
+
11
+ ### Added
12
+
13
+ - Provider registry with a DSL for shared and per-channel inputs, capabilities
14
+ and per-message-type templates.
15
+ - Bundled providers: MSG91 (email, SMS, WhatsApp), SMTP (email), Gupshup
16
+ (WhatsApp), and a Log provider for non-production environments.
17
+ - Per-store provider configuration with encrypted secrets, readiness and
18
+ priority ordering per channel.
19
+ - Router with failover on definite failures only, bulk sends without failover,
20
+ and one Delivery log row per attempt.
21
+ - Admin screens: Notification providers, per-provider test send, Delivery log.
22
+ - Action Mailer delivery method `:spree_notification_providers`.
23
+ - Gupshup delivery webhook with per-store secrets.
24
+ - Env importer generator for apps moving from environment variables.
data/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, ONTODI
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # spree_notification_providers
2
+
3
+ Per-channel notification providers with priority and failover for
4
+ [Spree Commerce](https://spreecommerce.org) 5.5+.
5
+
6
+ Each store picks the providers it uses for **Email**, **SMS** and **WhatsApp**,
7
+ enters only the inputs each provider needs, and puts them in order. Messages go
8
+ to the first ready provider; if it definitely did not take the message, the next
9
+ one is tried. Every attempt is recorded in an admin **Delivery log**.
10
+
11
+ Bundled providers:
12
+
13
+ | Provider | Email | SMS | WhatsApp |
14
+ |----------|-------|-----|----------|
15
+ | MSG91 | templates, bulk | OTP, Flow templates, bulk | OTP |
16
+ | SMTP | rendered HTML (any mailer) | — | — |
17
+ | Gupshup | — | — | OTP, templates |
18
+ | Log | all (writes to the Rails log; non-production only) | all | all |
19
+
20
+ You can add your own provider without changing this gem (see
21
+ [Adding a provider](#adding-a-provider)).
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ # Gemfile
29
+ gem 'spree_notification_providers', '~> 0.1'
30
+ ```
31
+
32
+ ```bash
33
+ bundle install
34
+ bin/rails g spree_notification_providers:install
35
+ ```
36
+
37
+ The generator copies three migrations (`spree_notification_provider_configs`,
38
+ `spree_notification_channel_providers`, `spree_notification_deliveries`), runs
39
+ them, and writes `config/initializers/spree_notification_providers.rb`.
40
+
41
+ Then:
42
+
43
+ 1. **Encryption keys.** Provider secrets are stored with Active Record
44
+ Encryption. Configure `active_record_encryption` credentials or set
45
+ `config.active_record.encryption.primary_key`, `deterministic_key` and
46
+ `key_derivation_salt` (`bin/rails db:encryption:init` prints a set).
47
+ 2. **Permissions.** Super users can open the screens already. For other admin
48
+ roles add the permission set:
49
+
50
+ ```ruby
51
+ Spree.permissions.assign(:admin, [Spree::PermissionSets::NotificationProviderManagement])
52
+ ```
53
+
54
+ 3. **Mailers (optional but recommended).** Route Action Mailer through the
55
+ store's Email providers:
56
+
57
+ ```ruby
58
+ # config/environments/production.rb
59
+ config.action_mailer.delivery_method = :spree_notification_providers
60
+ ```
61
+
62
+ 4. **Retention.** Schedule `Spree::NotificationDeliveries::PurgeJob` daily, e.g.
63
+ with sidekiq-cron:
64
+
65
+ ```yaml
66
+ notification_deliveries_purge:
67
+ cron: "0 4 * * *"
68
+ class: "Spree::NotificationDeliveries::PurgeJob"
69
+ ```
70
+
71
+ 5. Open **Admin → Settings → Notification providers**.
72
+
73
+ ---
74
+
75
+ ## Configuring providers
76
+
77
+ **Admin → Settings → Notification providers** has a tab per channel. Each tab
78
+ lists the store's providers in priority order (drag to reorder) with a status:
79
+
80
+ - **Ready** — enabled and every required input is set.
81
+ - **Not ready** — names the missing inputs.
82
+ - **Disabled** — skipped; its inputs are kept.
83
+ - **Unavailable** — the provider is no longer registered; skipped.
84
+
85
+ **Add** a provider from *Available providers*, then **Configure** it. The form
86
+ shows only that provider's inputs:
87
+
88
+ - **Shared settings** are entered once per store and used by every channel the
89
+ provider serves (e.g. the MSG91 auth key for Email and SMS).
90
+ - **Channel settings** apply to one channel (e.g. the MSG91 email sending domain).
91
+ - **Templates** — one field per registered message type (see below). A
92
+ template-based provider is skipped for a message type with no template.
93
+
94
+ Secret inputs are write-only: they show as "Set ••••1234" (last four characters
95
+ only for values of 12+ characters), a blank submit keeps the stored value, and
96
+ **Clear** removes it. They are stored encrypted and never logged.
97
+
98
+ **Send test** sends one message through that provider only, ignoring priority,
99
+ and records it in the Delivery log as `provider_test`.
100
+
101
+ ### MSG91
102
+
103
+ | Scope | Input | Notes |
104
+ |-------|-------|-------|
105
+ | shared | Auth key * (secret) | MSG91 dashboard → Authkey |
106
+ | email | Sending domain * | Verified domain; the sender's own domain is used when the message has a sender |
107
+ | email | Default from address | Used when the message has no sender |
108
+ | sms | Sender ID | DLT-registered header |
109
+ | sms, whatsapp | Default country code | Added to 10-digit numbers (default `91`) |
110
+
111
+ Templates: email template id, SMS OTP template / Flow id, WhatsApp OTP template.
112
+
113
+ ### SMTP
114
+
115
+ | Input | Notes |
116
+ |-------|-------|
117
+ | Host *, Port * (587), Username *, Password * (secret) | |
118
+ | From address | Used when a mail has no sender (else the username, if it is an address) |
119
+ | Authentication | `plain` (default), `login`, `cram_md5` |
120
+ | Use STARTTLS | default yes |
121
+
122
+ SMTP sends rendered HTML only (Action Mailer mails and `kind: :html` messages).
123
+
124
+ ### Gupshup
125
+
126
+ | Scope | Input | Notes |
127
+ |-------|-------|-------|
128
+ | shared | API key * (secret) | |
129
+ | shared | Webhook secret (secret) | Random string for the callback URL |
130
+ | whatsapp | Source number * | Business number with country code, digits only |
131
+ | whatsapp | App name * | |
132
+ | whatsapp | App ID | Needed when a template is entered by name instead of UUID |
133
+
134
+ Point the Gupshup app's callback at
135
+ `https://<host>/webhooks/gupshup?secret=<webhook secret>`. A `failed` event
136
+ marks the delivery failed and publishes `notification.delivery_failed`.
137
+
138
+ ---
139
+
140
+ ## How priority and failover work
141
+
142
+ For one-to-one messages the router walks the channel's enabled providers in
143
+ order:
144
+
145
+ 1. A provider that is not ready, has no template for the message type, or can't
146
+ send this kind of message is recorded as **skipped**.
147
+ 2. **Delivered** — stop.
148
+ 3. **Failed** — a definite failure (connection refused, connect timeout, DNS/TLS
149
+ error, HTTP 4xx, a 5xx that explains itself, SMTP auth/rejection): try the
150
+ next provider.
151
+ 4. **Unknown** — the message may have reached the provider (read timeout,
152
+ connection reset after sending, a 5xx with no body): **stop**, so the
153
+ customer never gets a duplicate.
154
+
155
+ Bulk sends (`deliver_bulk`) use the first ready provider that supports bulk and
156
+ **never fail over**; each rejected recipient is recorded as failed.
157
+
158
+ A provider that accepts a message and later reports it failed (e.g. a Gupshup
159
+ callback) does not trigger failover; subscribe to `notification.delivery_failed`
160
+ to react (spree_otp_auth uses it for its email backup).
161
+
162
+ ---
163
+
164
+ ## Delivery log
165
+
166
+ **Admin → Settings → Delivery log** lists every attempt for the store, newest
167
+ first, with attempts of one message grouped. Filter by channel, provider,
168
+ outcome, message type, date and bulk. Recipients are masked
169
+ (`ka•••@gmail.com`, `+91•••••3210`) and not searchable. Rows older than
170
+ `delivery_retention_days` (default 90) are removed by the purge job.
171
+
172
+ ---
173
+
174
+ ## Sending from code
175
+
176
+ ```ruby
177
+ message = SpreeNotificationProviders::Message.new(
178
+ channel: :sms, type: :otp_code, kind: :otp, to: '+919876543210', code: '482913', expiry_minutes: 10
179
+ )
180
+ result = SpreeNotificationProviders.deliver(store: store, message: message)
181
+ result.success? # true when a provider delivered it
182
+ result.retryable? # true only when no provider can have taken it
183
+ result.attempts # [{ provider_key:, outcome:, reason: }]
184
+ ```
185
+
186
+ ```ruby
187
+ bulk = SpreeNotificationProviders::BulkMessage.new(
188
+ channel: :email, type: :customer_blast, recipients: emails, variables: { 'OFFER' => '10%' },
189
+ templates: { msg91: 'blast-template' }
190
+ )
191
+ SpreeNotificationProviders.deliver_bulk(store: store, message: bulk).failed_recipients
192
+ ```
193
+
194
+ `SpreeNotificationProviders.ready_for?(store, :whatsapp, :otp_code)` and
195
+ `channel_status(store, :sms)` answer readiness questions.
196
+
197
+ ### Message types
198
+
199
+ Register the kinds of message your app sends; each gets a template field on
200
+ template-based providers:
201
+
202
+ ```ruby
203
+ SpreeNotificationProviders.register_message_type :order_confirmation,
204
+ label: 'Order confirmation', channels: %i[email sms]
205
+ SpreeNotificationProviders.register_message_type :newsletter,
206
+ label: 'Newsletter', channels: %i[email], bulk: true
207
+ ```
208
+
209
+ ### Action Mailer
210
+
211
+ With `delivery_method = :spree_notification_providers`, each mail is sent
212
+ through the store's Email providers. Optional headers (removed before sending):
213
+
214
+ | Header | Meaning |
215
+ |--------|---------|
216
+ | `X-Spree-Store-Id` | Store id (default `Spree::Current.store`, then the default store) |
217
+ | `X-Spree-Message-Type` | Message type (default `transactional_email`) |
218
+ | `X-Spree-Provider-Template-<Key>` | Template for one provider, e.g. `X-Spree-Provider-Template-Msg91` |
219
+
220
+ The method raises `SpreeNotificationProviders::DeliveryFailed` only when no
221
+ provider can have taken the mail, so `deliver_later` retries are safe.
222
+
223
+ ### Events
224
+
225
+ | Event | When |
226
+ |-------|------|
227
+ | `notification.delivered` | a provider delivered a message |
228
+ | `notification.failed` | every provider failed or was skipped |
229
+ | `notification.delivery_failed` | a provider reported an accepted message as failed |
230
+
231
+ ---
232
+
233
+ ## Adding a provider
234
+
235
+ ```ruby
236
+ # config/initializers/notification_providers.rb
237
+ SpreeNotificationProviders.register_provider :twilio do
238
+ label 'Twilio'
239
+ adapter 'MyApp::TwilioAdapter'
240
+
241
+ shared_input :account_sid, label: 'Account SID', required: true, env: 'TWILIO_ACCOUNT_SID'
242
+ shared_input :auth_token, label: 'Auth token', required: true, secret: true, env: 'TWILIO_AUTH_TOKEN'
243
+
244
+ channel :sms, kinds: %i[otp template] do
245
+ input :from_number, label: 'From number', type: :phone, required: true
246
+ end
247
+ end
248
+ ```
249
+
250
+ ```ruby
251
+ class MyApp::TwilioAdapter < SpreeNotificationProviders::Adapters::Base
252
+ def deliver(message, context)
253
+ response = SpreeNotificationProviders::Http.request(
254
+ :post, "https://api.twilio.com/2010-04-01/Accounts/#{context[:account_sid]}/Messages.json",
255
+ headers: { 'Authorization' => "Basic #{Base64.strict_encode64("#{context[:account_sid]}:#{context[:auth_token]}")}" },
256
+ form: { 'To' => message.to, 'From' => context[:from_number], 'Body' => "Your code is #{message.code}" }
257
+ )
258
+ body = SpreeNotificationProviders::Http.parse_json(response.body)
259
+ SpreeNotificationProviders::Adapters::HttpOutcome.from_response(
260
+ response, body, accepted: body['sid'].present?, message_id: body['sid'], provider_message: body['message']
261
+ )
262
+ rescue StandardError => e
263
+ SpreeNotificationProviders::Adapters::HttpOutcome.from_error(e)
264
+ end
265
+ end
266
+ ```
267
+
268
+ - Input types: `:string`, `:integer`, `:email`, `:phone`, `:url`, `:select`
269
+ (with `options:`), `:boolean`. Options: `required`, `secret`, `format`
270
+ (Regexp), `default`, `label`, `help`, `env`.
271
+ - Channel kinds: `:otp`, `:template`, `:html`, `:bulk`. `template_required: true`
272
+ skips the provider for message types without a template; `missing_check`
273
+ adds readiness rules that depend on several values.
274
+ - `context[:name]` gives merged shared + channel values (secrets decrypted,
275
+ defaults applied); `context.template` is the template for the message type.
276
+ - Return `Result.delivered(id)`, `Result.failed(reason)` or
277
+ `Result.unknown(reason)`. Never put credentials or full response bodies in a
278
+ reason.
279
+ - `env:` is read only by the importer below; adapters never read `ENV`.
280
+
281
+ ---
282
+
283
+ ## Upgrading from environment variables
284
+
285
+ If your app kept provider settings in ENV (`MSG91_AUTH_KEY`, `SMTP_HOST`,
286
+ `GUPSHUP_API_KEY`, …), copy them into a store's configuration:
287
+
288
+ ```bash
289
+ bin/rails g spree_notification_providers:import_env --store=<code> --dry-run
290
+ bin/rails g spree_notification_providers:import_env --store=<code>
291
+ ```
292
+
293
+ Only blank inputs are filled, so it is safe to rerun.
294
+
295
+ ---
296
+
297
+ ## Configuration
298
+
299
+ ```ruby
300
+ SpreeNotificationProviders.configure do |config|
301
+ config.delivery_retention_days = 90 # >= 1
302
+ config.enable_log_provider = !Rails.env.production? # register the Log provider
303
+ config.http_timeout_seconds = 5 # bundled HTTP adapters
304
+ config.bulk_batch_size = 100
305
+ end
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Development
311
+
312
+ ```bash
313
+ bundle exec rake test_app # generates spec/dummy (spree_dev_tools)
314
+ bundle exec rspec
315
+ ```
316
+
317
+ Specs stub `SpreeNotificationProviders::Http.request`; a real provider call
318
+ from a spec raises.
319
+
320
+ ## License
321
+
322
+ BSD 3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Admin
5
+ # Adds, configures, reorders, tests and removes one provider on one channel.
6
+ class NotificationChannelProvidersController < Spree::Admin::BaseController
7
+ helper 'spree/admin/notification_masking'
8
+
9
+ before_action :authorize_providers
10
+ before_action :load_entry, except: :create
11
+
12
+ rate_limit to: 5, within: 1.minute, only: :test, by: -> { try_spree_current_user&.id || request.remote_ip },
13
+ with: -> { redirect_back_or_to(edit_path, alert: 'Too many test messages. Try again in a minute.') }
14
+
15
+ def create
16
+ channel = params[:channel].to_s
17
+ definition = SpreeNotificationProviders.provider(params[:provider_key])
18
+ unless Spree::NotificationChannelProvider::CHANNELS.include?(channel) && definition&.supports?(channel)
19
+ flash[:error] = 'That provider is not available for this channel.'
20
+ return redirect_to spree.admin_notification_providers_path(channel: channel.presence), status: :see_other
21
+ end
22
+
23
+ entry = Spree::NotificationChannelProvider.find_or_initialize_by(store_id: current_store.id, channel: channel,
24
+ provider_key: definition.key.to_s)
25
+ entry.enabled = false if entry.new_record?
26
+ entry.save!(validate: false)
27
+ entry.provider_config
28
+ flash[:success] = t('spree_notification_providers.admin.providers.added', provider: definition.label,
29
+ channel: channel_label(channel))
30
+ redirect_to spree.edit_admin_notification_channel_provider_path(entry), status: :see_other
31
+ end
32
+
33
+ def edit; end
34
+
35
+ def update
36
+ config = @entry.provider_config
37
+ attrs = entry_params
38
+
39
+ config.assign_inputs(@entry.shared_inputs, values: attrs[:shared_settings], secrets: attrs[:shared_secret_settings],
40
+ clear: attrs[:clear_shared_secrets])
41
+ @entry.assign_inputs(@entry.channel_inputs, values: attrs[:settings], secrets: attrs[:secret_settings],
42
+ clear: attrs[:clear_secrets])
43
+ @entry.templates = cleaned_templates(attrs[:templates]) if attrs.key?(:templates)
44
+ @entry.enabled = ActiveModel::Type::Boolean.new.cast(attrs[:enabled]) if attrs.key?(:enabled)
45
+
46
+ config_valid = config.valid?
47
+ if config_valid && @entry.valid?
48
+ ActiveRecord::Base.transaction do
49
+ config.save!
50
+ @entry.save!
51
+ end
52
+ SpreeNotificationProviders::Adapters::Gupshup.clear_template_cache(current_store) if @entry.provider_key == 'gupshup'
53
+ flash[:success] = t('spree_notification_providers.admin.providers.updated', provider: provider_label)
54
+ redirect_to(params[:return_to] == 'index' ? index_path : edit_path, status: :see_other)
55
+ else
56
+ @config_errors = config.input_errors
57
+ flash.now[:error] = (config.errors.full_messages + @entry.errors.full_messages).uniq.join(', ')
58
+ render :edit, status: :unprocessable_content
59
+ end
60
+ end
61
+
62
+ def reposition
63
+ position = (params.dig(:notification_channel_provider, :position) || params[:position]).to_i
64
+ count = Spree::NotificationChannelProvider.where(store_id: current_store.id, channel: @entry.channel).count
65
+ @entry.insert_at(position.clamp(1, count)) if position.positive?
66
+ head :no_content
67
+ end
68
+
69
+ def destroy
70
+ channel = @entry.channel
71
+ @entry.destroy!
72
+ unless Spree::NotificationChannelProvider.exists?(store_id: current_store.id, provider_key: @entry.provider_key)
73
+ Spree::NotificationProviderConfig.where(store_id: current_store.id, provider_key: @entry.provider_key).destroy_all
74
+ end
75
+ flash[:success] = t('spree_notification_providers.admin.providers.removed', provider: provider_label,
76
+ channel: channel_label(channel))
77
+ redirect_to spree.admin_notification_providers_path(channel: channel), status: :see_other
78
+ end
79
+
80
+ # Sends one message through this provider only, ignoring priority.
81
+ def test
82
+ to = params[:to].to_s.strip
83
+ unless valid_test_recipient?(to)
84
+ kind = @entry.channel == 'email' ? 'email address' : 'phone number'
85
+ flash[:error] = t('spree_notification_providers.admin.configure.test_invalid_to', kind: kind)
86
+ return redirect_to edit_path, status: :see_other
87
+ end
88
+
89
+ message = SpreeNotificationProviders::Message.new(channel: @entry.channel, type: :provider_test, to: to,
90
+ code: SpreeNotificationProviders::Adapters::Base::TEST_CODE)
91
+ result = SpreeNotificationProviders::Router.new(current_store).deliver_via(@entry, message)
92
+ if result.success?
93
+ flash[:success] = t('spree_notification_providers.admin.configure.test_delivered', provider: provider_label)
94
+ else
95
+ flash[:error] = t('spree_notification_providers.admin.configure.test_failed',
96
+ provider: provider_label, reason: result.attempts.first[:reason] || result.outcome)
97
+ end
98
+ redirect_to edit_path, status: :see_other
99
+ end
100
+
101
+ private
102
+
103
+ def model_class
104
+ Spree::NotificationChannelProvider
105
+ end
106
+
107
+ def authorize_providers
108
+ authorize! :manage, Spree::NotificationChannelProvider
109
+ end
110
+
111
+ def load_entry
112
+ @entry = Spree::NotificationChannelProvider.where(store_id: current_store.id).find_by_param!(params[:id])
113
+ end
114
+
115
+ def entry_params
116
+ params.fetch(:entry, {}).permit(:enabled, settings: {}, secret_settings: {}, shared_settings: {},
117
+ shared_secret_settings: {}, templates: {}, clear_secrets: [],
118
+ clear_shared_secrets: [])
119
+ end
120
+
121
+ # Only registered message types for this channel; blanks dropped.
122
+ def cleaned_templates(raw)
123
+ allowed = SpreeNotificationProviders.message_types_for(@entry.channel).map { |type| type.key.to_s }
124
+ raw.to_h.slice(*allowed).transform_values { |value| value.to_s.strip }.compact_blank
125
+ end
126
+
127
+ def valid_test_recipient?(to)
128
+ if @entry.channel == 'email'
129
+ to.match?(SpreeNotificationProviders::InputDefinition::EMAIL_FORMAT)
130
+ else
131
+ to.delete(' -').match?(SpreeNotificationProviders::InputDefinition::PHONE_FORMAT)
132
+ end
133
+ end
134
+
135
+ def provider_label
136
+ @entry.definition&.label || @entry.provider_key.titleize
137
+ end
138
+
139
+ def channel_label(channel)
140
+ t("spree_notification_providers.channels.#{channel}")
141
+ end
142
+
143
+ def edit_path
144
+ spree.edit_admin_notification_channel_provider_path(@entry)
145
+ end
146
+
147
+ def index_path
148
+ spree.admin_notification_providers_path(channel: @entry.channel)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Admin
5
+ # Settings → Delivery log: every send attempt for the current store,
6
+ # grouped by message. Read-only; recipients are masked.
7
+ class NotificationDeliveriesController < Spree::Admin::BaseController
8
+ include Pagy::Method
9
+
10
+ helper 'spree/admin/notification_masking'
11
+
12
+ PER_PAGE = 50
13
+
14
+ def index
15
+ authorize! :manage, Spree::NotificationDelivery
16
+
17
+ @search = scope.ransack(params[:q])
18
+ filtered = @search.result
19
+ refs = filtered.group(:message_ref).order(Arel.sql('MAX(created_at) DESC')).select(:message_ref)
20
+ # Grouped rows have no simple count, so page without one.
21
+ @pagy, page_refs = pagy(:countless, refs, limit: PER_PAGE)
22
+ @groups = build_groups(page_refs.map(&:message_ref))
23
+ end
24
+
25
+ def show
26
+ authorize! :manage, Spree::NotificationDelivery
27
+
28
+ all = scope.where(message_ref: params[:message_ref]).order(:attempt_number, :id)
29
+ raise ActiveRecord::RecordNotFound if all.none?
30
+
31
+ @message_ref = params[:message_ref]
32
+ @first = all.first
33
+ @pagy, @deliveries = pagy(:offset, all, limit: PER_PAGE)
34
+ end
35
+
36
+ private
37
+
38
+ def model_class
39
+ Spree::NotificationDelivery
40
+ end
41
+
42
+ def scope
43
+ Spree::NotificationDelivery.where(store_id: current_store.id)
44
+ end
45
+
46
+ Group = Struct.new(:message_ref, :attempts, :bulk_first, :bulk_counts, keyword_init: true) do
47
+ def bulk? = bulk_first.present?
48
+ def first = bulk_first || attempts.first
49
+ def final = bulk? ? nil : (attempts.reverse.find { |a| a.outcome.in?(%w[delivered unknown]) } || attempts.last)
50
+ end
51
+
52
+ # One-to-one messages load their few attempts; a bulk send loads one
53
+ # sample row and counts per outcome, never every recipient.
54
+ def build_groups(refs)
55
+ attempts = scope.where(message_ref: refs, bulk: false).order(:attempt_number, :id).group_by(&:message_ref)
56
+ bulk_first = scope.where(message_ref: refs, bulk: true).select('DISTINCT ON (message_ref) *')
57
+ .order(:message_ref, :id).index_by(&:message_ref)
58
+ counts = scope.where(message_ref: bulk_first.keys).group(:message_ref, :outcome).count
59
+
60
+ refs.filter_map do |ref|
61
+ next unless attempts[ref] || bulk_first[ref]
62
+
63
+ bulk_counts = counts.select { |(r, _), _| r == ref }.transform_keys(&:last)
64
+ Group.new(message_ref: ref, attempts: attempts[ref] || [], bulk_first: bulk_first[ref], bulk_counts: bulk_counts)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Admin
5
+ # Settings → Notification providers: one tab per channel, providers in
6
+ # priority order, and the providers that can still be added.
7
+ class NotificationProvidersController < Spree::Admin::BaseController
8
+ helper 'spree/admin/notification_masking'
9
+
10
+ def index
11
+ authorize! :manage, Spree::NotificationChannelProvider
12
+
13
+ @channel = channel_param
14
+ @entries = Spree::NotificationChannelProvider.where(store_id: current_store.id, channel: @channel)
15
+ .order(:position).to_a
16
+ added = @entries.map(&:provider_key)
17
+ @available = SpreeNotificationProviders.providers_for(@channel).reject { |p| added.include?(p.key.to_s) }
18
+ end
19
+
20
+ private
21
+
22
+ def model_class
23
+ Spree::NotificationChannelProvider
24
+ end
25
+
26
+ def channel_param
27
+ Spree::NotificationChannelProvider::CHANNELS.include?(params[:channel]) ? params[:channel] : 'email'
28
+ end
29
+ end
30
+ end
31
+ end