heya 0.0.1 → 0.1.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +85 -0
  4. data/README.md +418 -0
  5. data/Rakefile +14 -14
  6. data/app/mailers/heya/application_mailer.rb +0 -2
  7. data/app/mailers/heya/campaign_mailer.rb +25 -0
  8. data/app/models/heya/campaign_membership.rb +9 -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 +53 -0
  14. data/lib/generators/heya/campaign/templates/application_campaign.rb.tt +3 -0
  15. data/lib/generators/heya/campaign/templates/campaign.rb.tt +7 -0
  16. data/lib/generators/heya/campaign/templates/message.html.erb.tt +1 -0
  17. data/lib/generators/heya/campaign/templates/message.md.erb.tt +1 -0
  18. data/lib/generators/heya/campaign/templates/message.text.erb.tt +1 -0
  19. data/lib/generators/heya/install/USAGE +10 -0
  20. data/lib/generators/heya/install/install_generator.rb +20 -0
  21. data/lib/generators/heya/install/templates/initializer.rb.tt +16 -0
  22. data/lib/generators/heya/install/templates/migration.rb.tt +28 -0
  23. data/lib/heya.rb +44 -1
  24. data/lib/heya/campaigns/action.rb +27 -0
  25. data/lib/heya/campaigns/actions/block.rb +24 -0
  26. data/lib/heya/campaigns/actions/email.rb +15 -0
  27. data/lib/heya/campaigns/base.rb +151 -0
  28. data/lib/heya/campaigns/queries.rb +118 -0
  29. data/lib/heya/campaigns/scheduler.rb +49 -0
  30. data/lib/heya/campaigns/step.rb +24 -0
  31. data/lib/heya/campaigns/step_action_job.rb +34 -0
  32. data/lib/heya/config.rb +17 -0
  33. data/lib/heya/engine.rb +14 -0
  34. data/lib/heya/version.rb +3 -1
  35. data/lib/tasks/heya_tasks.rake +8 -4
  36. metadata +58 -7
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Heya
4
+ module Campaigns
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)
91
+ }
92
+
93
+ # Given a campaign and a user, {Queries::CampaignMembershipsForUpdate}
94
+ # returns the user's campaign memberships which should be updated
95
+ # concurrently.
96
+ CampaignMembershipsForUpdate = ->(campaign, user) {
97
+ membership = CampaignMembership.where(user: user, campaign_gid: campaign.gid).first
98
+ if membership.concurrent?
99
+ CampaignMembership
100
+ .where(user: user, campaign_gid: campaign.gid)
101
+ else
102
+ CampaignMembership
103
+ .where(user: user, concurrent: false)
104
+ end
105
+ }
106
+
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) {
111
+ CampaignMembership
112
+ .where(campaign_gid: campaign.gid)
113
+ .where(user_type: campaign.user_class.base_class.name)
114
+ .where.not(user_id: campaign.users.select("id"))
115
+ }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,49 @@
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
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
22
+ end
23
+
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
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.process(campaign, step, user)
34
+ ActiveRecord::Base.transaction do
35
+ return if CampaignReceipt.where(user: user, step_gid: step.gid).exists?
36
+
37
+ if step.in_segment?(user)
38
+ now = Time.now.utc
39
+ Queries::CampaignMembershipsForUpdate.call(campaign, user).update_all(last_sent_at: now)
40
+ CampaignReceipt.create!(user: user, step_gid: step.gid, sent_at: now)
41
+ step.action.new(user: user, step: step).deliver_later
42
+ else
43
+ CampaignReceipt.create!(user: user, step_gid: step.gid)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
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 gid
16
+ to_gid(app: "heya").to_s
17
+ end
18
+
19
+ def in_segment?(user)
20
+ Heya.in_segments?(user, *campaign.__segments, segment)
21
+ end
22
+ end
23
+ end
24
+ 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
@@ -1,5 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Heya
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace Heya
6
+
7
+ initializer "heya.reload_campaigns" do |app|
8
+ app.reloader.to_run do
9
+ Heya.campaigns.clear
10
+ end
11
+ end
12
+
13
+ config.to_prepare do
14
+ Dir.glob(Rails.root + "app/campaigns/*.rb").each do |c|
15
+ require_dependency(c)
16
+ end
17
+ end
4
18
  end
