heya 0.0.1 → 0.4.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 +29 -0
- data/LICENSE +661 -0
- data/README.md +573 -0
- data/Rakefile +14 -14
- data/app/mailers/heya/application_mailer.rb +0 -2
- data/app/mailers/heya/campaign_mailer.rb +52 -0
- data/app/models/heya/campaign_membership.rb +90 -0
- data/app/models/heya/campaign_receipt.rb +5 -0
- data/app/views/layouts/heya/campaign_mailer.html.erb +20 -0
- data/app/views/layouts/heya/campaign_mailer.text.erb +1 -0
- data/lib/generators/heya/campaign/USAGE +14 -0
- data/lib/generators/heya/campaign/campaign_generator.rb +69 -0
- data/lib/generators/heya/campaign/templates/campaign.rb.tt +4 -0
- data/lib/generators/heya/campaign/templates/message.html.erb.tt +1 -0
- data/lib/generators/heya/campaign/templates/message.md.erb.tt +1 -0
- data/lib/generators/heya/campaign/templates/message.text.erb.tt +1 -0
- data/lib/generators/heya/campaign/templates/preview.rb.tt +12 -0
- data/lib/generators/heya/install/USAGE +10 -0
- data/lib/generators/heya/install/install_generator.rb +24 -0
- data/lib/generators/heya/install/templates/application_campaign.rb.tt +3 -0
- data/lib/generators/heya/install/templates/initializer.rb.tt +16 -0
- data/lib/generators/heya/install/templates/migration.rb.tt +29 -0
- data/lib/heya.rb +45 -1
- data/lib/heya/active_record_extension.rb +37 -0
- data/lib/heya/campaigns/action.rb +27 -0
- data/lib/heya/campaigns/actions/block.rb +24 -0
- data/lib/heya/campaigns/actions/email.rb +24 -0
- data/lib/heya/campaigns/base.rb +154 -0
- data/lib/heya/campaigns/queries.rb +35 -0
- data/lib/heya/campaigns/scheduler.rb +50 -0
- data/lib/heya/campaigns/step.rb +35 -0
- data/lib/heya/campaigns/step_action_job.rb +34 -0
- data/lib/heya/config.rb +17 -0
- data/lib/heya/engine.rb +14 -0
- data/lib/heya/version.rb +3 -1
- data/lib/tasks/heya_tasks.rake +8 -4
- metadata +64 -11
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record/relation"
|
4
|
+
|
5
|
+
module Heya
|
6
|
+
module ActiveRecordRelationExtension
|
7
|
+
TABLE_REGEXP = /heya_steps/
|
8
|
+
|
9
|
+
def build_arel(aliases)
|
10
|
+
arel = super(aliases)
|
11
|
+
|
12
|
+
if table_name == "heya_campaign_memberships" && arel.to_sql =~ TABLE_REGEXP
|
13
|
+
# https://www.postgresql.org/docs/9.4/queries-values.html
|
14
|
+
values = Heya
|
15
|
+
.campaigns.reduce([]) { |steps, campaign| steps | campaign.steps }
|
16
|
+
.map { |step|
|
17
|
+
ActiveRecord::Base.sanitize_sql_array(
|
18
|
+
["(?, ?)", step.gid, step.wait.to_i]
|
19
|
+
)
|
20
|
+
}
|
21
|
+
|
22
|
+
if values.any?
|
23
|
+
arel.with(
|
24
|
+
Arel::Nodes::As.new(
|
25
|
+
Arel::Table.new(:heya_steps),
|
26
|
+
Arel::Nodes::SqlLiteral.new("(SELECT * FROM (VALUES #{values.join(", ")}) AS heya_steps (gid,wait))")
|
27
|
+
)
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
arel
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
ActiveRecord::Relation.prepend(ActiveRecordRelationExtension)
|
37
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Heya
|
4
|
+
module Campaigns
|
5
|
+
class Action
|
6
|
+
def initialize(user:, step:)
|
7
|
+
@user, @step = user, step
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :user, :step
|
11
|
+
|
12
|
+
def build
|
13
|
+
raise NotImplementedError, "Please implement #build on subclass of Heya::Campaigns::Action."
|
14
|
+
end
|
15
|
+
|
16
|
+
def deliver_now
|
17
|
+
build.deliver
|
18
|
+
end
|
19
|
+
|
20
|
+
def deliver_later
|
21
|
+
StepActionJob
|
22
|
+
.set(queue: step.queue)
|
23
|
+
.perform_later(step.campaign.class.name, user, step)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Heya
|
4
|
+
module Campaigns
|
5
|
+
module Actions
|
6
|
+
class Block < Action
|
7
|
+
class Execution
|
8
|
+
def initialize(user:, step:, &block)
|
9
|
+
@user, @step, @block = user, step, block
|
10
|
+
end
|
11
|
+
|
12
|
+
def deliver
|
13
|
+
instance_exec(@user, @step, &@block)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def build
|
18
|
+
block = step.params.fetch("block")
|
19
|
+
Execution.new(user: user, step: step, &block)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Heya
|
4
|
+
module Campaigns
|
5
|
+
module Actions
|
6
|
+
class Email < Action
|
7
|
+
VALID_PARAMS = %w[subject from reply_to]
|
8
|
+
|
9
|
+
def self.validate_step(step)
|
10
|
+
step.params.assert_valid_keys(VALID_PARAMS)
|
11
|
+
unless step.params["subject"].present? || I18n.exists?("#{step.campaign_name.underscore}.#{step.name.underscore}.subject")
|
12
|
+
raise ArgumentError.new(%("subject" is required))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def build
|
17
|
+
CampaignMailer
|
18
|
+
.with(user: user, step: step)
|
19
|
+
.build
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/descendants_tracker"
|
4
|
+
require "active_support/rescuable"
|
5
|
+
|
6
|
+
module Heya
|
7
|
+
module Campaigns
|
8
|
+
# {Campaigns::Base} provides a Ruby DSL for building campaign sequences.
|
9
|
+
# Multiple actions are supported; the default is email.
|
10
|
+
class Base
|
11
|
+
extend ActiveSupport::DescendantsTracker
|
12
|
+
|
13
|
+
include Singleton
|
14
|
+
include GlobalID::Identification
|
15
|
+
include ActiveSupport::Rescuable
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
self.steps = []
|
19
|
+
end
|
20
|
+
|
21
|
+
delegate :name, :__segments, to: :class
|
22
|
+
alias id name
|
23
|
+
|
24
|
+
# Returns String GlobalID.
|
25
|
+
def gid
|
26
|
+
to_gid(app: "heya").to_s
|
27
|
+
end
|
28
|
+
|
29
|
+
def add(user, restart: false, concurrent: false, send_now: true)
|
30
|
+
return false unless Heya.in_segments?(user, *__segments)
|
31
|
+
|
32
|
+
membership = CampaignMembership.where(user: user, campaign_gid: gid)
|
33
|
+
if membership.exists?
|
34
|
+
return false unless restart
|
35
|
+
membership.delete_all
|
36
|
+
end
|
37
|
+
|
38
|
+
if restart
|
39
|
+
CampaignReceipt
|
40
|
+
.where(user: user, step_gid: steps.map(&:gid))
|
41
|
+
.delete_all
|
42
|
+
end
|
43
|
+
|
44
|
+
if (step = steps.first)
|
45
|
+
membership.create! do |m|
|
46
|
+
m.concurrent = concurrent
|
47
|
+
m.step_gid = step.gid
|
48
|
+
end
|
49
|
+
|
50
|
+
if send_now && step.wait == 0
|
51
|
+
Scheduler.new.run(user: user)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
true
|
56
|
+
end
|
57
|
+
|
58
|
+
def remove(user)
|
59
|
+
CampaignMembership.where(user: user, campaign_gid: gid).delete_all
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
def user_class
|
64
|
+
@user_class ||= self.class.user_type.constantize
|
65
|
+
end
|
66
|
+
|
67
|
+
def handle_exception(exception)
|
68
|
+
rescue_with_handler(exception) || raise(exception)
|
69
|
+
end
|
70
|
+
|
71
|
+
attr_accessor :steps
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
delegate :sanitize_sql_array, to: ActiveRecord::Base
|
76
|
+
|
77
|
+
class_attribute :__defaults, default: {}.freeze
|
78
|
+
class_attribute :__segments, default: [].freeze
|
79
|
+
class_attribute :__user_type, default: nil
|
80
|
+
|
81
|
+
STEP_ATTRS = {
|
82
|
+
action: Actions::Email,
|
83
|
+
wait: 2.days,
|
84
|
+
segment: nil,
|
85
|
+
queue: "heya"
|
86
|
+
}.freeze
|
87
|
+
|
88
|
+
class << self
|
89
|
+
def inherited(campaign)
|
90
|
+
Heya.register_campaign(campaign)
|
91
|
+
Heya.unregister_campaign(campaign.superclass)
|
92
|
+
super
|
93
|
+
end
|
94
|
+
|
95
|
+
def find(_id)
|
96
|
+
instance
|
97
|
+
end
|
98
|
+
|
99
|
+
delegate :steps, :add, :remove, :gid, :user_class, :handle_exception, to: :instance
|
100
|
+
|
101
|
+
def default(**params)
|
102
|
+
self.__defaults = __defaults.merge(params).freeze
|
103
|
+
end
|
104
|
+
|
105
|
+
def user_type(value = nil)
|
106
|
+
if value.present?
|
107
|
+
self.__user_type = value.is_a?(String) ? value.to_s : value.name
|
108
|
+
end
|
109
|
+
|
110
|
+
__user_type || Heya.config.user_type
|
111
|
+
end
|
112
|
+
|
113
|
+
def segment(arg = nil, &block)
|
114
|
+
if block_given?
|
115
|
+
self.__segments = ([block] | __segments).freeze
|
116
|
+
elsif arg
|
117
|
+
self.__segments = ([arg] | __segments).freeze
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def step(name, **opts, &block)
|
122
|
+
if block_given?
|
123
|
+
opts[:block] ||= block
|
124
|
+
opts[:action] ||= Actions::Block
|
125
|
+
end
|
126
|
+
|
127
|
+
opts =
|
128
|
+
STEP_ATTRS
|
129
|
+
.merge(Heya.config.campaigns.default_options)
|
130
|
+
.merge(__defaults)
|
131
|
+
.merge(opts)
|
132
|
+
|
133
|
+
attrs = opts.select { |k, _| STEP_ATTRS.key?(k) }
|
134
|
+
attrs[:id] = "#{self.name}/#{name}"
|
135
|
+
attrs[:name] = name.to_s
|
136
|
+
attrs[:campaign] = instance
|
137
|
+
attrs[:position] = steps.size
|
138
|
+
attrs[:params] = opts.reject { |k, _| STEP_ATTRS.key?(k) }.stringify_keys
|
139
|
+
|
140
|
+
step = Step.new(**attrs)
|
141
|
+
method_name = :"#{step.name.underscore}"
|
142
|
+
raise "Invalid step name: #{step.name}\n Step names must not conflict with method names on Heya::Campaigns::Base" if respond_to?(method_name)
|
143
|
+
|
144
|
+
define_singleton_method method_name do |user|
|
145
|
+
step.action.new(user: user, step: step).build
|
146
|
+
end
|
147
|
+
steps << step
|
148
|
+
|
149
|
+
step
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Heya
|
4
|
+
module Campaigns
|
5
|
+
module Queries
|
6
|
+
# {Queries::MembershipsToProcess} returns the CampaignMembership records
|
7
|
+
# which should be processed by the scheduler.
|
8
|
+
MembershipsToProcess = ->(user: nil) {
|
9
|
+
Heya::CampaignMembership.to_process(user: user)
|
10
|
+
}
|
11
|
+
|
12
|
+
# Given a campaign and a user, {Queries::MembershipsForUpdate}
|
13
|
+
# returns the user's campaign memberships which should be updated
|
14
|
+
# concurrently.
|
15
|
+
MembershipsForUpdate = ->(campaign, user) {
|
16
|
+
membership = CampaignMembership.where(user: user, campaign_gid: campaign.gid).first
|
17
|
+
if membership.concurrent?
|
18
|
+
CampaignMembership
|
19
|
+
.where(user: user, campaign_gid: campaign.gid)
|
20
|
+
else
|
21
|
+
CampaignMembership
|
22
|
+
.where(user: user, concurrent: false)
|
23
|
+
end
|
24
|
+
}
|
25
|
+
|
26
|
+
# Given a campaign, {Queries::OrphanedMemberships} returns the campaign
|
27
|
+
# memberships which are on steps have been removed from the campaign.
|
28
|
+
OrphanedMemberships = ->(campaign) {
|
29
|
+
CampaignMembership
|
30
|
+
.where(campaign_gid: campaign.gid)
|
31
|
+
.where.not(step_gid: campaign.steps.map(&:gid))
|
32
|
+
}
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Heya
|
4
|
+
module Campaigns
|
5
|
+
# {Campaigns::Scheduler} schedules campaign jobs to run for each campaign.
|
6
|
+
#
|
7
|
+
# For each step in each campaign:
|
8
|
+
# 1. Find users who haven't completed step, and are outside the `wait`
|
9
|
+
# window
|
10
|
+
# 2. Match segment
|
11
|
+
# 3. Create CampaignReceipt (excludes user in subsequent steps)
|
12
|
+
# 4. Process job
|
13
|
+
class Scheduler
|
14
|
+
def run(user: nil)
|
15
|
+
Heya.campaigns.each do |campaign|
|
16
|
+
if campaign.steps.any?
|
17
|
+
Queries::OrphanedMemberships.call(campaign).update_all(step_gid: campaign.steps.first.gid)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
Queries::MembershipsToProcess.call(user: user).find_each do |membership|
|
22
|
+
step = GlobalID::Locator.locate(membership.step_gid)
|
23
|
+
campaign = GlobalID::Locator.locate(membership.campaign_gid)
|
24
|
+
process(campaign, step, membership.user)
|
25
|
+
current_index = campaign.steps.index(step)
|
26
|
+
if (next_step = campaign.steps[current_index + 1])
|
27
|
+
membership.update(step_gid: next_step.gid)
|
28
|
+
else
|
29
|
+
membership.destroy
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def process(campaign, step, user)
|
37
|
+
ActiveRecord::Base.transaction do
|
38
|
+
return if CampaignReceipt.where(user: user, step_gid: step.gid).exists?
|
39
|
+
|
40
|
+
if step.in_segment?(user)
|
41
|
+
now = Time.now.utc
|
42
|
+
Queries::MembershipsForUpdate.call(campaign, user).update_all(last_sent_at: now)
|
43
|
+
CampaignReceipt.create!(user: user, step_gid: step.gid, sent_at: now)
|
44
|
+
step.action.new(user: user, step: step).deliver_later
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ostruct"
|
4
|
+
|
5
|
+
module Heya
|
6
|
+
module Campaigns
|
7
|
+
class Step < OpenStruct
|
8
|
+
include GlobalID::Identification
|
9
|
+
|
10
|
+
def self.find(id)
|
11
|
+
campaign_name, _step_name = id.to_s.split("/")
|
12
|
+
campaign_name.constantize.steps.find { |s| s.id == id }
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(id:, name:, campaign:, position:, action:, wait:, segment:, queue:, params: {})
|
16
|
+
super
|
17
|
+
if action.respond_to?(:validate_step)
|
18
|
+
action.validate_step(self)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def gid
|
23
|
+
to_gid(app: "heya").to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
def in_segment?(user)
|
27
|
+
Heya.in_segments?(user, *campaign.__segments, segment)
|
28
|
+
end
|
29
|
+
|
30
|
+
def campaign_name
|
31
|
+
@campaign_name ||= campaign.name
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Heya
|
4
|
+
module Campaigns
|
5
|
+
class StepActionJob < ActiveJob::Base
|
6
|
+
queue_as { Heya.config.campaigns.queue }
|
7
|
+
|
8
|
+
rescue_from StandardError, with: :handle_exception_with_campaign_class
|
9
|
+
|
10
|
+
def perform(_campaign, user, step)
|
11
|
+
step.action.new(user: user, step: step).deliver_now
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
# From ActionMailer: "deserialize" the mailer class name by hand in case
|
17
|
+
# another argument (like a Global ID reference) raised
|
18
|
+
# DeserializationError.
|
19
|
+
def campaign_class
|
20
|
+
if (campaign = (arguments_serialized? && Array(@serialized_arguments).first) || Array(arguments).first)
|
21
|
+
campaign.constantize
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def handle_exception_with_campaign_class(exception)
|
26
|
+
if (klass = campaign_class)
|
27
|
+
klass.handle_exception(exception)
|
28
|
+
else
|
29
|
+
raise exception
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/heya/config.rb
ADDED