heya 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []