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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +243 -4
- data/app/controllers/sm_sms_campaign_webhook/application_controller.rb +22 -0
- data/app/controllers/sm_sms_campaign_webhook/webhook_controller.rb +25 -0
- data/app/exceptions/sm_sms_campaign_webhook/error.rb +6 -0
- data/app/exceptions/sm_sms_campaign_webhook/invalid_payload.rb +6 -0
- data/app/exceptions/sm_sms_campaign_webhook/invalid_payload_value.rb +6 -0
- data/app/exceptions/sm_sms_campaign_webhook/missing_config_error.rb +6 -0
- data/app/exceptions/sm_sms_campaign_webhook/payload_dispatch_error.rb +6 -0
- data/app/jobs/sm_sms_campaign_webhook/application_job.rb +7 -0
- data/app/jobs/sm_sms_campaign_webhook/dispatch_payload_job.rb +14 -0
- data/app/jobs/sm_sms_campaign_webhook/process_campaign_engagement_job.rb +14 -0
- data/app/models/sm_sms_campaign_webhook/campaign_engagement.rb +216 -0
- data/app/models/sm_sms_campaign_webhook/campaign_engagement/answer.rb +72 -0
- data/app/operations/sm_sms_campaign_webhook/campaign_engagement_operation.rb +36 -0
- data/app/operations/sm_sms_campaign_webhook/payload_operation.rb +26 -0
- data/app/processors/sm_sms_campaign_webhook/default_processor.rb +11 -0
- data/app/processors/sm_sms_campaign_webhook/processable.rb +21 -0
- data/config/routes.rb +7 -0
- data/lib/generators/sm_sms_campaign_webhook/install/USAGE +12 -0
- data/lib/generators/sm_sms_campaign_webhook/install/install_generator.rb +35 -0
- data/lib/generators/sm_sms_campaign_webhook/install/templates/README +25 -0
- data/lib/generators/sm_sms_campaign_webhook/install/templates/sm_sms_campaign_webhook.rb +9 -0
- data/lib/generators/sm_sms_campaign_webhook/install/templates/sms_payload_processor.rb.erb +13 -0
- data/lib/sm_sms_campaign_webhook.rb +25 -2
- data/lib/sm_sms_campaign_webhook/engine.rb +11 -0
- data/lib/sm_sms_campaign_webhook/version.rb +1 -1
- data/sm_sms_campaign_webhook.gemspec +27 -19
- metadata +66 -16
- data/.gitignore +0 -16
- data/.rspec +0 -3
- data/.travis.yml +0 -18
- data/Gemfile +0 -4
- data/Rakefile +0 -6
- data/bin/console +0 -14
- 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,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
|
data/config/routes.rb
ADDED
@@ -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
|