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
data/Rakefile CHANGED
@@ -1,31 +1,31 @@
1
1
  begin
2
- require 'bundler/setup'
2
+ require "bundler/setup"
3
3
  rescue LoadError
4
- puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
4
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
5
5
  end
6
6
 
7
- require 'rdoc/task'
7
+ require "rdoc/task"
8
8
 
9
9
  RDoc::Task.new(:rdoc) do |rdoc|
10
- rdoc.rdoc_dir = 'rdoc'
11
- rdoc.title = 'Heya'
12
- rdoc.options << '--line-numbers'
13
- rdoc.rdoc_files.include('README.md')
14
- rdoc.rdoc_files.include('lib/**/*.rb')
10
+ rdoc.rdoc_dir = "rdoc"
11
+ rdoc.title = "Heya"
12
+ rdoc.options << "--line-numbers"
13
+ rdoc.rdoc_files.include("README.md")
14
+ rdoc.rdoc_files.include("lib/**/*.rb")
15
15
  end
16
16
 
17
17
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
18
- load 'rails/tasks/engine.rake'
18
+ load "rails/tasks/engine.rake"
19
19
 
20
- load 'rails/tasks/statistics.rake'
20
+ load "rails/tasks/statistics.rake"
21
21
 
22
- require 'bundler/gem_tasks'
22
+ require "bundler/gem_tasks"
23
23
 
24
- require 'rake/testtask'
24
+ require "rake/testtask"
25
25
 
26
26
  Rake::TestTask.new(:test) do |t|
27
- t.libs << 'test'
28
- t.pattern = 'test/**/*_test.rb'
27
+ t.libs << "test"
28
+ t.pattern = "test/**/*_test.rb"
29
29
  t.verbose = false
30
30
  end
31
31
 
@@ -1,6 +1,4 @@
1
1
  module Heya
2
2
  class ApplicationMailer < ActionMailer::Base
3
- default from: 'from@example.com'
4
- layout 'mailer'
5
3
  end
6
4
  end