5
19
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Heya
2
- VERSION = '0.0.1'
4
+ VERSION = "0.1.0"
3
5
  end
@@ -1,4 +1,8 @@
1
- # desc "Explaining what the task does"
2
- # task :heya do
3
- # # Task goes here
4
- # end
1
+ # frozen_string_literal: true
2
+
3
+ namespace :heya do
4
+ desc "Send campaign emails"
5
+ task scheduler: :environment do
6
+ Heya::Campaigns::Scheduler.new.run
7
+ end
8
+ end
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: heya
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Wood
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-06-12 00:00:00.000000000 Z
11
+ date: 2020-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: 5.2.3
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 6.1.0
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: 5.2.3
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 6.1.0
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: pg
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -38,13 +44,32 @@ dependencies:
38
44
  - - ">="
39
45
  - !ruby/object:Gem::Version
40
46
  version: '0'
41
- description: "Heya \U0001F44B"
47
+ - !ruby/object:Gem::Dependency
48
+ name: appraisal
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ description: Heya is a customer communication and automation framework for Ruby on
62
+ Rails. It's as robust as the hosted alternatives, without the integration and compliance
63
+ nightmare.
42
64
  email:
43
65
  - josh@honeybadger.io
44
66
  executables: []
45
67
  extensions: []
46
68
  extra_rdoc_files: []
47
69
  files:
70
+ - CHANGELOG.md
71
+ - LICENSE
72
+ - README.md
48
73
  - Rakefile
49
74
  - app/assets/config/heya_manifest.js
50
75
  - app/assets/javascripts/heya/application.js
@@ -53,15 +78,41 @@ files:
53
78
  - app/helpers/heya/application_helper.rb
54
79
  - app/jobs/heya/application_job.rb
55
80
  - app/mailers/heya/application_mailer.rb
81
+ - app/mailers/heya/campaign_mailer.rb
56
82
  - app/models/heya/application_record.rb
83
+ - app/models/heya/campaign_membership.rb
84
+ - app/models/heya/campaign_receipt.rb
57
85
  - app/views/layouts/heya/application.html.erb
86
+ - app/views/layouts/heya/campaign_mailer.html.erb
87
+ - app/views/layouts/heya/campaign_mailer.text.erb
58
88
  - config/routes.rb
89
+ - lib/generators/heya/campaign/USAGE
90
+ - lib/generators/heya/campaign/campaign_generator.rb
91
+ - lib/generators/heya/campaign/templates/application_campaign.rb.tt
92
+ - lib/generators/heya/campaign/templates/campaign.rb.tt
93
+ - lib/generators/heya/campaign/templates/message.html.erb.tt
94
+ - lib/generators/heya/campaign/templates/message.md.erb.tt
95
+ - lib/generators/heya/campaign/templates/message.text.erb.tt
96
+ - lib/generators/heya/install/USAGE
97
+ - lib/generators/heya/install/install_generator.rb
98
+ - lib/generators/heya/install/templates/initializer.rb.tt
99
+ - lib/generators/heya/install/templates/migration.rb.tt
59
100
  - lib/heya.rb
101
+ - lib/heya/campaigns/action.rb
102
+ - lib/heya/campaigns/actions/block.rb
103
+ - lib/heya/campaigns/actions/email.rb
104
+ - lib/heya/campaigns/base.rb
105
+ - lib/heya/campaigns/queries.rb
106
+ - lib/heya/campaigns/scheduler.rb
107
+ - lib/heya/campaigns/step.rb
108
+ - lib/heya/campaigns/step_action_job.rb
109
+ - lib/heya/config.rb
60
110
  - lib/heya/engine.rb
61
111
  - lib/heya/version.rb
62
112
  - lib/tasks/heya_tasks.rake
63
- homepage: https://www.honeybadger.io
64
- licenses: []
113
+ homepage: https://github.com/honeybadger-io/heya
114
+ licenses:
115
+ - Prosperity Public License
65
116
  metadata: {}
66
117
  post_install_message:
67
118
  rdoc_options: []