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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +661 -0
  4. data/README.md +573 -0
  5. data/Rakefile +14 -14
  6. data/app/mailers/heya/application_mailer.rb +0 -2
  7. data/app/mailers/heya/campaign_mailer.rb +52 -0
  8. data/app/models/heya/campaign_membership.rb +90 -0
  9. data/app/models/heya/campaign_receipt.rb +5 -0
  10. data/app/views/layouts/heya/campaign_mailer.html.erb +20 -0
  11. data/app/views/layouts/heya/campaign_mailer.text.erb +1 -0
  12. data/lib/generators/heya/campaign/USAGE +14 -0
  13. data/lib/generators/heya/campaign/campaign_generator.rb +69 -0
  14. data/lib/generators/heya/campaign/templates/campaign.rb.tt +4 -0
  15. data/lib/generators/heya/campaign/templates/message.html.erb.tt +1 -0
  16. data/lib/generators/heya/campaign/templates/message.md.erb.tt +1 -0
  17. data/lib/generators/heya/campaign/templates/message.text.erb.tt +1 -0
  18. data/lib/generators/heya/campaign/templates/preview.rb.tt +12 -0
  19. data/lib/generators/heya/install/USAGE +10 -0
  20. data/lib/generators/heya/install/install_generator.rb +24 -0
  21. data/lib/generators/heya/install/templates/application_campaign.rb.tt +3 -0
  22. data/lib/generators/heya/install/templates/initializer.rb.tt +16 -0
  23. data/lib/generators/heya/install/templates/migration.rb.tt +29 -0
  24. data/lib/heya.rb +45 -1
  25. data/lib/heya/active_record_extension.rb +37 -0
  26. data/lib/heya/campaigns/action.rb +27 -0
  27. data/lib/heya/campaigns/actions/block.rb +24 -0
  28. data/lib/heya/campaigns/actions/email.rb +24 -0
  29. data/lib/heya/campaigns/base.rb +154 -0
  30. data/lib/heya/campaigns/queries.rb +35 -0
  31. data/lib/heya/campaigns/scheduler.rb +50 -0
  32. data/lib/heya/campaigns/step.rb +35 -0
  33. data/lib/heya/campaigns/step_action_job.rb +34 -0
  34. data/lib/heya/config.rb +17 -0
  35. data/lib/heya/engine.rb +14 -0
  36. data/lib/heya/version.rb +3 -1
  37. data/lib/tasks/heya_tasks.rake +8 -4
  38. 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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module Heya
6
+ class Config < OpenStruct
7
+ def initialize
8
+ super(
9
+ user_type: "User",
10
+ campaigns: OpenStruct.new(
11
+ priority: [],
12
+ default_options: {}
13
+ ),
14
+ )
15
+ end
16
+ end
17
+ end