@@ -0,0 +1,25 @@
1
+ module Heya
2
+ class CampaignMailer < ApplicationMailer
3
+ layout "heya/campaign_mailer"
4
+
5
+ def build
6
+ user = params.fetch(:user)
7
+ step = params.fetch(:step)
8
+ campaign = step.campaign
9
+ from = step.params.fetch("from")
10
+ reply_to = step.params.fetch("reply_to", nil)
11
+ subject = step.params.fetch("subject")
12
+
13
+ instance_variable_set(:"@#{user.model_name.element}", user)
14
+
15
+ mail(
16
+ from: from,
17
+ reply_to: reply_to,
18
+ to: user.email,
19
+ subject: subject,
20
+ template_path: "heya/campaign_mailer/#{campaign.name.underscore}",
21
+ template_name: step.name.underscore
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ module Heya
2
+ class CampaignMembership < ApplicationRecord
3
+ belongs_to :user, polymorphic: true
4
+
5
+ before_create do
6
+ self.last_sent_at = Time.now
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module Heya
2
+ class CampaignReceipt < ApplicationRecord
3
+ belongs_to :user, polymorphic: true
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
5
+ <style>
6
+ .message-content { max-width: 600px; }
7
+ .message-content div { padding-bottom: 10px; }
8
+ .message-content img { max-width: 100%; height: auto; }
9
+ p {
10
+ margin-bottom: 1em;
11
+ }
12
+ </style>
13
+ </head>
14
+
15
+ <body>
16
+ <div class="message-content">
17
+ <%= yield %>
18
+ </div>
19
+ </body>
20
+ </html>
@@ -0,0 +1,14 @@
1
+ Description:
2
+ Generate a new campaign
3
+
4
+ Example:
5
+ rails generate heya:campaign Onboarding first second third
6
+
7
+ This will create:
8
+ app/campaigns/onboarding_campaign.rb
9
+ app/views/heya/campaign_mailer/onboarding_campaign/first.text.erb
10
+ app/views/heya/campaign_mailer/onboarding_campaign/first.html.erb
11
+ app/views/heya/campaign_mailer/onboarding_campaign/second.text.erb
12
+ app/views/heya/campaign_mailer/onboarding_campaign/second.html.erb
13
+ app/views/heya/campaign_mailer/onboarding_campaign/third.text.erb
14
+ app/views/heya/campaign_mailer/onboarding_campaign/third.html.erb
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Heya::CampaignGenerator < Rails::Generators::NamedBase
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ argument :steps, type: :array, default: []
7
+
8
+ def copy_campaign_template
9
+ template "application_campaign.rb", "app/campaigns/application_campaign.rb"
10
+ template "campaign.rb", "app/campaigns/#{file_name.underscore}_campaign.rb"
11
+ end
12
+
13
+ def copy_view_templates
14
+ selection =
15
+ if defined?(Maildown)
16
+ puts <<~MSG
17
+ What type of views would you like to generate?
18
+ 1. Multipart (text/html)
19
+ 2. Maildown (markdown)
20
+ MSG
21
+
22
+ ask(">")
23
+ else
24
+ "1"
25
+ end
26
+
27
+ template_method =
28
+ case selection
29
+ when "1"
30
+ method(:action_mailer_template)
31
+ when "2"
32
+ method(:maildown_template)
33
+ else
34
+ abort "Error: must be a number [1-2]"
35
+ end
36
+
37
+ steps.each do |step|
38
+ @step = step
39
+ template_method.call(step)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def action_mailer_template(step)
46
+ template "message.text.erb", "app/views/heya/campaign_mailer/#{file_name.underscore}_campaign/#{step.underscore.to_sym}.text.erb"
47
+ template "message.html.erb", "app/views/heya/campaign_mailer/#{file_name.underscore}_campaign/#{step.underscore.to_sym}.html.erb"
48
+ end
49
+
50
+ def maildown_template(step)
51
+ template "message.md.erb", "app/views/heya/campaign_mailer/#{file_name.underscore}_campaign/#{step.underscore.to_sym}.md.erb"
52
+ end
53
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationCampaign < Heya::Campaigns::Base
2
+ default from: "from@example.com"
3
+ end
@@ -0,0 +1,7 @@
1
+ class <%= file_name.camelcase %>Campaign < ApplicationCampaign
2
+ <% steps.each do |step| %>
3
+ step :<%= step.underscore.to_sym %>,
4
+ subject: "<%= step.humanize %> Message"
5
+
6
+ <% end -%>
7
+ end
@@ -0,0 +1 @@
1
+ This is the <%= @step.downcase %> messsage.
@@ -0,0 +1 @@
1
+ This is the <%= @step.downcase %> messsage.
@@ -0,0 +1 @@
1
+ This is the <%= @step.downcase %> messsage.
@@ -0,0 +1,10 @@
1
+ Description:
2
+ Install Heya initializer and migrations
3
+
4
+ Example:
5
+ rails generate heya:install
6
+
7
+ This will create:
8
+ db/migrate/*_create_heya_campaign_receipts.heya.rb
9
+ db/migrate/*_create_heya_campaign_memberships.heya.rb
10
+ config/initializers/heya.rb
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Heya::InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def copy_migrations
9
+ migration_template "migration.rb", "db/migrate/create_heya_tables.rb"
10
+ end
11
+
12
+ def copy_initializer_file
13
+ copy_file "initializer.rb", "config/initializers/heya.rb"
14
+ end
15
+
16
+ def self.next_migration_number(dirname)
17
+ next_migration_number = current_migration_number(dirname) + 1
18
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ Heya.configure do |config|
2
+ # The name of the model you want to use with Heya.
3
+ config.user_type = "User"
4
+
5
+ # The default options to use when processing campaign steps.
6
+ # config.campaigns.default_options = {from: "user@example.com"}
7
+
8
+ # Campaign priority. When a user is added to multiple campaigns, they are
9
+ # sent in this order. Campaigns are sent in the order that the users were
10
+ # added if no priority is configured.
11
+ # config.campaings.priority = [
12
+ # "FirstCampaign",
13
+ # "SecondCampaign",
14
+ # "ThirdCampaign"
15
+ # ]
16
+ end
@@ -0,0 +1,28 @@
1
+ class CreateHeyaTables < ActiveRecord::Migration[<%= ActiveRecord::VERSION::MAJOR %>.<%= ActiveRecord::VERSION::MINOR %>]
2
+ def change
3
+ create_table :heya_campaign_memberships do |t|
4
+ t.references :user, null: false, polymorphic: true, index: false
5
+
6
+ t.string :campaign_gid, null: false
7
+ t.boolean :concurrent, null: false, default: false
8
+
9
+ t.datetime :last_sent_at, null: false
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :heya_campaign_memberships, [:user_type, :user_id, :campaign_gid], unique: true, name: :user_campaign_idx
15
+
16
+ create_table :heya_campaign_receipts do |t|
17
+ t.references :user, null: false, polymorphic: true, index: false
18
+
19
+ t.string :step_gid, null: false
20
+
21
+ t.datetime :sent_at
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :heya_campaign_receipts, [:user_type, :user_id, :step_gid], unique: true, name: :user_step_idx
27
+ end
28
+ end
@@ -1,5 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "heya/version"
1
4
  require "heya/engine"
5
+ require "heya/config"
6
+ require "heya/campaigns/action"
7
+ require "heya/campaigns/actions/email"
8
+ require "heya/campaigns/actions/block"
9
+ require "heya/campaigns/base"
10
+ require "heya/campaigns/queries"
11
+ require "heya/campaigns/scheduler"
12
+ require "heya/campaigns/step"
13
+ require "heya/campaigns/step_action_job"
2
14
 
3
15
  module Heya
4
- # Your code goes here...
16
+ extend self
17
+
18
+ attr_accessor :campaigns
19
+ self.campaigns = []
20
+
21
+ def register_campaign(klass)
22
+ campaigns.push(klass) unless campaigns.include?(klass)
23
+ end
24
+
25
+ def unregister_campaign(klass)
26
+ campaigns.delete(klass)
27
+ end
28
+
29
+ def configure
30
+ yield(config) if block_given?
31
+ config
32
+ end
33
+
34
+ def config
35
+ @config ||= Config.new
36
+ end
37
+
38
+ def in_segments?(user, *segments)
39
+ return false if segments.any? { |s| !in_segment?(user, s) }
40
+ true
41
+ end
42
+
43
+ def in_segment?(user, segment)
44
+ return true if segment.nil?
45
+ return user.send(segment) if segment.is_a?(Symbol)
46
+ segment.call(user)
47
+ end
5
48
  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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Heya
4
+ module Campaigns
5
+ module Actions
6
+ class Email < Action
7
+ def build
8
+ CampaignMailer
9
+ .with(user: user, step: step)
10
+ .build
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/descendants_tracker"
4
+
5
+ module Heya
6
+ module Campaigns
7
+ # {Campaigns::Base} provides a Ruby DSL for building campaign sequences.
8
+ # Multiple actions are supported; the default is email.
9
+ class Base
10
+ extend ActiveSupport::DescendantsTracker
11
+
12
+ include Singleton
13
+ include GlobalID::Identification
14
+
15
+ def initialize
16
+ self.steps = []
17
+ end
18
+
19
+ delegate :name, :__segments, to: :class
20
+ alias id name
21
+
22
+ # Returns String GlobalID.
23
+ def gid
24
+ to_gid(app: "heya").to_s
25
+ end
26
+
27
+ def add(user, restart: false, concurrent: false, send_now: true)
28
+ return false unless Heya.in_segments?(user, *__segments)
29
+
30
+ restart && CampaignReceipt
31
+ .where(user: user, step_gid: steps.map(&:gid))
32
+ .delete_all
33
+
34
+ CampaignMembership.where(user: user, campaign_gid: gid, concurrent: concurrent).first_or_create!
35
+
36
+ if send_now && (step = steps.first) && step.wait <= 0
37
+ Scheduler.process(self, step, user)
38
+ end
39
+
40
+ true
41
+ end
42
+
43
+ def remove(user)
44
+ CampaignMembership.where(user: user, campaign_gid: gid).delete_all
45
+ true
46
+ end
47
+
48
+ def users
49
+ base_class = user_class.base_class
50
+ user_class
51
+ .joins(
52
+ sanitize_sql_array([
53
+ "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 = ?",
54
+ base_class.name,
55
+ gid
56
+ ])
57
+ ).all
58
+ end
59
+
60
+ def user_class
61
+ @user_class ||= self.class.user_type.constantize
62
+ end
63
+
64
+ attr_accessor :steps
65
+
66
+ private
67
+
68
+ delegate :sanitize_sql_array, to: ActiveRecord::Base
69
+
70
+ class_attribute :__defaults, default: {}.freeze
71
+ class_attribute :__segments, default: [].freeze
72
+ class_attribute :__user_type, default: nil
73
+
74
+ STEP_ATTRS = {
75
+ action: Actions::Email,
76
+ wait: 2.days,
77
+ segment: nil,
78
+ queue: "heya"
79
+ }.freeze
80
+
81
+ class << self
82
+ def inherited(campaign)
83
+ Heya.register_campaign(campaign)
84
+ Heya.unregister_campaign(campaign.superclass)
85
+ super
86
+ end
87
+
88
+ def find(_id)
89
+ instance
90
+ end
91
+
92
+ def handle_exception(exception)
93
+ raise exception
94
+ end
95
+
96
+ delegate :steps, :add, :remove, :users, :gid, :user_class, to: :instance
97
+
98
+ def default(**params)
99
+ self.__defaults = __defaults.merge(params).freeze
100
+ end
101
+
102
+ def user_type(value = nil)
103
+ if value.present?
104
+ self.__user_type = value.is_a?(String) ? value.to_s : value.name
105
+ end
106
+
107
+ __user_type || Heya.config.user_type
108
+ end
109
+
110
+ def segment(arg = nil, &block)
111
+ if block_given?
112
+ self.__segments = ([block] | __segments).freeze
113
+ elsif arg
114
+ self.__segments = ([arg] | __segments).freeze
115
+ end
116
+ end
117
+
118
+ def step(name, **opts, &block)
119
+ if block_given?
120
+ opts[:block] ||= block
121
+ opts[:action] ||= Actions::Block
122
+ end
123
+
124
+ opts =
125
+ STEP_ATTRS
126
+ .merge(Heya.config.campaigns.default_options)
127
+ .merge(__defaults)
128
+ .merge(opts)
129
+
130
+ attrs = opts.select { |k, _| STEP_ATTRS.key?(k) }
131
+ attrs[:params] = opts.reject { |k, _| STEP_ATTRS.key?(k) }.stringify_keys
132
+ attrs[:id] = "#{self.name}/#{name}"
133
+ attrs[:name] = name.to_s
134
+ attrs[:position] = steps.size
135
+ attrs[:campaign] = instance
136
+
137
+ step = Step.new(attrs)
138
+ method_name = :"#{step.name.underscore}"
139
+ raise "Invalid step name: #{step.name}\n Step names must not conflict with method names on Heya::Campaigns::Base" if respond_to?(method_name)
140
+
141
+ define_singleton_method method_name do |user|
142
+ step.action.new(user: user, step: step).build
143
+ end
144
+ steps << step
145
+
146
+ step
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end