heya 0.3.0 → 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.
data/README.md CHANGED
@@ -58,10 +58,10 @@ end
58
58
  ```ruby
59
59
  OnboardingCampaign.add(user)
60
60
  ```
61
-
61
+
62
62
  Add the following to your `User` model to send them the campaign
63
63
  when they first signup:
64
-
64
+
65
65
  ```ruby
66
66
  after_create_commit do
67
67
  OnboardingCampaign.add(self)
@@ -353,7 +353,7 @@ To remove a user from a campaign:
353
353
  OnboardingCampaign.remove(user)
354
354
  ```
355
355
 
356
- Adding users to campaigns from Rails opens up some interesting automation possibilities--for instance, you can start or stop campaigns from `ActiveRecord` callbacks, or in response to other events that you're already tracking in your application. [See here for a list of ideas](#).
356
+ Adding users to campaigns from Rails opens up some interesting automation possibilities--for instance, you can start or stop campaigns from `ActiveRecord` callbacks, or in response to other events that you're already tracking in your application. [See here for a list of ideas](#automation-ideas).
357
357
 
358
358
  Because Heya stacks campaigns by default (meaning it will never send more than one at a time), you can also queue up several campaigns for a user, and they'll receive them in order:
359
359
 
@@ -543,6 +543,13 @@ Yep. Use the `restart` option to resend a campaign to a user (if they are alread
543
543
  Yep. By default, Heya sends campaigns ain order of `priority`. Use the `concurrent` option to send campaigns concurrently.
544
544
  </details>
545
545
 
546
+ ## Upgrading Heya
547
+ Heya adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and
548
+ should be considered **unstable** until version 1.0.0. Always check
549
+ [CHANGELOG.md](./CHANGELOG.md) prior to upgrading (breaking changes will always
550
+ be called out there). Upgrade instructions for breaking changes are in
551
+ [UPGRADING.md](./UPGRADING.md).
552
+
546
553
  ## Roadmap
547
554
  See [here](https://github.com/honeybadger-io/heya/projects/1) for things we're
548
555
  considering adding to Heya.
@@ -550,10 +557,17 @@ considering adding to Heya.
550
557
  ## Contributing
551
558
  1. Fork it.
552
559
  2. Create a topic branch `git checkout -b my_branch`
553
- 3. Make your changes and add an entry to the [CHANGELOG](CHANGELOG.md).
560
+ 3. Make your changes and add an entry to [CHANGELOG.md](CHANGELOG.md).
554
561
  4. Commit your changes `git commit -am "Boom"`
555
562
  5. Push to your branch `git push origin my_branch`
556
563
  6. Send a [pull request](https://github.com/honeybadger-io/heya/pulls)
557
564
 
565
+ ## Releasing
566
+ 1. `gem install gem-release`
567
+ 2. `gem bump -v [version] -t -r`
568
+ 3. Update unreleased heading in [CHANGELOG.md](./CHANGELOG.md) (TODO: automate
569
+ this in gem-release command)
570
+ 4. `git push origin master --tags`
571
+
558
572
  ## License
559
- This package is free to use for noncommercial purposes and for commercial purposes during a trial period under the terms of the [Prosperity Public License](LICENSE).
573
+ Heya is licensed under the [AGPL](./LICENSE).
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Heya
2
4
  class CampaignMembership < ApplicationRecord
3
5
  belongs_to :user, polymorphic: true
@@ -5,5 +7,84 @@ module Heya
5
7
  before_create do
6
8
  self.last_sent_at = Time.now
7
9
  end
10
+
11
+ scope :with_steps, -> {
12
+ joins(
13
+ %(INNER JOIN "heya_steps" ON "heya_steps".gid = "heya_campaign_memberships".step_gid)
14
+ )
15
+ }
16
+
17
+ scope :active, -> {
18
+ priority_gids = Heya.config.campaigns.priority.map { |c| (c.is_a?(String) ? c.constantize : c).gid }
19
+ where(<<~SQL, priority_gids: priority_gids)
20
+ "heya_campaign_memberships".concurrent = TRUE
21
+ OR "heya_campaign_memberships"."campaign_gid" IN (
22
+ SELECT
23
+ "active_membership"."campaign_gid"
24
+ FROM
25
+ "heya_campaign_memberships" as "active_membership"
26
+ WHERE
27
+ "active_membership"."concurrent" = FALSE
28
+ AND
29
+ (
30
+ "active_membership".user_type = "heya_campaign_memberships".user_type
31
+ AND
32
+ "active_membership".user_id = "heya_campaign_memberships".user_id
33
+ )
34
+ ORDER BY
35
+ array_position(ARRAY[:priority_gids], "active_membership".campaign_gid::text) ASC,
36
+ "active_membership".created_at ASC
37
+ LIMIT 1
38
+ )
39
+ SQL
40
+ }
41
+
42
+ scope :upcoming, -> {
43
+ with_steps
44
+ .active
45
+ .order(
46
+ Arel.sql(
47
+ %("heya_campaign_memberships".last_sent_at + make_interval(secs := "heya_steps".wait) DESC)
48
+ )
49
+ )
50
+ }
51
+
52
+ scope :to_process, ->(now: Time.now, user: nil) {
53
+ upcoming
54
+ .where(<<~SQL, now: now.utc, user_type: user&.class&.base_class&.name, user_id: user&.id)
55
+ ("heya_campaign_memberships".last_sent_at <= (TIMESTAMP :now - make_interval(secs := "heya_steps".wait)))
56
+ AND (
57
+ (:user_type IS NULL OR :user_id IS NULL)
58
+ OR (
59
+ "heya_campaign_memberships".user_type = :user_type
60
+ AND
61
+ "heya_campaign_memberships".user_id = :user_id
62
+ )
63
+ )
64
+ SQL
65
+ }
66
+
67
+ def self.migrate_next_step!
68
+ find_each do |membership|
69
+ campaign = GlobalID::Locator.locate(membership.campaign_gid)
70
+ receipt = campaign && CampaignReceipt.where(user: membership.user, step_gid: campaign.steps.map(&:gid)).order("created_at desc").first
71
+
72
+ next_step = if receipt
73
+ last_step = GlobalID::Locator.locate(receipt.step_gid)
74
+ current_index = campaign.steps.index(last_step)
75
+ campaign.steps[current_index + 1]
76
+ else
77
+ campaign&.steps&.first
78
+ end
79
+
80
+ if next_step
81
+ membership.update(step_gid: next_step.gid)
82
+ else
83
+ membership.destroy
84
+ end
85
+ end
86
+
87
+ CampaignReceipt.where(sent_at: nil).destroy_all
88
+ end
8
89
  end
9
90
  end
@@ -4,6 +4,7 @@ class CreateHeyaTables < ActiveRecord::Migration[<%= ActiveRecord::VERSION::MAJO
4
4
  t.references :user, null: false, polymorphic: true, index: false
5
5
 
6
6
  t.string :campaign_gid, null: false
7
+ t.string :step_gid, null: false
7
8
  t.boolean :concurrent, null: false, default: false
8
9
 
9
10
  t.datetime :last_sent_at, null: false
data/lib/heya.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "heya/version"
4
+ require "heya/active_record_extension"
4
5
  require "heya/engine"
5
6
  require "heya/config"
6
- require "heya/license"
7
7
  require "heya/campaigns/action"
8
8
  require "heya/campaigns/actions/email"
9
9
  require "heya/campaigns/actions/block"
@@ -46,45 +46,4 @@ module Heya
46
46
  return user.send(segment) if segment.is_a?(Symbol)
47
47
  segment.call(user)
48
48
  end
49
-
50
- def verify_license!
51
- unless File.file?(config.license_file)
52
- puts(<<-NOTICE.strip_heredoc)
53
- This copy of Heya is licensed for non-commercial non-profit, or 30-day trial usage only.
54
- For a commercial use license, please visit https://www.heya.email
55
- NOTICE
56
- return
57
- end
58
-
59
- begin
60
- license = License.import(File.read(config.license_file))
61
- rescue License::ImportError
62
- warn(<<-NOTICE.strip_heredoc)
63
- Your Heya license is invalid.
64
- If you need support, please visit https://www.heya.email
65
- NOTICE
66
- return
67
- end
68
-
69
- if license.expired?
70
- warn(<<-NOTICE.strip_heredoc)
71
- Your Heya license has expired.
72
- To update your license, please visit https://www.heya.email
73
- NOTICE
74
- return
75
- end
76
-
77
- if (max_user_count = license.restrictions[:user_count]&.to_i)
78
- user_count = config.user_type.constantize.count
79
- if user_count > max_user_count
80
- warn(<<-NOTICE.strip_heredoc)
81
- Your app exceeds the number of users for your Heya license.
82
- To upgrade your license, please visit https://www.heya.email
83
- NOTICE
84
- end
85
- return # rubocop:disable Style/RedundantReturn
86
- end
87
-
88
- # Valid license
89
- end
90
49
  end
@@ -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
@@ -41,12 +41,15 @@ module Heya
41
41
  .delete_all
42
42
  end
43
43
 
44
- membership.create! do |m|
45
- m.concurrent = concurrent
46
- end
44
+ if (step = steps.first)
45
+ membership.create! do |m|
46
+ m.concurrent = concurrent
47
+ m.step_gid = step.gid
48
+ end
47
49
 
48
- if send_now && (step = steps.first) && step.wait <= 0
49
- Scheduler.process(self, step, user)
50
+ if send_now && step.wait == 0
51
+ Scheduler.new.run(user: user)
52
+ end
50
53
  end
51
54
 
52
55
  true
@@ -57,18 +60,6 @@ module Heya
57
60
  true
58
61
  end
59
62
 
60
- def users
61
- base_class = user_class.base_class
62
- user_class
63
- .joins(
64
- sanitize_sql_array([
65
- "inner join heya_campaign_memberships on heya_campaign_memberships.user_type = ? and heya_campaign_memberships.user_id = #{base_class.table_name}.id and heya_campaign_memberships.campaign_gid = ?",
66
- base_class.name,
67
- gid
68
- ])
69
- ).all
70
- end
71
-
72
63
  def user_class
73
64
  @user_class ||= self.class.user_type.constantize
74
65
  end
@@ -105,7 +96,7 @@ module Heya
105
96
  instance
106
97
  end
107
98
 
108
- delegate :steps, :add, :remove, :users, :gid, :user_class, :handle_exception, to: :instance
99
+ delegate :steps, :add, :remove, :gid, :user_class, :handle_exception, to: :instance
109
100
 
110
101
  def default(**params)
111
102
  self.__defaults = __defaults.merge(params).freeze
@@ -3,97 +3,16 @@
3
3
  module Heya
4
4
  module Campaigns
5
5
  module Queries
6
- NEXT_STEP_SUBQUERY = <<~SQL
7
- (WITH steps AS (SELECT * FROM (VALUES :steps_values) AS m (step_gid,campaign_gid,position))
8
- SELECT m.step_gid FROM steps AS m
9
- WHERE m.campaign_gid = :campaign_gid
10
- AND m.position > coalesce((SELECT m.position FROM heya_campaign_receipts AS r
11
- INNER JOIN steps AS m ON m.step_gid = r.step_gid
12
- AND m.campaign_gid = :campaign_gid
13
- WHERE r.user_type = heya_campaign_memberships.user_type
14
- AND r.user_id = heya_campaign_memberships.user_id
15
- ORDER BY m.position DESC
16
- LIMIT 1), -1)
17
- ORDER BY m.position ASC
18
- LIMIT 1
19
- ) = :step_gid
20
- SQL
21
-
22
- ACTIVE_CAMPAIGN_SUBQUERY = <<~SQL
23
- (WITH heya_campaigns AS (SELECT * FROM (VALUES :campaigns_values) AS campaigns (campaign_gid,position))
24
- SELECT memberships.campaign_gid FROM heya_campaign_memberships AS memberships
25
- INNER JOIN heya_campaigns AS campaigns
26
- ON campaigns.campaign_gid = memberships.campaign_gid
27
- WHERE memberships.user_type = heya_campaign_memberships.user_type
28
- AND memberships.user_id = heya_campaign_memberships.user_id
29
- AND memberships.concurrent = FALSE
30
- ORDER BY campaigns.position DESC, memberships.created_at ASC
31
- LIMIT 1
32
- ) = :campaign_gid
33
- SQL
34
-
35
- # Given a campaign and a step, {Queries::UsersForStep} returns the
36
- # users who should complete the step.
37
- UsersForStep = ->(campaign, step) {
38
- wait_threshold = Time.now.utc - step.wait
39
-
40
- # Safeguard to make sure we never complete the same step twice.
41
- receipt_query = CampaignReceipt
42
- .select("heya_campaign_receipts.user_id")
43
- .where(user_type: campaign.user_class.name)
44
- .where("heya_campaign_receipts.step_gid = ?", step.gid)
45
-
46
- # https://www.postgresql.org/docs/9.4/queries-values.html
47
- steps_values = campaign.steps.map { |m|
48
- ActiveRecord::Base.sanitize_sql_array(
49
- ["(?, ?, ?)", m.gid, campaign.gid, m.position]
50
- )
51
- }.join(", ")
52
-
53
- priority = Heya.config.campaigns.priority.reverse.map { |c| c.is_a?(String) ? c : c.name }
54
- campaigns_values = Heya.campaigns.map { |c|
55
- ActiveRecord::Base.sanitize_sql_array(
56
- ["(?, ?)", c.gid, priority.index(c.name) || -1]
57
- )
58
- }.join(", ")
59
-
60
- users = campaign.users
61
- users
62
- .where.not(id: receipt_query)
63
- .where(NEXT_STEP_SUBQUERY.gsub(":steps_values", steps_values), {
64
- campaign_gid: campaign.gid,
65
- step_gid: step.gid
66
- })
67
- .merge(
68
- users
69
- .where("heya_campaign_memberships.concurrent = ?", true)
70
- .or(
71
- users.where(ACTIVE_CAMPAIGN_SUBQUERY.gsub(":campaigns_values", campaigns_values), {
72
- campaign_gid: campaign.gid
73
- })
74
- )
75
- )
76
- .where(
77
- "heya_campaign_memberships.last_sent_at <= ?", wait_threshold
78
- )
79
- }
80
-
81
- # Given a campaign and a step, {Queries::UsersCompletedStep}
82
- # returns the users who have completed the step.
83
- UsersCompletedStep = ->(campaign, step) {
84
- receipt_query = CampaignReceipt
85
- .select("heya_campaign_receipts.user_id")
86
- .where(user_type: campaign.user_class.name)
87
- .where("heya_campaign_receipts.step_gid = ?", step.gid)
88
-
89
- campaign.users
90
- .where(id: receipt_query)
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)
91
10
  }
92
11
 
93
- # Given a campaign and a user, {Queries::CampaignMembershipsForUpdate}
12
+ # Given a campaign and a user, {Queries::MembershipsForUpdate}
94
13
  # returns the user's campaign memberships which should be updated
95
14
  # concurrently.
96
- CampaignMembershipsForUpdate = ->(campaign, user) {
15
+ MembershipsForUpdate = ->(campaign, user) {
97
16
  membership = CampaignMembership.where(user: user, campaign_gid: campaign.gid).first
98
17
  if membership.concurrent?
99
18
  CampaignMembership
@@ -104,14 +23,12 @@ module Heya
104
23
  end
105
24
  }
106
25
 
107
- # Given a campaign, {Queries::OrphanedCampaignMemberships} returns the
108
- # campaign memberships which belong to users who no longer exist in the
109
- # database.
110
- OrphanedCampaignMemberships = ->(campaign) {
26
+ # Given a campaign, {Queries::OrphanedMemberships} returns the campaign
27
+ # memberships which are on steps have been removed from the campaign.
28
+ OrphanedMemberships = ->(campaign) {
111
29
  CampaignMembership
112
30
  .where(campaign_gid: campaign.gid)
113
- .where(user_type: campaign.user_class.base_class.name)
114
- .where.not(user_id: campaign.users.select("id"))
31
+ .where.not(step_gid: campaign.steps.map(&:gid))
115
32
  }
116
33
  end
117
34
  end
@@ -11,36 +11,37 @@ module Heya
11
11
  # 3. Create CampaignReceipt (excludes user in subsequent steps)
12
12
  # 4. Process job
13
13
  class Scheduler
14
- def run
14
+ def run(user: nil)
15
15
  Heya.campaigns.each do |campaign|
16
- Queries::OrphanedCampaignMemberships.call(campaign).delete_all
17
-
18
- campaign.steps.each do |step|
19
- Queries::UsersForStep.call(campaign, step).find_each do |user|
20
- self.class.process(campaign, step, user)
21
- end
16
+ if campaign.steps.any?
17
+ Queries::OrphanedMemberships.call(campaign).update_all(step_gid: campaign.steps.first.gid)
22
18
  end
19
+ end
23
20
 
24
- if (last_step = campaign.steps.last)
25
- CampaignMembership.where(
26
- user: Queries::UsersCompletedStep.call(campaign, last_step),
27
- campaign_gid: campaign.gid
28
- ).delete_all
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
29
30
  end
30
31
  end
31
32
  end
32
33
 
33
- def self.process(campaign, step, user)
34
+ private
35
+
36
+ def process(campaign, step, user)
34
37
  ActiveRecord::Base.transaction do
35
38
  return if CampaignReceipt.where(user: user, step_gid: step.gid).exists?
36
39
 
37
40
  if step.in_segment?(user)
38
41
  now = Time.now.utc
39
- Queries::CampaignMembershipsForUpdate.call(campaign, user).update_all(last_sent_at: now)
42
+ Queries::MembershipsForUpdate.call(campaign, user).update_all(last_sent_at: now)
40
43
  CampaignReceipt.create!(user: user, step_gid: step.gid, sent_at: now)
41
44
  step.action.new(user: user, step: step).deliver_later
42
- else
43
- CampaignReceipt.create!(user: user, step_gid: step.gid)
44
45
  end
45
46
  end
46
47
  end