sm_sms_campaign_webhook 0.1.1 → 1.0.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +243 -4
  4. data/app/controllers/sm_sms_campaign_webhook/application_controller.rb +22 -0
  5. data/app/controllers/sm_sms_campaign_webhook/webhook_controller.rb +25 -0
  6. data/app/exceptions/sm_sms_campaign_webhook/error.rb +6 -0
  7. data/app/exceptions/sm_sms_campaign_webhook/invalid_payload.rb +6 -0
  8. data/app/exceptions/sm_sms_campaign_webhook/invalid_payload_value.rb +6 -0
  9. data/app/exceptions/sm_sms_campaign_webhook/missing_config_error.rb +6 -0
  10. data/app/exceptions/sm_sms_campaign_webhook/payload_dispatch_error.rb +6 -0
  11. data/app/jobs/sm_sms_campaign_webhook/application_job.rb +7 -0
  12. data/app/jobs/sm_sms_campaign_webhook/dispatch_payload_job.rb +14 -0
  13. data/app/jobs/sm_sms_campaign_webhook/process_campaign_engagement_job.rb +14 -0
  14. data/app/models/sm_sms_campaign_webhook/campaign_engagement.rb +216 -0
  15. data/app/models/sm_sms_campaign_webhook/campaign_engagement/answer.rb +72 -0
  16. data/app/operations/sm_sms_campaign_webhook/campaign_engagement_operation.rb +36 -0
  17. data/app/operations/sm_sms_campaign_webhook/payload_operation.rb +26 -0
  18. data/app/processors/sm_sms_campaign_webhook/default_processor.rb +11 -0
  19. data/app/processors/sm_sms_campaign_webhook/processable.rb +21 -0
  20. data/config/routes.rb +7 -0
  21. data/lib/generators/sm_sms_campaign_webhook/install/USAGE +12 -0
  22. data/lib/generators/sm_sms_campaign_webhook/install/install_generator.rb +35 -0
  23. data/lib/generators/sm_sms_campaign_webhook/install/templates/README +25 -0
  24. data/lib/generators/sm_sms_campaign_webhook/install/templates/sm_sms_campaign_webhook.rb +9 -0
  25. data/lib/generators/sm_sms_campaign_webhook/install/templates/sms_payload_processor.rb.erb +13 -0
  26. data/lib/sm_sms_campaign_webhook.rb +25 -2
  27. data/lib/sm_sms_campaign_webhook/engine.rb +11 -0
  28. data/lib/sm_sms_campaign_webhook/version.rb +1 -1
  29. data/sm_sms_campaign_webhook.gemspec +27 -19
  30. metadata +66 -16
  31. data/.gitignore +0 -16
  32. data/.rspec +0 -3
  33. data/.travis.yml +0 -18
  34. data/Gemfile +0 -4
  35. data/Rakefile +0 -6
  36. data/bin/console +0 -14
  37. data/bin/setup +0 -8
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # Data model for campaign engagement event from SMS campaign.
7
+ class CampaignEngagement
8
+ attr_reader :payload
9
+
10
+ # @param payload [Hash] Campaign engagement event payload
11
+ def initialize(payload:)
12
+ @payload = payload.deep_dup.freeze
13
+ end
14
+
15
+ # @return [String] Campaign engagement event UUID
16
+ # @raise [InvalidPayload] when uuid missing from payload
17
+ def event_uuid
18
+ @event_uuid ||= String(
19
+ payload.fetch("uuid") do
20
+ raise_invalid_payload_for("uuid")
21
+ end.freeze
22
+ )
23
+ end
24
+
25
+ # @return [String] Campaign engagement event type
26
+ # @raise [InvalidPayload] when type missing from payload
27
+ def event_type
28
+ @event_type ||= String(
29
+ payload.fetch("type") do
30
+ raise_invalid_payload_for("type")
31
+ end.freeze
32
+ )
33
+ end
34
+
35
+ # @return [DateTime] Campaign engagement event timestamp
36
+ # @raise [InvalidPayload] when created_at missing from payload
37
+ # @raise [InvalidPayloadValue] when created_at not datetime
38
+ def event_created_at
39
+ @event_created_at ||= begin
40
+ raw_created_at = payload.fetch("created_at") do
41
+ raise_invalid_payload_for("created_at")
42
+ end
43
+ DateTime.parse(raw_created_at).freeze
44
+ end
45
+ rescue ArgumentError
46
+ raise InvalidPayloadValue,
47
+ "created_at has invalid datetime value #{payload.inspect}"
48
+ end
49
+
50
+ # @return [Integer] ID of the engaged campaign
51
+ # @raise [InvalidPayload] when campaign id missing from payload
52
+ # @raise [InvalidPayloadValue] when campaign id not numeric
53
+ def campaign_id
54
+ @campaign_id ||= Integer(
55
+ campaign_hash.fetch("id") do
56
+ raise_invalid_payload_for("campaign id")
57
+ end
58
+ )
59
+ rescue ArgumentError
60
+ raise InvalidPayloadValue,
61
+ "campaign id has invalid integer value #{payload.inspect}"
62
+ end
63
+
64
+ # @return [String] Keyword of the engaged campaign
65
+ # @raise [InvalidPayload] when campaign keyword missing from payload
66
+ def campaign_keyword
67
+ @campaign_keyword ||= String(
68
+ campaign_hash.fetch("keyword") do
69
+ raise_invalid_payload_for("campaign keyword")
70
+ end.freeze
71
+ )
72
+ end
73
+
74
+ # @return [Integer] ID of the engaging phone
75
+ # @raise [InvalidPayload] when phone id missing from payload
76
+ # @raise [InvalidPayloadValue] when phone id not numeric
77
+ def phone_id
78
+ @phone_id ||= Integer(
79
+ phone_hash.fetch("id") do
80
+ raise_invalid_payload_for("phone id")
81
+ end
82
+ )
83
+ rescue ArgumentError
84
+ raise InvalidPayloadValue,
85
+ "phone id has invalid integer value #{payload.inspect}"
86
+ end
87
+
88
+ # @return [String] Phone number engaging the campaign
89
+ # @raise [InvalidPayload] when phone number missing from payload
90
+ def phone_number
91
+ @phone_number ||= String(
92
+ phone_hash.fetch("number") do
93
+ raise_invalid_payload_for("phone number")
94
+ end.freeze
95
+ )
96
+ end
97
+
98
+ # @return [Integer] ID of campaign engagement state record
99
+ # @raise [InvalidPayload] when phone_campaign_state id missing from payload
100
+ # @raise [InvalidPayloadValue] when phone_campaign_state id not numeric
101
+ def phone_campaign_state_id
102
+ @phone_campaign_state_id ||= Integer(
103
+ phone_campaign_state_hash.fetch("id") do
104
+ raise_invalid_payload_for("phone_campaign_state id")
105
+ end
106
+ )
107
+ rescue ArgumentError
108
+ raise InvalidPayloadValue,
109
+ "phone_campaign_state id has invalid integer value #{payload.inspect}"
110
+ end
111
+
112
+ # @return [TrueClass,FalseClass] Has campaign engagement completed?
113
+ # @raise [InvalidPayload] when phone_campaign_state completed missing from payload
114
+ # @raise [InvalidPayloadValue] when phone_campaign_state completed is not boolean
115
+ def phone_campaign_state_completed?
116
+ # Has the boolean value already been assigned?
117
+ if !@phone_campaign_state_completed.nil?
118
+ return @phone_campaign_state_completed
119
+ end
120
+
121
+ # Extract the value and memoize it.
122
+ @phone_campaign_state_completed = begin
123
+ completed = phone_campaign_state_hash
124
+ .fetch("completed") do
125
+ raise_invalid_payload_for("phone_campaign_state completed")
126
+ end
127
+
128
+ # Is this a boolean value?
129
+ if [true, false].none?(completed)
130
+ raise InvalidPayloadValue,
131
+ "phone_campaign_state completed has invalid boolean value #{payload.inspect}"
132
+ end
133
+
134
+ completed
135
+ end
136
+ end
137
+
138
+ # @return [DateTime,NilClass] Timestamp of campaign engagement completion if completed
139
+ # @raise [InvalidPayload] when phone_campaign_state completed_at missing from payload
140
+ # @raise [InvalidPayloadValue] when phone_campaign_state completed_at not datetime
141
+ def phone_campaign_state_completed_at
142
+ @phone_campaign_state_completed_at ||= begin
143
+ raw_completed_at = phone_campaign_state_hash.fetch("completed_at") do
144
+ raise_invalid_payload_for("phone_campaign_state completed_at")
145
+ end
146
+ DateTime.parse(raw_completed_at).freeze if raw_completed_at
147
+ end
148
+ rescue ArgumentError
149
+ raise InvalidPayloadValue,
150
+ "phone_campaign_state completed_at has invalid datetime value #{payload.inspect}"
151
+ end
152
+
153
+ # @return [Array<Answer>] Modeled campaign engagement answers
154
+ # @raise [InvalidPayload] when phone_campaign_state answers missing from payload
155
+ # @raise [InvalidPayloadValue] when phone_campaign_state answers not hash
156
+ def phone_campaign_state_answers
157
+ @phone_campaign_state_answers ||= begin
158
+ # Extract answers data from payload.
159
+ data = phone_campaign_state_hash.fetch("answers") do
160
+ raise_invalid_payload_for("phone_campaign_state answers")
161
+ end
162
+
163
+ # Is this hash data?
164
+ if !data.is_a?(Hash)
165
+ raise InvalidPayloadValue,
166
+ "phone_campaign_state answers has invalid hash value #{payload.inspect}"
167
+ end
168
+
169
+ # Cast answers data.
170
+ Answer.cast(data: data).freeze
171
+ end
172
+ end
173
+
174
+ # @return [TrueClass,FalseClass] Are any campaign engagement answers present?
175
+ def phone_campaign_state_answers?
176
+ !phone_campaign_state_answers.empty?
177
+ end
178
+
179
+ # @param field [String] Answer data to find
180
+ # @return [Answer,NilClass] Modeled answer for field when found
181
+ def answer_for(field:)
182
+ phone_campaign_state_answers.detect do |answer|
183
+ answer.field == field
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ # @param attr [String] Expected attribute missing from payload
190
+ def raise_invalid_payload_for(attr)
191
+ raise InvalidPayload, "#{attr} missing from payload #{payload.inspect}"
192
+ end
193
+
194
+ # @return [Hash] Data from campaign engagement payload
195
+ def payload_data
196
+ @payload_data ||= payload.fetch("data", {}).freeze
197
+ end
198
+
199
+ # @return [Hash] Campaign hash from payload data
200
+ def campaign_hash
201
+ @campaign_hash ||= payload_data.fetch("campaign", {}).freeze
202
+ end
203
+
204
+ # @return [Hash] Phone hash from payload data
205
+ def phone_hash
206
+ @phone_hash ||= payload_data.fetch("phone", {}).freeze
207
+ end
208
+
209
+ # @return [Hash] Campaign engagement state hash from payload data
210
+ def phone_campaign_state_hash
211
+ @phone_campaign_state_hash ||= payload_data
212
+ .fetch("phone_campaign_state", {})
213
+ .freeze
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SmSmsCampaignWebhook
6
+ class CampaignEngagement
7
+ # Data model for campaign engagement answer data.
8
+ class Answer
9
+ # @param data [Hash] Answers from payload
10
+ # @return [Array<Answer>] Modeled answer data sorted by collected_at
11
+ def self.cast(data:)
12
+ data.map do |field, answer_hash|
13
+ new(field: field, answer_hash: answer_hash)
14
+ end.sort_by(&:collected_at)
15
+ end
16
+
17
+ attr_reader :field,
18
+ :answer_hash
19
+
20
+ # @param field [String] Field describing the answer
21
+ # @param answer_hash [Hash] Answer data from payload
22
+ def initialize(field:, answer_hash:)
23
+ @field = String(field)
24
+ @answer_hash = answer_hash.freeze
25
+ end
26
+
27
+ # Collected answer could be many different value types.
28
+ #
29
+ # The SMS campaign service collects answers of type:
30
+ # string, email, date, number, boolean, us_state
31
+ #
32
+ # The possible types are from SMS campaign service perspective.
33
+ # They are coerced to the appropriate type in Ruby.
34
+ #
35
+ # @return [String,Integer,Date,TrueClass,FalseClass] Coerced answer
36
+ # @raise [InvalidPayload] when value is missing from answer_hash
37
+ def value
38
+ # Could be boolean so cannot rely on double pipe assignment guard.
39
+ if !@value.nil?
40
+ return @value
41
+ end
42
+
43
+ # Extract the value and memoize it.
44
+ @value = begin
45
+ raw_value = answer_hash.fetch("value") do
46
+ raise InvalidPayload,
47
+ "value missing from answer_hash #{answer_hash.inspect}"
48
+ end.freeze
49
+
50
+ # Attempt to parse date value falling back to raw value.
51
+ Date.strptime(raw_value, "%Y-%m-%d").freeze rescue raw_value
52
+ end
53
+ end
54
+
55
+ # @return [DateTime] Timestamp of answer value collection
56
+ # @raise [InvalidPayload] when collected_at missing from answer_hash
57
+ # @raise [InvalidPayloadValue] when collected_at not datetime
58
+ def collected_at
59
+ @collected_at ||= begin
60
+ raw_collected_at = answer_hash.fetch("collected_at") do
61
+ raise InvalidPayload,
62
+ "collected_at missing from answer_hash #{answer_hash.inspect}"
63
+ end
64
+ DateTime.parse(raw_collected_at).freeze
65
+ end
66
+ rescue ArgumentError
67
+ raise InvalidPayloadValue,
68
+ "collected_at has invalid datetime value #{answer_hash.inspect}"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmSmsCampaignWebhook
4
+ # Handles campaign engagement payload data modeling and processing.
5
+ class CampaignEngagementOperation
6
+ # Models the campaign engagement payload and hands of processing
7
+ # of the data to the processor.
8
+ #
9
+ # @param payload [Hash] Deserialized SMS campaign engagement payload
10
+ # @raise [PayloadDispatchError] when not campaign engagement payload
11
+ # @see .processor
12
+ def self.process(payload:)
13
+ logger.debug "#{name} - Processing campaign engagement payload: #{payload.inspect}"
14
+
15
+ if payload.fetch("type") != "campaign.engagement"
16
+ raise PayloadDispatchError,
17
+ "dispatched payload different from campaign.engagement #{payload.inspect}"
18
+ end
19
+
20
+ campaign_enagement = CampaignEngagement.new(payload: payload)
21
+ processor.process_campaign_engagement(campaign_enagement)
22
+ end
23
+
24
+ # @return [Processable] Configured SMS campaign payload processor
25
+ # @see SmSmsCampaignWebhook.processor
26
+ def self.processor
27
+ @processor ||= SmSmsCampaignWebhook.processor
28
+ end
29
+
30
+ # @return [ActiveSupport::Logger] Abstraction of app logger
31
+ def self.logger
32
+ @logger ||= Rails.logger
33
+ end
34
+ private_class_method :logger
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmSmsCampaignWebhook
4
+ # Handles payload data modeling and data processing.
5
+ class PayloadOperation
6
+ # @param payload [Hash] Deserialized payload from SMS campaign service
7
+ # @see ProcessCampaignEngagementJob#perform
8
+ def self.dispatch(payload:)
9
+ logger.debug "#{name} - Dispatching payload: #{payload.inspect}"
10
+
11
+ case payload.fetch("type", "unknown")
12
+ when "campaign.engagement"
13
+ ProcessCampaignEngagementJob.perform_later(payload)
14
+ else
15
+ # NOOP - Unsupported event type.
16
+ logger.warn "#{name} - Unsupported event type"
17
+ end
18
+ end
19
+
20
+ # @return [ActiveSupport::Logger] Abstraction of app logger
21
+ def self.logger
22
+ @logger ||= Rails.logger
23
+ end
24
+ private_class_method :logger
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency "sm_sms_campaign_webhook/processable"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # Default processor with NOOP implementations.
7
+ # @see Processable
8
+ class DefaultProcessor
9
+ include Processable
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmSmsCampaignWebhook
4
+ # Define behavior that SMS campaign payload processors must implement.
5
+ module Processable
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # Campaign engagement operation sends modeled campaign engagement payload
10
+ # to this method for applying business logic. Implementors should define
11
+ # the app behavior to properly handle this data.
12
+ #
13
+ # @param campaign_engagement [CampaignEngagement] modeled payload
14
+ # @raise [NotImplementedError] requiring implementing class to define behavior
15
+ def process_campaign_engagement(campaign_engagement)
16
+ raise NotImplementedError,
17
+ "#{self.class} must implement .process_campaign_engagement receiving campaign_engagement param"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ SmSmsCampaignWebhook::Engine.routes.draw do
4
+ scope "/api" do
5
+ resources :webhook, only: [:create]
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ Description:
2
+ Configures the app to handle inbound requests from the SMS campaign service.
3
+
4
+ Example:
5
+ rails generate sm_sms_campaign_webhook:install
6
+
7
+ This will create:
8
+ app/processors/sms_payload_processor.rb
9
+ config/initializers/sm_sms_campaign_webhook.rb
10
+
11
+ This will mount the engine in:
12
+ config/routes.rb
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module SmSmsCampaignWebhook
6
+ # Namespace for generators provided by the gem.
7
+ module Generators
8
+ # Installs files to prep an app for SMS campaign webhook.
9
+ class InstallGenerator < Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ # Copy initializer template to config/initializers
13
+ def copy_initializer
14
+ template "sm_sms_campaign_webhook.rb", "config/initializers/sm_sms_campaign_webhook.rb"
15
+ end
16
+
17
+ # Copy processor template to app/processors
18
+ def copy_processor
19
+ template "sms_payload_processor.rb.erb", "app/processors/sms_payload_processor.rb"
20
+ end
21
+
22
+ # Mount engine to path in config/routes.rb
23
+ def add_mount_path
24
+ insert_into_file "config/routes.rb",
25
+ " mount SmSmsCampaignWebhook::Engine => \"/sms_campaign\"\n",
26
+ after: "Rails.application.routes.draw do\n"
27
+ end
28
+
29
+ # Dump the README for the app developer
30
+ def show_readme
31
+ readme "README" if behavior == :invoke
32
+ end
33
+ end
34
+ end
35
+ end