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,127 @@
1
+ <% scope = 'spree_notification_providers.admin.deliveries' %>
2
+ <% q = params[:q] || {} %>
3
+ <% content_for :page_title do %>
4
+ <%= t("#{scope}.title") %>
5
+ <% end %>
6
+
7
+ <% content_for :page_actions do %>
8
+ <%= link_to t('spree_notification_providers.admin.providers.title'), spree.admin_notification_providers_path, class: 'btn btn-light' %>
9
+ <% end %>
10
+
11
+ <p class="text-sm text-gray-500 mb-4">
12
+ <%= t("#{scope}.intro", days: SpreeNotificationProviders.configuration.delivery_retention_days) %>
13
+ </p>
14
+
15
+ <div class="card mb-6">
16
+ <div class="card-body">
17
+ <%= form_with url: spree.admin_notification_deliveries_path, method: :get, local: true,
18
+ class: 'flex flex-wrap items-end gap-3', id: 'delivery-log-filters' do %>
19
+ <% any = [[t("#{scope}.filters.any"), '']] %>
20
+ <div class="form-group mb-0">
21
+ <%= label_tag :q_channel_eq, t("#{scope}.filters.channel") %>
22
+ <%= select_tag 'q[channel_eq]', options_for_select(any + Spree::NotificationChannelProvider::CHANNELS.map { |c| [notification_channel_label(c), c] }, q[:channel_eq]),
23
+ id: 'q_channel_eq', class: 'form-select' %>
24
+ </div>
25
+ <div class="form-group mb-0">
26
+ <%= label_tag :q_provider_key_eq, t("#{scope}.filters.provider") %>
27
+ <%= select_tag 'q[provider_key_eq]', options_for_select(any + SpreeNotificationProviders.providers.map { |p| [p.label, p.key] }, q[:provider_key_eq]),
28
+ id: 'q_provider_key_eq', class: 'form-select' %>
29
+ </div>
30
+ <div class="form-group mb-0">
31
+ <%= label_tag :q_outcome_eq, t("#{scope}.filters.outcome") %>
32
+ <%= select_tag 'q[outcome_eq]', options_for_select(any + Spree::NotificationDelivery::OUTCOMES.map { |o| [t("spree_notification_providers.outcomes.#{o}"), o] }, q[:outcome_eq]),
33
+ id: 'q_outcome_eq', class: 'form-select' %>
34
+ </div>
35
+ <div class="form-group mb-0">
36
+ <%= label_tag :q_message_type_eq, t("#{scope}.filters.message_type") %>
37
+ <%= select_tag 'q[message_type_eq]', options_for_select(any + SpreeNotificationProviders.message_types.map { |m| [m.label, m.key] }, q[:message_type_eq]),
38
+ id: 'q_message_type_eq', class: 'form-select' %>
39
+ </div>
40
+ <div class="form-group mb-0">
41
+ <%= label_tag :q_created_at_gteq, t("#{scope}.filters.from") %>
42
+ <%= date_field_tag 'q[created_at_gteq]', q[:created_at_gteq], id: 'q_created_at_gteq', class: 'form-control' %>
43
+ </div>
44
+ <div class="form-group mb-0">
45
+ <%= label_tag :q_created_at_lteq, t("#{scope}.filters.to") %>
46
+ <%= date_field_tag 'q[created_at_lteq]', q[:created_at_lteq], id: 'q_created_at_lteq', class: 'form-control' %>
47
+ </div>
48
+ <label class="inline-flex items-center gap-1 mb-2">
49
+ <%= check_box_tag 'q[bulk_eq]', 'true', q[:bulk_eq] == 'true', id: 'q_bulk_eq' %>
50
+ <%= t("#{scope}.filters.bulk") %>
51
+ </label>
52
+ <div class="flex items-center gap-2">
53
+ <%= submit_tag t("#{scope}.filters.apply"), name: nil, class: 'btn btn-primary' %>
54
+ <%= link_to Spree.t(:clear), spree.admin_notification_deliveries_path, class: 'btn btn-light' if q.values.any?(&:present?) %>
55
+ </div>
56
+ <% end %>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="card-lg">
61
+ <% if @groups.any? %>
62
+ <div class="overflow-x-auto">
63
+ <table class="table">
64
+ <thead>
65
+ <tr>
66
+ <th scope="col"><%= t("#{scope}.columns.time") %></th>
67
+ <th scope="col"><%= t("#{scope}.columns.channel") %></th>
68
+ <th scope="col"><%= t("#{scope}.columns.type") %></th>
69
+ <th scope="col"><%= t("#{scope}.columns.recipient") %></th>
70
+ <th scope="col"><%= t("#{scope}.columns.attempts") %></th>
71
+ <th scope="col"><%= t("#{scope}.columns.outcome") %></th>
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ <% @groups.each do |group| %>
76
+ <% first = group.first %>
77
+ <tr id="delivery-<%= group.message_ref %>">
78
+ <td class="whitespace-nowrap text-sm">
79
+ <%= link_to l(first.created_at, format: :short), spree.admin_notification_delivery_path(group.message_ref), class: 'text-primary hover:underline' %>
80
+ </td>
81
+ <td><%= notification_channel_label(first.channel) %></td>
82
+ <td class="text-sm"><%= SpreeNotificationProviders.message_type(first.message_type)&.label || first.message_type %></td>
83
+ <td class="text-sm font-mono">
84
+ <% if group.bulk? %>
85
+ <%= t("#{scope}.bulk_recipients", count: group.bulk_counts.values.sum) %>
86
+ <% else %>
87
+ <%= mask_recipient(first.recipient) %>
88
+ <% end %>
89
+ </td>
90
+ <td class="text-sm">
91
+ <% if group.bulk? %>
92
+ <%= SpreeNotificationProviders.provider(first.provider_key)&.label || first.provider_key || '—' %>
93
+ <% else %>
94
+ <ol class="space-y-1">
95
+ <% group.attempts.each do |attempt| %>
96
+ <li>
97
+ <%= SpreeNotificationProviders.provider(attempt.provider_key)&.label || attempt.provider_key || '—' %>
98
+ <%= notification_status_badge(attempt.outcome) %>
99
+ <% if attempt.reason.present? %><span class="text-xs text-gray-500"><%= attempt.reason %></span><% end %>
100
+ </li>
101
+ <% end %>
102
+ </ol>
103
+ <% end %>
104
+ </td>
105
+ <td>
106
+ <% if group.bulk? %>
107
+ <% group.bulk_counts.each do |outcome, count| %>
108
+ <div><%= notification_status_badge(outcome) %> <span class="text-xs"><%= count %></span></div>
109
+ <% end %>
110
+ <% else %>
111
+ <%= notification_status_badge(group.final.outcome) %>
112
+ <% end %>
113
+ </td>
114
+ </tr>
115
+ <% end %>
116
+ </tbody>
117
+ </table>
118
+ </div>
119
+ <div class="flex justify-between p-4">
120
+ <%= link_to '← ' + Spree.t(:previous), url_for(request.query_parameters.merge(page: @pagy.previous)), class: 'btn btn-light btn-sm' if @pagy.previous %>
121
+ <span></span>
122
+ <%= link_to Spree.t(:next) + ' →', url_for(request.query_parameters.merge(page: @pagy.next)), class: 'btn btn-light btn-sm' if @pagy.next %>
123
+ </div>
124
+ <% else %>
125
+ <div class="card-body text-sm text-gray-500"><%= t("#{scope}.empty") %></div>
126
+ <% end %>
127
+ </div>
@@ -0,0 +1,52 @@
1
+ <% scope = 'spree_notification_providers.admin.deliveries' %>
2
+ <% content_for :page_title do %>
3
+ <%= t("#{scope}.show_title", ref: @message_ref.first(8)) %>
4
+ <% end %>
5
+
6
+ <% content_for :page_actions do %>
7
+ <%= link_to t("#{scope}.back"), spree.admin_notification_deliveries_path, class: 'btn btn-light' %>
8
+ <% end %>
9
+
10
+ <div class="card mb-6">
11
+ <div class="card-body grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
12
+ <div><div class="text-gray-500"><%= t("#{scope}.columns.channel") %></div><%= notification_channel_label(@first.channel) %></div>
13
+ <div><div class="text-gray-500"><%= t("#{scope}.columns.type") %></div><%= SpreeNotificationProviders.message_type(@first.message_type)&.label || @first.message_type %></div>
14
+ <div><div class="text-gray-500"><%= t("#{scope}.columns.time") %></div><%= l(@first.created_at, format: :long) %></div>
15
+ <div>
16
+ <div class="text-gray-500"><%= t("#{scope}.columns.source") %></div>
17
+ <%= @first.source_type ? "#{@first.source_type.demodulize} #{@first.source.try(:number) || @first.source_id}" : '—' %>
18
+ </div>
19
+ </div>
20
+ </div>
21
+
22
+ <div class="card-lg">
23
+ <table class="table">
24
+ <thead>
25
+ <tr>
26
+ <th scope="col"><%= t("#{scope}.columns.attempt") %></th>
27
+ <th scope="col"><%= t("#{scope}.columns.recipient") %></th>
28
+ <th scope="col"><%= t("#{scope}.columns.provider") %></th>
29
+ <th scope="col"><%= t("#{scope}.columns.outcome") %></th>
30
+ <th scope="col"><%= t("#{scope}.columns.reason") %></th>
31
+ <th scope="col"><%= t("#{scope}.columns.time") %></th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <% @deliveries.each do |delivery| %>
36
+ <tr id="<%= delivery.prefixed_id %>">
37
+ <td><%= delivery.attempt_number %></td>
38
+ <td class="font-mono text-sm"><%= mask_recipient(delivery.recipient) %></td>
39
+ <td><%= SpreeNotificationProviders.provider(delivery.provider_key)&.label || delivery.provider_key || '—' %></td>
40
+ <td><%= notification_status_badge(delivery.outcome) %></td>
41
+ <td class="text-xs text-gray-600"><%= delivery.reason %></td>
42
+ <td class="text-sm whitespace-nowrap"><%= l(delivery.created_at, format: :short) %></td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+ <div class="flex justify-between p-4">
48
+ <%= link_to '← ' + Spree.t(:previous), url_for(request.query_parameters.merge(page: @pagy.previous)), class: 'btn btn-light btn-sm' if @pagy.previous %>
49
+ <span></span>
50
+ <%= link_to Spree.t(:next) + ' →', url_for(request.query_parameters.merge(page: @pagy.next)), class: 'btn btn-light btn-sm' if @pagy.next %>
51
+ </div>
52
+ </div>
@@ -0,0 +1,114 @@
1
+ <% scope = 'spree_notification_providers.admin.providers' %>
2
+ <% content_for :page_title do %>
3
+ <%= t("#{scope}.title") %>
4
+ <% end %>
5
+
6
+ <% content_for :page_actions do %>
7
+ <%= link_to t("#{scope}.delivery_log"), spree.admin_notification_deliveries_path(q: { channel_eq: @channel }), class: 'btn btn-light' %>
8
+ <% end %>
9
+
10
+ <p class="text-sm text-gray-500 mb-4"><%= t("#{scope}.intro") %></p>
11
+
12
+ <div class="border-b border-gray-200 mb-6">
13
+ <nav class="-mb-px flex space-x-8" aria-label="Channels">
14
+ <% Spree::NotificationChannelProvider::CHANNELS.each do |channel| %>
15
+ <% active = channel == @channel %>
16
+ <%= link_to notification_channel_label(channel),
17
+ spree.admin_notification_providers_path(channel: channel),
18
+ id: "channel-tab-#{channel}",
19
+ 'aria-current': (active ? 'page' : nil),
20
+ class: "whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm #{active ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}" %>
21
+ <% end %>
22
+ </nav>
23
+ </div>
24
+
25
+ <div class="card-lg mb-6">
26
+ <% if @entries.any? %>
27
+ <table class="table">
28
+ <thead>
29
+ <tr>
30
+ <th scope="col" class="w-5"></th>
31
+ <th scope="col"><%= t('spree_notification_providers.admin.deliveries.columns.provider') %></th>
32
+ <th scope="col"><%= Spree.t(:status) %></th>
33
+ <th scope="col" class="text-center"><%= t("#{scope}.enabled") %></th>
34
+ <th scope="col"></th>
35
+ </tr>
36
+ </thead>
37
+ <tbody data-controller="sortable" data-sortable-handle-value=".move-handle"
38
+ data-sortable-resource-name-value="notification_channel_provider">
39
+ <% @entries.each do |entry| %>
40
+ <% status = entry.status %>
41
+ <tr id="<%= spree_dom_id(entry) %>"
42
+ data-sortable-update-url="<%= spree.position_admin_notification_channel_provider_path(entry) %>">
43
+ <td class="w-5 text-center <%= 'move-handle cursor-move' if @entries.size > 1 %>"
44
+ title="<%= t("#{scope}.drag_to_reorder") if @entries.size > 1 %>">
45
+ <% if @entries.size > 1 %>
46
+ <%= icon('grip-vertical', class: 'rounded-md hover:bg-gray-100 p-2') %>
47
+ <% else %>
48
+ <span class="text-gray-400 text-sm"><%= entry.position %></span>
49
+ <% end %>
50
+ </td>
51
+ <td>
52
+ <div class="font-medium"><%= entry.definition&.label || entry.provider_key.titleize %></div>
53
+ <% if entry.shared_with_channels.size > 1 %>
54
+ <div class="text-xs text-gray-500">
55
+ <%= t('spree_notification_providers.admin.configure.shared_used_by',
56
+ channels: entry.shared_with_channels.map { |c| notification_channel_label(c) }.to_sentence) %>
57
+ </div>
58
+ <% end %>
59
+ </td>
60
+ <td>
61
+ <%= notification_status_badge(status) %>
62
+ <% missing = entry.missing_inputs %>
63
+ <% if missing.any? %>
64
+ <div class="text-xs text-amber-700 mt-1"><%= t("#{scope}.missing", inputs: missing.join(', ')) %></div>
65
+ <% end %>
66
+ </td>
67
+ <td class="text-center">
68
+ <%= form_with url: spree.admin_notification_channel_provider_path(entry), method: :patch, local: true do %>
69
+ <%= hidden_field_tag 'entry[enabled]', entry.enabled? ? '0' : '1' %>
70
+ <%= hidden_field_tag :return_to, 'index' %>
71
+ <button type="submit" role="switch" aria-checked="<%= entry.enabled? %>"
72
+ aria-label="<%= t("#{scope}.enabled") %>"
73
+ class="btn btn-sm <%= entry.enabled? ? 'btn-primary' : 'btn-light' %>">
74
+ <%= entry.enabled? ? Spree.t(:say_yes) : Spree.t(:say_no) %>
75
+ </button>
76
+ <% end %>
77
+ </td>
78
+ <td class="text-right whitespace-nowrap">
79
+ <%= link_to t("#{scope}.configure"), spree.edit_admin_notification_channel_provider_path(entry), class: 'btn btn-light btn-sm' %>
80
+ <%= link_to t("#{scope}.remove"), spree.admin_notification_channel_provider_path(entry),
81
+ class: 'btn btn-light btn-sm text-red-600',
82
+ data: { turbo_method: :delete,
83
+ turbo_confirm: t("#{scope}.remove_confirm",
84
+ provider: entry.definition&.label || entry.provider_key,
85
+ channel: notification_channel_label(entry.channel)) } %>
86
+ </td>
87
+ </tr>
88
+ <% end %>
89
+ </tbody>
90
+ </table>
91
+ <% else %>
92
+ <div class="card-body text-sm text-gray-500"><%= t("#{scope}.none_added") %></div>
93
+ <% end %>
94
+ </div>
95
+
96
+ <% if @available.any? %>
97
+ <h6 class="pb-2"><%= t("#{scope}.available") %></h6>
98
+ <div class="card-lg">
99
+ <table class="table">
100
+ <tbody>
101
+ <% @available.each do |definition| %>
102
+ <tr id="available-<%= definition.key %>">
103
+ <td class="font-medium"><%= definition.label %></td>
104
+ <td class="text-right">
105
+ <%= button_to t("#{scope}.add"), spree.admin_notification_channel_providers_path,
106
+ params: { channel: @channel, provider_key: definition.key },
107
+ class: 'btn btn-primary btn-sm', form: { class: 'inline' } %>
108
+ </td>
109
+ </tr>
110
+ <% end %>
111
+ </tbody>
112
+ </table>
113
+ </div>
114
+ <% end %>
@@ -0,0 +1,92 @@
1
+ en:
2
+ spree:
3
+ admin:
4
+ tab:
5
+ notification_providers: "Notification providers"
6
+ notification_deliveries: "Delivery log"
7
+ notification_providers: "Notification providers"
8
+ notification_deliveries: "Delivery log"
9
+ spree_notification_providers:
10
+ channels:
11
+ email: "Email"
12
+ sms: "SMS"
13
+ whatsapp: "WhatsApp"
14
+ statuses:
15
+ ready: "Ready"
16
+ not_ready: "Not ready"
17
+ disabled: "Disabled"
18
+ unavailable: "Unavailable"
19
+ outcomes:
20
+ delivered: "Delivered"
21
+ failed: "Failed"
22
+ unknown: "Unknown"
23
+ skipped: "Skipped"
24
+ test_message:
25
+ subject: "Test message"
26
+ html: "<p>This is a test message from your store's notification settings.</p>"
27
+ admin:
28
+ providers:
29
+ title: "Notification providers"
30
+ intro: "Providers are tried in this order. A provider is skipped when it is not ready, and the next one is tried only when a provider definitely did not accept the message."
31
+ available: "Available providers"
32
+ none_added: "No providers added for this channel yet."
33
+ add: "Add"
34
+ configure: "Configure"
35
+ remove: "Remove"
36
+ remove_confirm: "Remove %{provider} from %{channel}? Its shared settings are kept while other channels use it."
37
+ enabled: "Enabled"
38
+ missing: "Missing: %{inputs}"
39
+ drag_to_reorder: "Drag to change priority"
40
+ added: "%{provider} added to %{channel}. Fill in its settings and enable it."
41
+ updated: "%{provider} settings saved."
42
+ removed: "%{provider} removed from %{channel}."
43
+ delivery_log: "Delivery log"
44
+ configure:
45
+ title: "%{provider} — %{channel}"
46
+ shared_header: "Shared settings"
47
+ shared_used_by: "Used by: %{channels}"
48
+ channel_header: "%{channel} settings"
49
+ templates_header: "Templates"
50
+ templates_help: "The provider-side template used for each kind of message. Leave blank to skip this provider for that message."
51
+ secret_set: "Set %{hint}"
52
+ secret_not_set: "Not set"
53
+ secret_placeholder: "Leave blank to keep the current value"
54
+ clear: "Clear"
55
+ save: "Save"
56
+ back: "Back to providers"
57
+ test_header: "Send test"
58
+ test_help: "Sends one message through this provider only, ignoring priority."
59
+ test_to_email: "Email address"
60
+ test_to_phone: "Phone number (with country code)"
61
+ test_send: "Send test"
62
+ test_delivered: "Test sent through %{provider}."
63
+ test_failed: "Test through %{provider} did not go out: %{reason}"
64
+ test_invalid_to: "Enter a valid %{kind}."
65
+ deliveries:
66
+ title: "Delivery log"
67
+ intro: "Every attempt to send a message. Recipients are partly hidden. Rows older than %{days} days are removed automatically."
68
+ empty: "No deliveries match these filters."
69
+ filters:
70
+ channel: "Channel"
71
+ provider: "Provider"
72
+ outcome: "Outcome"
73
+ message_type: "Message type"
74
+ from: "From"
75
+ to: "To"
76
+ bulk: "Bulk only"
77
+ apply: "Filter"
78
+ any: "Any"
79
+ columns:
80
+ time: "Time"
81
+ channel: "Channel"
82
+ type: "Type"
83
+ recipient: "Recipient"
84
+ provider: "Provider"
85
+ outcome: "Outcome"
86
+ attempts: "Attempts"
87
+ reason: "Reason"
88
+ attempt: "#"
89
+ source: "Source"
90
+ bulk_recipients: "%{count} recipients"
91
+ show_title: "Message %{ref}"
92
+ back: "Back to delivery log"
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Spree::Core::Engine.add_routes do
4
+ # Gupshup message-event callback: /webhooks/gupshup?secret=<store webhook secret>
5
+ post 'webhooks/gupshup', to: 'webhooks/gupshup#create', as: :gupshup_webhook
6
+
7
+ namespace :admin do
8
+ resources :notification_providers, only: :index
9
+ resources :notification_channel_providers, only: %i[create edit update destroy] do
10
+ member do
11
+ patch :position, action: :reposition
12
+ post :test
13
+ end
14
+ end
15
+ resources :notification_deliveries, only: %i[index show], param: :message_ref
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSpreeNotificationProviderConfigs < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :spree_notification_provider_configs do |t|
6
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }, index: false
7
+ t.string :provider_key, null: false
8
+ t.jsonb :settings, null: false, default: {}
9
+ t.text :secret_settings
10
+ t.string :import_source
11
+ t.datetime :imported_at
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :spree_notification_provider_configs, %i[store_id provider_key], unique: true,
17
+ name: 'idx_spree_notif_provider_configs_on_store_provider'
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSpreeNotificationChannelProviders < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :spree_notification_channel_providers do |t|
6
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }, index: false
7
+ t.string :channel, null: false
8
+ t.string :provider_key, null: false
9
+ t.boolean :enabled, null: false, default: false
10
+ t.integer :position, null: false
11
+ t.jsonb :settings, null: false, default: {}
12
+ t.text :secret_settings
13
+ t.jsonb :templates, null: false, default: {}
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :spree_notification_channel_providers, %i[store_id channel provider_key], unique: true,
19
+ name: 'idx_spree_notif_channel_providers_on_store_channel_provider'
20
+ add_index :spree_notification_channel_providers, %i[store_id channel position],
21
+ name: 'idx_spree_notif_channel_providers_on_store_channel_position'
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSpreeNotificationDeliveries < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :spree_notification_deliveries do |t|
6
+ t.references :store, null: false, foreign_key: { to_table: :spree_stores }, index: false
7
+ t.uuid :message_ref, null: false
8
+ t.string :channel, null: false
9
+ t.string :provider_key
10
+ t.string :message_type, null: false
11
+ t.string :recipient, null: false
12
+ t.string :outcome, null: false
13
+ t.string :reason, limit: 255
14
+ t.string :provider_message_id
15
+ t.integer :attempt_number
16
+ t.boolean :bulk, null: false, default: false
17
+ t.string :source_type
18
+ t.bigint :source_id
19
+ t.datetime :created_at, null: false
20
+ end
21
+
22
+ add_index :spree_notification_deliveries, %i[store_id created_at], name: 'idx_spree_notif_deliveries_on_store_created'
23
+ add_index :spree_notification_deliveries, %i[store_id channel outcome created_at],
24
+ name: 'idx_spree_notif_deliveries_on_store_channel_outcome'
25
+ add_index :spree_notification_deliveries, :message_ref
26
+ add_index :spree_notification_deliveries, :provider_message_id
27
+ add_index :spree_notification_deliveries, %i[source_type source_id]
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module SpreeNotificationProviders
6
+ module Generators
7
+ # Copies provider environment variables (MSG91_AUTH_KEY, SMTP_HOST, ...)
8
+ # into a store's provider settings. Fills blanks only; safe to rerun.
9
+ class ImportEnvGenerator < Rails::Generators::Base
10
+ desc "Import provider settings from environment variables into a store's configuration"
11
+
12
+ class_option :store, type: :string, desc: 'Store code (default: the default store)'
13
+ class_option :dry_run, type: :boolean, default: false, desc: 'Print the report without saving'
14
+
15
+ def import
16
+ store = options[:store].present? ? Spree::Store.find_by!(code: options[:store]) : Spree::Store.default
17
+ writer = SpreeNotificationProviders::Importers::EnvImporter.new(store, dry_run: options[:dry_run]).call
18
+ say writer.report
19
+ say(options[:dry_run] ? 'Dry run: nothing saved.' : 'Saved.')
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module SpreeNotificationProviders
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ desc 'Install spree_notification_providers migrations and initializer'
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ class_option :migrate, type: :boolean, default: true, banner: 'Run migrations'
12
+
13
+ def add_initializer
14
+ template 'initializer.rb', 'config/initializers/spree_notification_providers.rb'
15
+ end
16
+
17
+ def add_migrations
18
+ run 'bundle exec rake railties:install:migrations FROM=spree_notification_providers'
19
+ end
20
+
21
+ def run_migrations
22
+ if options[:migrate]
23
+ run 'bundle exec rake db:migrate'
24
+ else
25
+ say_status :skipping, 'db:migrate, remember to run migrations manually'
26
+ end
27
+ end
28
+
29
+ def show_next_steps
30
+ say <<~TEXT
31
+
32
+ spree_notification_providers is installed. Next:
33
+ 1. Add Spree::PermissionSets::NotificationProviderManagement to non-super-user admin roles.
34
+ 2. Set config.action_mailer.delivery_method = :spree_notification_providers to route mailers.
35
+ 3. Schedule Spree::NotificationDeliveries::PurgeJob daily.
36
+ 4. Admin -> Settings -> Notification providers to add providers.
37
+ TEXT
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ SpreeNotificationProviders.configure do |config|
4
+ # Delivery log rows older than this are removed by
5
+ # Spree::NotificationDeliveries::PurgeJob.
6
+ config.delivery_retention_days = 90
7
+
8
+ # The Log provider writes messages to the Rails log instead of sending them.
9
+ # config.enable_log_provider = !Rails.env.production?
10
+ end
11
+
12
+ # Each message type gets its own template field on template-based providers.
13
+ # SpreeNotificationProviders.register_message_type :order_confirmation,
14
+ # label: 'Order confirmation', channels: %i[email sms]
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeNotificationProviders
4
+ module Adapters
5
+ # What an adapter gets for each call.
6
+ #
7
+ # settings - the provider's shared and channel inputs merged, secrets
8
+ # decrypted, defaults applied, keyed by Symbol
9
+ # template - the template for this message's type (or the message's own
10
+ # override), or nil
11
+ # templates - every template configured on the channel, keyed by type
12
+ Context = Struct.new(:store, :channel, :settings, :template, :templates, keyword_init: true) do
13
+ def [](name)
14
+ settings[name.to_sym]
15
+ end
16
+ end
17
+
18
+ # Subclass this to add a provider. Return a Result from every method;
19
+ # handle transport errors yourself (see HttpOutcome). An exception raised
20
+ # here is recorded as a failure by the router.
21
+ class Base
22
+ TEST_CODE = '123456'
23
+
24
+ attr_reader :definition
25
+
26
+ def initialize(definition)
27
+ @definition = definition
28
+ end
29
+
30
+ def deliver(_message, _context)
31
+ raise NotImplementedError, "#{self.class.name}#deliver must be implemented"
32
+ end
33
+
34
+ def deliver_bulk(_bulk, _context)
35
+ raise NotImplementedError, "#{self.class.name}#deliver_bulk must be implemented"
36
+ end
37
+
38
+ # Admin "Send test": a real send through this provider only.
39
+ def test_message(channel, to, context)
40
+ deliver(test_message_for(channel, to), context)
41
+ end
42
+
43
+ def test_message_for(channel, to)
44
+ capability = definition.capability(channel)
45
+ kind = if capability.supports_kind?(:otp) then :otp
46
+ elsif capability.supports_kind?(:html) then :html
47
+ else :template
48
+ end
49
+ Message.new(
50
+ channel: channel, type: :provider_test, kind: kind, to: to,
51
+ code: TEST_CODE, expiry_minutes: 10,
52
+ subject: I18n.t('spree_notification_providers.test_message.subject', default: 'Test message'),
53
+ html: I18n.t('spree_notification_providers.test_message.html',
54
+ default: '<p>This is a test message from your store.</p>'),
55
+ variables: {}
56
+ )
57
+ end
58
+
59
+ protected
60
+
61
+ # Replaces any secret input value found in `text` before it is stored as
62
+ # a reason or logged.
63
+ def scrub(text, context)
64
+ return nil if text.blank?
65
+
66
+ secrets = definition_secret_keys(context.channel).filter_map { |key| context[key].presence&.to_s }
67
+ secrets.reduce(text.to_s) { |memo, secret| memo.gsub(secret, '[FILTERED]') }.truncate(120)
68
+ end
69
+
70
+ def definition_secret_keys(channel)
71
+ inputs = definition.shared_inputs + Array(definition.capability(channel)&.inputs)
72
+ inputs.select(&:secret?).map(&:name)
73
+ end
74
+ end
75
+ end
76
+ end