sm_sms_campaign_webhook 0.1.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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