caffeinate 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cac59a69d742f7948455f3f3b5787e33841664267eab72a2c6bc296735da5d1e
4
- data.tar.gz: 826ca15444ba2242f99d8376d0f5a4aae9612846ee61ef53d1a4a53a879d0b54
3
+ metadata.gz: a3bf57f512b2d4760d99422a5696250ae68e29498fb013a6b7864441c028277f
4
+ data.tar.gz: 20402615978ccb8fe58a8dba9a229a10e972ea42f03bfad68894744263d2302d
5
5
  SHA512:
6
- metadata.gz: 2d1cc9c295d50c77c5db6c5f22d585372b513396d54a9a37b05f40af405f8f4e32b1e0d42045a9e5b0e0c40fe592466a4d835a67dd71ad62bab55d24b7586d15
7
- data.tar.gz: 0ca6801b63b8e50f73654dbc6a7f5e9c5063fd825699bd6698e4af4f6846ef23358ed0955cc2d0ed8d19e705124d0f25094c0980331dd90dd31121ab06c07f3e
6
+ metadata.gz: 72698ac79d53fe73f71203a31d789916112dac478924e88369de9622195bb9f27d25ced846b44d5896b51d787295591e971c594ede370d0c62ba68f623f8c85b
7
+ data.tar.gz: 8e9a34fe244c2968f2c808f9a700d4e4502d7441de9b718a647a9dae615b4df36193f8c5e4da248b0722353447ea0d32455bc8a9c0dac99f1aa862958adc0ec6
data/README.md CHANGED
@@ -1,131 +1,201 @@
1
+ <div align="center">
2
+ <img width="450" src="https://github.com/joshmn/caffeinate/raw/master/logo.png" alt="Caffeinate logo" />
3
+ </div>
4
+
5
+ ---
6
+
1
7
  # Caffeinate
2
8
 
3
9
  Caffeinate is a drip campaign engine for Ruby on Rails applications.
4
10
 
5
- Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
11
+ Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
6
12
  and has everything you need to get started and to successfully manage campaigns. It's only dependency is the stack you're
7
13
  already familiar with: Ruby on Rails.
8
14
 
9
- ![Caffeinate logo](logo.png)
15
+ There's a cool demo with all the things included at [caffeinate.email](https://caffeinate.email). You can view the [marketing site source code here](https://github.com/joshmn/caffeinate-marketing).
10
16
 
11
- ## Usage
17
+ ## Do you suffer from ActionMailer tragedies?
12
18
 
13
- You can probably imagine seeing a Mailer like this:
19
+ If you have _anything_ like this is your codebase, **you need Caffeinate**:
20
+
21
+ ```ruby
22
+ class User < ApplicationRecord
23
+ after_commit on: :create do
24
+ OnboardingMailer.welcome_to_my_cool_app(self).deliver_later
25
+ OnboardingMailer.some_cool_tips(self).deliver_later(wait: 2.days)
26
+ OnboardingMailer.help_getting_started(self).deliver_later(wait: 3.days)
27
+ end
28
+ end
29
+ ```
14
30
 
15
- ```ruby
16
- class OnboardingMailer < ActionMailer::Base
31
+ ```ruby
32
+ class OnboardingMailer < ActionMailer::Base
17
33
  # Send on account creation
18
34
  def welcome_to_my_cool_app(user)
19
- mail(to: user.email, subject: "You forgot something!")
35
+ mail(to: user.email, subject: "Welcome to CoolApp!")
20
36
  end
21
37
 
22
38
  # Send 2 days after the user signs up
23
39
  def some_cool_tips(user)
40
+ return if user.unsubscribed_from_onboarding_campaign?
41
+
24
42
  mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
25
- end
43
+ end
26
44
 
27
45
  # Sends 3 days after the user signs up and hasn't added a company profile yet
28
46
  def help_getting_started(user)
29
- return if user.company.present?
47
+ return if user.unsubscribed_from_onboarding_campaign?
48
+ return if user.onboarding_completed?
30
49
 
31
- mail(to: user.email, subject: "Did you know...")
32
- end
33
- end
50
+ mail(to: user.email, subject: "Do you need help getting started?")
51
+ end
52
+ end
34
53
  ```
35
54
 
36
- With background jobs running, checking, and everything else. That's messy. Why are we checking state in the Mailer? Ugh.
55
+ ### What's wrong with this?
37
56
 
38
- We can clean this up with Caffeinate. Here's how we'd do it.
57
+ * You're checking state in a mailer
58
+ * The unsubscribe feature is, most likely, tied to a `User`, which means...
59
+ * It's going to be _so fun_ to scale horizontally
39
60
 
40
- ### Create a Campaign
61
+ ## Caffeinate to the rescue
41
62
 
42
- ```ruby
43
- Caffeinate::Campaign.create!(name: "Onboarding Campaign", slug: "onboarding")
44
- ```
63
+ Caffeinate combines a simple scheduling DSL, ActionMailer, and your data models to create scheduled email sequences.
45
64
 
46
- ### Create a Caffeinate::Dripper
65
+ What can you do with drip campaigns?
66
+ * Onboard new customers with cool tips and tricks
67
+ * Remind customers to use your product
68
+ * Nag customers about using your product
69
+ * Reach their spam folder after you fail to handle their unsubscribe request
70
+ * And more!
47
71
 
48
- Place the contents below in `app/drippers/onboarding_dripper.rb`:
72
+ ## Onboarding in Caffeinate
49
73
 
50
- ```ruby
51
- class OnboardingDripper < ApplicationDripper
52
- drip :welcome_to_my_cool_app, delay: 0.hours
53
- drip :some_cool_tips, delay: 2.days
54
- drip :help_getting_started, delay: 3.days do
55
- if mailing.user.company.present?
56
- mailing.unsubscribe!
57
- return false
58
- end
59
- end
60
- end
61
- ```
74
+ In five minutes you can implement this onboarding campaign, and it won't even hijack your entire app!
62
75
 
63
- ### Add a subscriber to the Campaign
76
+ ### Install it
64
77
 
65
- ```ruby
66
- class User < ApplicationRecord
67
- after_create_commit do
68
- Caffeinate::Campaign.find_by(slug: "onboarding").subscribe(self)
69
- end
70
- end
78
+ Add to Gemfile, run the installer, migrate:
79
+
80
+ ```bash
81
+ $ bundle add caffeinate
82
+ $ rails g caffeinate:install
83
+ $ rake db:migrate
71
84
  ```
72
85
 
73
- ### Run the Dripper
86
+ ### Remove that ActionMailer logic
87
+
88
+ Just delete it. Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
74
89
 
75
- You'd normally want to do this in a cron/whenever/scheduled Sidekiq/etc job.
90
+ The only other change you need to make is the argument that the mailer action receives:
76
91
 
77
92
  ```ruby
78
- OnboardingDripper.perform!
93
+ class OnboardingMailer < ActionMailer::Base
94
+ def welcome_to_my_cool_app(mailing)
95
+ @user = mailing.subscriber
96
+ mail(to: @user.email, subject: "Welcome to CoolApp!")
97
+ end
98
+
99
+ def some_cool_tips(mailing)
100
+ @user = mailing.subscriber
101
+ mail(to: @user.email, subject: "Here are some cool tips for MyCoolApp")
102
+ end
103
+
104
+ def help_getting_started(mailing)
105
+ @user = mailing.subscriber
106
+ mail(to: @user.email, subject: "Do you need help getting started?")
107
+ end
108
+ end
79
109
  ```
80
110
 
81
- ### Spend more time building
111
+ While we're there, let's add an unsubscribe link to the views or layout:
82
112
 
83
- Now you can spend more time building your app and less time managing your marketing campaigns.
84
- * Centralized logic makes it easy to understand the flow
85
- * Subscription management, timings, send history all built-in
86
- * Built on the stack you're already familiar with
113
+ ```erb
114
+ <%= link_to "Stop receiving onboarding tips :(", caffeinate_unsubscribe_url %>
115
+ ```
87
116
 
88
- There's a lot more than what you just saw, too! Caffeinate almost makes managing timed email sequences fun.
117
+ ### Create a Dripper
89
118
 
90
- ## Installation
119
+ A Dripper has all the logic for your Campaign and coordinates with ActionMailer on what to send.
91
120
 
92
- Add this line to your application's Gemfile:
121
+ In `app/drippers/onboarding_dripper.rb`:
93
122
 
94
123
  ```ruby
95
- gem 'caffeinate'
124
+ class OnboardingDripper < ApplicationDripper
125
+ drip :welcome_to_my_cool_app, mailer: 'OnboardingMailer', delay: 0.hours
126
+ drip :some_cool_tips, mailer: 'OnboardingMailer', delay: 2.days
127
+ drip :help_getting_started, mailer: 'OnboardingMailer', delay: 3.days
128
+ end
96
129
  ```
97
130
 
98
- And then do the bundle:
131
+ The `drip` syntax is `def drip(mailer_action, options = {})`.
99
132
 
100
- ```bash
101
- $ bundle
102
- ```
133
+ ### Add a subscriber to the Campaign
103
134
 
104
- Add do some housekeeping:
135
+ Call `OnboardingDripper.subscribe` to subscribe a polymorphic `subscriber` to the Campaign, which creates
136
+ a `Caffeinate::CampaignSubscription`.
105
137
 
106
- ```bash
107
- $ rails g caffeinate:install
138
+ ```ruby
139
+ class User < ApplicationRecord
140
+ after_commit on: :create do
141
+ OnboardingDripper.subscribe(self)
142
+ end
143
+
144
+ after_commit on: :update do
145
+ if onboarding_completed? && onboarding_completed_changed?
146
+ if OnboardingDripper.subscribed?(self)
147
+ OnboardingDripper.unsubscribe(self)
148
+ end
149
+ end
150
+ end
151
+ end
108
152
  ```
109
153
 
110
- Followed by a migrate:
154
+ When a `Caffeinate::CampaignSubscription` is created, the relevant Dripper is parsed and `Caffeinate::Mailing` records
155
+ are created from the `drip` DSL. A `Caffeinate::Mailing` record has a `send_at` attribute which tells Caffeinate when we
156
+ can send the mail, which we get from `Caffeiate::Mailing#mailer_class` and `Caffeinate::Mailing#mailer_action`.
111
157
 
112
- ```bash
113
- $ rails db:migrate
158
+ ### Run the Dripper
159
+
160
+ Running `OnboardingDripper.perform!` every `x` minutes will call `Caffeinate::Mailing#process!` on `Caffeinate::Mailing`
161
+ records that have `send_at < Time.now`.
162
+
163
+ ```ruby
164
+ OnboardingDripper.perform!
114
165
  ```
115
166
 
167
+ ### Done. But wait, there's more fun if you want
168
+
169
+ * Automatic subscriptions
170
+ * Campaign-specific unsubscribe links
171
+ * Reasons for unsubscribing so you can have some sort of analytics
172
+ * Periodical emails (daily, weekly, monthly digests, anyone?)
173
+ * Parameterized mailer support a la `OnboardingMailer.with(mailing: mailing)`
174
+
175
+ ### Done. But wait, there's more fun if you want
176
+
177
+ * Automatic subscriptions
178
+ * Campaign-specific unsubscribe links
179
+ * Reasons for unsubscribing so you can have some sort of analytics
180
+ * Periodical emails (daily, weekly, monthly digests, anyone?)
181
+ * Parameterized mailer support a la `OnboardingMailer.with(mailing: mailing)`
182
+
116
183
  ## Documentation
117
184
 
118
- * [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
119
- * [Better-than-average code documentation](https://rubydoc.info/github/joshmn/caffeinate)
185
+ * [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
186
+ * [Better-than-average code documentation](https://rubydoc.info/gems/caffeinate)
120
187
 
121
188
  ## Upcoming features/todo
122
189
 
123
- * Ability to optionally use relative start time when creating a step
124
- * Logo
125
- * Conversion tracking
126
- * Custom field support on CampaignSubscription
127
- * GUI (?)
128
- * REST API (?)
190
+ [Handy dandy roadmap](https://github.com/joshmn/caffeinate/projects/1).
191
+
192
+ ## Alternatives
193
+
194
+ Not a fan? There are some alternatives!
195
+
196
+ * https://github.com/honeybadger-io/heya
197
+ * https://github.com/tarr11/dripper
198
+ * https://github.com/Sology/maily_herald
129
199
 
130
200
  ## Contributing
131
201
 
@@ -133,8 +203,8 @@ Just do it.
133
203
 
134
204
  ## Contributors & thanks
135
205
 
136
- * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
137
-
206
+ * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
207
+
138
208
  ## License
139
209
 
140
210
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -13,7 +13,7 @@ module Caffeinate
13
13
  end
14
14
 
15
15
  def subscribe
16
- @campaign_subscription.subscribe!
16
+ @campaign_subscription.resubscribe!
17
17
  end
18
18
 
19
19
  private
@@ -22,7 +22,7 @@ module Caffeinate
22
22
  Caffeinate::UrlHelpers.caffeinate_subscribe_url(@campaign_subscription, options)
23
23
  end
24
24
 
25
- def caffeinate_unsubscribe_url
25
+ def caffeinate_unsubscribe_url(**options)
26
26
  Caffeinate::UrlHelpers.caffeinate_unsubscribe_url(@campaign_subscription, options)
27
27
  end
28
28
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- # :nodoc:
5
4
  class ApplicationRecord < ::ActiveRecord::Base
6
5
  self.abstract_class = true
7
6
  end
@@ -14,12 +14,15 @@ module Caffeinate
14
14
  # Campaign ties together subscribers and mailings, and provides one core model for handling your Drippers.
15
15
  class Campaign < ApplicationRecord
16
16
  self.table_name = 'caffeinate_campaigns'
17
+
17
18
  has_many :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
19
+ has_many :subscriptions, class_name: 'Caffeinate::CampaignSubscription', foreign_key: :caffeinate_campaign_id
18
20
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
21
+ has_many :mailings, through: :caffeinate_campaign_subscriptions, class_name: 'Caffeinate::CampaignSubscriptions'
19
22
 
20
23
  # Poorly-named Campaign class resolver
21
24
  def to_dripper
22
- Caffeinate.dripper_to_campaign_class[slug.to_sym].constantize
25
+ ::Caffeinate.dripper_collection.resolve(self)
23
26
  end
24
27
 
25
28
  # Convenience method for find_by!(slug: value)
@@ -43,17 +46,32 @@ module Caffeinate
43
46
  subscriber(record, **args).present?
44
47
  end
45
48
 
46
- # Subscribes an object to a campaign.
49
+ # Unsubscribes an object from a campaign.
50
+ #
51
+ # Campaign[:onboarding].unsubscribe(Company.first, user: Company.first.admin, reason: "Because I said so")
52
+ #
53
+ # is the same as
54
+ #
55
+ # Campaign.find_by(slug: "onboarding").caffeinate_campaign_subscriptions.find_by(subscriber: Company.first, user: Company.first.admin).unsubscribe!("Because I said so")
56
+ #
57
+ # Just... mintier.
58
+ def unsubscribe(subscriber, **args)
59
+ reason = args.delete(:reason)
60
+ subscription = subscriber(subscriber, **args)
61
+ raise ActiveRecord::RecordInvalid, subscription if subscription.nil?
62
+
63
+ subscription.unsubscribe!(reason)
64
+ end
65
+
66
+ # Creates a `CampaignSubscription` object for the present Campaign. Allows passing `**args` to
67
+ # delegate additional arguments to the record. Uses `find_or_create_by`.
47
68
  def subscribe(subscriber, **args)
48
69
  caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
49
70
  end
50
71
 
51
- # Subscribes an object to a campaign.
72
+ # Subscribes an object to a campaign. Raises `ActiveRecord::RecordInvalid` if the record was invalid.
52
73
  def subscribe!(subscriber, **args)
53
- subscription = subscribe(subscriber, **args)
54
- return subscription if subscribe.persisted?
55
-
56
- raise ActiveRecord::RecordInvalid, subscription
74
+ subscribe(subscriber, **args)
57
75
  end
58
76
  end
59
77
  end
@@ -24,21 +24,23 @@ module Caffeinate
24
24
  # CampaignSubscription associates an object and its optional user to a Campaign
25
25
  # and its relevant Mailings.
26
26
  class CampaignSubscription < ApplicationRecord
27
-
28
27
  self.table_name = 'caffeinate_campaign_subscriptions'
29
28
 
30
29
  has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
31
- has_one :next_caffeinate_mailing, -> { upcoming.unsent }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
30
+ has_many :mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
31
+
32
+ has_one :next_caffeinate_mailing, -> { upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
33
+ has_one :next_mailing, -> { upcoming.unsent.order(send_at: :asc) }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
34
+
32
35
  belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
36
+ alias_attribute :campaign, :caffeinate_campaign
37
+
33
38
  belongs_to :subscriber, polymorphic: true
34
39
  belongs_to :user, polymorphic: true, optional: true
35
40
 
36
41
  # All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
37
42
  scope :active, -> { where(unsubscribed_at: nil, ended_at: nil) }
38
-
39
- # All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
40
43
  scope :subscribed, -> { active }
41
-
42
44
  scope :unsubscribed, -> { where.not(unsubscribed_at: nil) }
43
45
 
44
46
  # All CampaignSubscriptions that where `ended_at` is not nil
@@ -49,29 +51,31 @@ module Caffeinate
49
51
 
50
52
  after_commit :create_mailings!, on: :create
51
53
 
54
+ after_commit :on_complete, if: :completed?
55
+
52
56
  # Actually deliver and process the mail
53
57
  def deliver!(mailing)
54
58
  caffeinate_campaign.to_dripper.deliver!(mailing)
55
59
  end
56
60
 
57
- # Checks if the subscription is not ended and not unsubscribed
61
+ # Checks if the `CampaignSubscription` is not ended and not unsubscribed
58
62
  def subscribed?
59
63
  !ended? && !unsubscribed?
60
64
  end
61
65
 
62
- # Checks if the CampaignSubscription is not subscribed by checking the presence of `unsubscribed_at`
66
+ # Checks if the `CampaignSubscription` is not subscribed by checking the presence of `unsubscribed_at`
63
67
  def unsubscribed?
64
68
  unsubscribed_at.present?
65
69
  end
66
70
 
67
- # Checks if the CampaignSubscription is ended by checking the presence of `ended_at`
71
+ # Checks if the `CampaignSubscription` is ended by checking the presence of `ended_at`
68
72
  def ended?
69
73
  ended_at.present?
70
74
  end
71
75
 
72
76
  # Updates `ended_at` and runs `on_complete` callbacks
73
77
  def end!(reason = nil)
74
- raise ::Caffeinate::InvalidState, "CampaignSubscription is already unsubscribed." if unsubscribed?
78
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed?
75
79
 
76
80
  update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
77
81
 
@@ -81,7 +85,7 @@ module Caffeinate
81
85
 
82
86
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
83
87
  def unsubscribe!(reason = nil)
84
- raise ::Caffeinate::InvalidState, "CampaignSubscription is already ended." if ended?
88
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended?
85
89
 
86
90
  update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
87
91
 
@@ -92,8 +96,8 @@ module Caffeinate
92
96
  # Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks.
93
97
  # Use `force` to forcefully reset. Does not create the mailings.
94
98
  def resubscribe!(force = false)
95
- raise ::Caffeinate::InvalidState, "CampaignSubscription is already ended." if ended? && !force
96
- raise ::Caffeinate::InvalidState, "CampaignSubscription is already unsubscribed." if unsubscribed? && !force
99
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended? && !force
100
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed? && !force
97
101
 
98
102
  update!(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
99
103
 
@@ -101,8 +105,16 @@ module Caffeinate
101
105
  true
102
106
  end
103
107
 
108
+ def completed?
109
+ caffeinate_mailings.unsent.count.zero?
110
+ end
111
+
104
112
  private
105
113
 
114
+ def on_complete
115
+ caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
116
+ end
117
+
106
118
  # Create mailings according to the drips registered in the Campaign
107
119
  def create_mailings!
108
120
  caffeinate_campaign.to_dripper.drips.each do |drip|
@@ -113,6 +125,7 @@ module Caffeinate
113
125
  true
114
126
  end
115
127
 
128
+ # Sets a unique token
116
129
  def set_token!
117
130
  loop do
118
131
  self.token = SecureRandom.uuid
@@ -17,12 +17,12 @@
17
17
  module Caffeinate
18
18
  # Records of the mails sent and to be sent for a given `::Caffeinate::CampaignSubscriber`
19
19
  class Mailing < ApplicationRecord
20
- CURRENT_THREAD_KEY = :current_caffeinate_mailing
21
-
22
20
  self.table_name = 'caffeinate_mailings'
23
21
 
24
22
  belongs_to :caffeinate_campaign_subscription, class_name: 'Caffeinate::CampaignSubscription'
23
+ alias_attribute :subscription, :caffeinate_campaign_subscription
25
24
  has_one :caffeinate_campaign, through: :caffeinate_campaign_subscription
25
+ alias_attribute :campaign, :caffeinate_campaign
26
26
 
27
27
  scope :upcoming, -> { unsent.unskipped.where('send_at < ?', ::Caffeinate.config.time_now).order('send_at asc') }
28
28
  scope :unsent, -> { unskipped.where(sent_at: nil) }
@@ -70,7 +70,6 @@ module Caffeinate
70
70
  end
71
71
 
72
72
  # The associated drip
73
- # @todo This can be optimized with a better cache
74
73
  def drip
75
74
  @drip ||= caffeinate_campaign.to_dripper.drip_collection.for(mailer_action)
76
75
  end
@@ -100,6 +99,8 @@ module Caffeinate
100
99
  else
101
100
  deliver!
102
101
  end
102
+
103
+ caffeinate_campaign_subscription.touch
103
104
  end
104
105
 
105
106
  # Delivers the Mailing in the foreground
@@ -7,9 +7,9 @@ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
7
7
  create_table :caffeinate_campaign_subscriptions do |t|
8
8
  t.references :caffeinate_campaign, null: false, index: { name: :caffeineate_campaign_subscriptions_on_campaign }, foreign_key: true
9
9
  t.string :subscriber_type, null: false
10
- t.string :subscriber_id, null: false
10
+ t.integer :subscriber_id, null: false
11
11
  t.string :user_type
12
- t.string :user_id
12
+ t.integer :user_id
13
13
  t.string :token, null: false
14
14
  t.datetime :ended_at
15
15
  t.string :ended_reason
@@ -20,6 +20,6 @@ class CreateCaffeinateCampaignSubscriptions < ActiveRecord::Migration[6.0]
20
20
  t.timestamps
21
21
  end
22
22
  add_index :caffeinate_campaign_subscriptions, :token, unique: true
23
- add_index :caffeinate_campaign_subscriptions, %i[subscriber_id subscriber_type user_id user_type], name: :index_caffeinate_campaign_subscriptions
23
+ add_index :caffeinate_campaign_subscriptions, %i[caffeinate_campaign_id subscriber_id subscriber_type user_id user_type ended_at resubscribed_at unsubscribed_at], name: :index_caffeinate_campaign_subscriptions
24
24
  end
25
25
  end
@@ -14,9 +14,7 @@ class CreateCaffeinateMailings < ActiveRecord::Migration[6.0]
14
14
 
15
15
  t.timestamps
16
16
  end
17
- add_index :caffeinate_mailings, :sent_at
18
- add_index :caffeinate_mailings, :send_at
19
- add_index :caffeinate_mailings, :skipped_at
20
- add_index :caffeinate_mailings, %i[caffeinate_campaign_subscription_id mailer_class mailer_action], name: :index_caffeinate_mailings
17
+
18
+ add_index :caffeinate_mailings, %i[caffeinate_campaign_subscription_id send_at sent_at skipped_at], name: :index_caffeinate_mailings
21
19
  end
22
20
  end
@@ -8,16 +8,11 @@ require 'caffeinate/url_helpers'
8
8
  require 'caffeinate/configuration'
9
9
  require 'caffeinate/dripper/base'
10
10
  require 'caffeinate/deliver_async'
11
+ require 'caffeinate/dripper_collection'
11
12
 
12
13
  module Caffeinate
13
- # Caches the campaign to the campaign class
14
- def self.dripper_to_campaign_class
15
- @dripper_to_campaign_class ||= {}
16
- end
17
-
18
- # Convenience method for `dripper_to_campaign_class`
19
- def self.register_dripper(name, klass)
20
- dripper_to_campaign_class[name.to_sym] = klass
14
+ def self.dripper_collection
15
+ @drippers ||= DripperCollection.new
21
16
  end
22
17
 
23
18
  # Global configuration
@@ -5,30 +5,35 @@ module Caffeinate
5
5
  # Includes the ActiveRecord association and relevant scopes for an ActiveRecord-backed model
6
6
  module Extension
7
7
  # Adds the associations for a subscriber
8
- def caffeinate_subscriber
8
+ def acts_as_caffeinate_subscriber
9
9
  has_many :caffeinate_campaign_subscriptions, as: :subscriber, class_name: '::Caffeinate::CampaignSubscription', dependent: :destroy
10
10
  has_many :caffeinate_campaigns, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Campaign'
11
11
  has_many :caffeinate_mailings, through: :caffeinate_campaign_subscriptions, class_name: '::Caffeinate::Mailing'
12
12
 
13
13
  scope :not_subscribed_to_campaign, lambda { |list|
14
- subscribed = ::Caffeinate::CampaignSubscription.select(:subscriber_id).joins(:caffeinate_campaign).where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
15
- where.not(id: subscribed)
14
+ where.not(id: ::Caffeinate::CampaignSubscription
15
+ .select(:subscriber_id)
16
+ .joins(:caffeinate_campaign)
17
+ .where(subscriber_type: name, caffeinate_campaigns: { slug: list }))
16
18
  }
17
19
 
18
20
  scope :unsubscribed_from_campaign, lambda { |list|
19
- unsubscribed = ::Caffeinate::CampaignSubscription
20
- .unsubscribed
21
- .select(:subscriber_id)
22
- .joins(:caffeinate_campaign)
23
- .where(caffeinate_campaigns: { slug: list }, subscriber_type: name)
24
- where(id: unsubscribed)
21
+ where(id: ::Caffeinate::CampaignSubscription
22
+ .unsubscribed
23
+ .select(:subscriber_id)
24
+ .joins(:caffeinate_campaign)
25
+ .where(subscriber_type: name, caffeinate_campaigns: { slug: list }))
25
26
  }
26
27
  end
28
+ alias caffeinate_subscriber acts_as_caffeinate_subscriber
27
29
 
28
30
  # Adds the associations for a user
29
- def caffeinate_user
31
+ def acts_as_caffeinate_user
30
32
  has_many :caffeinate_campaign_subscriptions_as_user, as: :user, class_name: '::Caffeinate::CampaignSubscription'
33
+ has_many :caffeinate_campaigns_as_user, through: :caffeinate_campaign_subscriptions_as_user, class_name: '::Caffeinate::Campaign'
34
+ has_many :caffeinate_mailings_as_user, through: :caffeinate_campaign_subscriptions_as_user, class_name: '::Caffeinate::Campaign'
31
35
  end
36
+ alias caffeinate_user acts_as_caffeinate_user
32
37
  end
33
38
  end
34
39
  end
@@ -3,20 +3,26 @@
3
3
  module Caffeinate
4
4
  # Global configuration
5
5
  class Configuration
6
- attr_accessor :now, :async_delivery, :mailing_job, :batch_size, :drippers_path
6
+ attr_accessor :now, :async_delivery, :mailing_job, :batch_size, :drippers_path, :implicit_campaigns
7
7
 
8
8
  def initialize
9
9
  @now = -> { Time.current }
10
10
  @async_delivery = false
11
11
  @mailing_job = nil
12
12
  @batch_size = 1_000
13
- @drippers_path = "app/drippers"
13
+ @drippers_path = 'app/drippers'
14
+ @implicit_campaigns = true
14
15
  end
15
16
 
16
17
  def now=(val)
17
18
  raise ArgumentError, '#now must be a proc' unless val.respond_to?(:call)
18
19
 
19
- super
20
+ @now = val
21
+ end
22
+
23
+ # Automatically create a `::Caffeinate::Campaign` object if not found per `Dripper.inferred_campaign_slug`
24
+ def implicit_campaigns?
25
+ @implicit_campaigns == true
20
26
  end
21
27
 
22
28
  # The current time, for database calls
@@ -7,6 +7,7 @@ module Caffeinate
7
7
  # Handles the block and provides convenience methods for the drip
8
8
  class Drip
9
9
  attr_reader :dripper, :action, :options, :block
10
+
10
11
  def initialize(dripper, action, options, &block)
11
12
  @dripper = dripper
12
13
  @action = action
@@ -22,9 +23,7 @@ module Caffeinate
22
23
  def send_at(mailing = nil)
23
24
  if periodical?
24
25
  start = mailing.instance_exec(&options[:start])
25
- if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count > 0
26
- start += options[:every]
27
- end
26
+ start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
28
27
  start.from_now
29
28
  else
30
29
  options[:delay].from_now
@@ -37,6 +36,8 @@ module Caffeinate
37
36
 
38
37
  # Checks if the drip is enabled
39
38
  def enabled?(mailing)
39
+ dripper.run_callbacks(:before_drip, self, mailing)
40
+
40
41
  DripEvaluator.new(mailing).call(&@block)
41
42
  end
42
43
  end
@@ -4,6 +4,7 @@ module Caffeinate
4
4
  # Handles evaluating the `drip` block and provides convenience methods for handling the mailing or its campaign.
5
5
  class DripEvaluator
6
6
  attr_reader :mailing
7
+
7
8
  def initialize(mailing)
8
9
  @mailing = mailing
9
10
  end
@@ -38,6 +38,22 @@ module Caffeinate
38
38
  @on_subscribe_blocks ||= []
39
39
  end
40
40
 
41
+ # Callback after a Caffeinate::CampaignSubscription is `#resubscribed!`
42
+ #
43
+ # on_resubscribe do |campaign_subscription|
44
+ # Slack.notify(:caffeinate, "Someone resubscribed to #{campaign_subscription.campaign.name}!")
45
+ # end
46
+ #
47
+ # @yield Caffeinate::CampaignSubscription
48
+ def on_resubscribe(&block)
49
+ on_resubscribe_blocks << block
50
+ end
51
+
52
+ # :nodoc:
53
+ def on_resubscribe_blocks
54
+ @on_resubscribe_blocks ||= []
55
+ end
56
+
41
57
  # Callback before the mailings get processed.
42
58
  #
43
59
  # before_perform do |dripper|
@@ -171,7 +187,6 @@ module Caffeinate
171
187
  @on_unsubscribe_blocks ||= []
172
188
  end
173
189
 
174
-
175
190
  # Callback after a CampaignSubscriber has ended.
176
191
  #
177
192
  # on_end do |campaign_sub|
@@ -31,14 +31,27 @@ module Caffeinate
31
31
  def campaign=(slug)
32
32
  @caffeinate_campaign = nil
33
33
  @_campaign_slug = slug.to_sym
34
- Caffeinate.register_dripper(@_campaign_slug, name)
34
+ Caffeinate.dripper_collection.register(@_campaign_slug, name)
35
35
  end
36
36
 
37
- # Returns the `Caffeinate::Campaign` object for the Dripper
37
+ # Returns the `Caffeinate::Campaign` object for the Dripper.
38
+ #
39
+ # If `config.implicit_campaigns` is true, this will automatically create a `Caffeinate::Campaign` if one is not
40
+ # found via the `campaign_slug`.
38
41
  def caffeinate_campaign
39
42
  return @caffeinate_campaign if @caffeinate_campaign.present?
40
43
 
41
- @caffeinate_campaign = ::Caffeinate::Campaign[campaign_slug]
44
+ if ::Caffeinate.config.implicit_campaigns?
45
+ @caffeinate_campaign = ::Caffeinate::Campaign.find_or_initialize_by(slug: campaign_slug)
46
+ if @caffeinate_campaign.new_record?
47
+ @caffeinate_campaign.name = "#{name.delete_suffix('Dripper').titleize} Campaign"
48
+ @caffeinate_campaign.save!
49
+ end
50
+ else
51
+ @caffeinate_campaign = ::Caffeinate::Campaign[campaign_slug]
52
+ end
53
+
54
+ @caffeinate_campaign
42
55
  end
43
56
  alias campaign caffeinate_campaign
44
57
 
@@ -1,66 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'caffeinate/dripper/drip_collection'
3
4
  module Caffeinate
4
5
  module Dripper
5
6
  # The Drip DSL for registering a drip.
6
7
  module Drip
7
- # A collection of Drip objects for a `Caffeinate::Dripper`
8
- class DripCollection
9
- include Enumerable
10
-
11
- def initialize(dripper)
12
- @dripper = dripper
13
- @drips = {}
14
- end
15
-
16
- def for(action)
17
- @drips[action.to_sym]
18
- end
19
-
20
- # Register the drip
21
- def register(action, options, &block)
22
- options = validate_drip_options(action, options)
23
-
24
- @drips[action.to_sym] = ::Caffeinate::Drip.new(@dripper, action, options, &block)
25
- end
26
-
27
- def each(&block)
28
- @drips.each { |action_name, drip| block.call(action_name, drip) }
29
- end
30
-
31
- def values
32
- @drips.values
33
- end
34
-
35
- def size
36
- @drips.size
37
- end
38
-
39
- def [](val)
40
- @drips[val]
41
- end
42
-
43
- private
44
-
45
- def validate_drip_options(action, options)
46
- options.symbolize_keys!
47
- options.assert_valid_keys(:mailer_class, :step, :delay, :every, :start, :using, :mailer)
48
- options[:mailer_class] ||= options[:mailer] || @dripper.defaults[:mailer_class]
49
- options[:using] ||= @dripper.defaults[:using]
50
- options[:step] ||= @dripper.drips.size + 1
51
-
52
- if options[:mailer_class].nil?
53
- raise ArgumentError, "You must define :mailer_class or :mailer in the options for #{action.inspect} on #{@dripper.inspect}"
54
- end
55
-
56
- if options[:every].nil? && options[:delay].nil?
57
- raise ArgumentError, "You must define :delay in the options for #{action.inspect} on #{@dripper.inspect}"
58
- end
59
-
60
- options
61
- end
62
- end
63
-
64
8
  # :nodoc:
65
9
  def self.included(klass)
66
10
  klass.extend ClassMethods
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Caffeinate
4
+ module Dripper
5
+ # A collection of Drip objects for a `Caffeinate::Dripper`
6
+ class DripCollection
7
+ include Enumerable
8
+
9
+ def initialize(dripper)
10
+ @dripper = dripper
11
+ @drips = {}
12
+ end
13
+
14
+ def for(action)
15
+ @drips[action.to_sym]
16
+ end
17
+
18
+ # Register the drip
19
+ def register(action, options, &block)
20
+ options = validate_drip_options(action, options)
21
+
22
+ @drips[action.to_sym] = ::Caffeinate::Drip.new(@dripper, action, options, &block)
23
+ end
24
+
25
+ def each(&block)
26
+ @drips.each { |action_name, drip| block.call(action_name, drip) }
27
+ end
28
+
29
+ def values
30
+ @drips.values
31
+ end
32
+
33
+ def size
34
+ @drips.size
35
+ end
36
+
37
+ def [](val)
38
+ @drips[val]
39
+ end
40
+
41
+ private
42
+
43
+ def validate_drip_options(action, options)
44
+ options.symbolize_keys!
45
+ options.assert_valid_keys(:mailer_class, :step, :delay, :every, :start, :using, :mailer)
46
+ options[:mailer_class] ||= options[:mailer] || @dripper.defaults[:mailer_class]
47
+ options[:using] ||= @dripper.defaults[:using]
48
+ options[:step] ||= @dripper.drips.size + 1
49
+
50
+ if options[:mailer_class].nil?
51
+ raise ArgumentError, "You must define :mailer_class or :mailer in the options for #{action.inspect} on #{@dripper.inspect}"
52
+ end
53
+
54
+ if options[:every].nil? && options[:delay].nil?
55
+ raise ArgumentError, "You must define :delay in the options for #{action.inspect} on #{@dripper.inspect}"
56
+ end
57
+
58
+ options
59
+ end
60
+ end
61
+ end
62
+ end
@@ -19,7 +19,9 @@ module Caffeinate
19
19
  nil
20
20
  end
21
21
 
22
- # The inferred mailer class
22
+ # The inferred campaign slug
23
+ #
24
+ # MyCoolDripper => my_cool
23
25
  def inferred_campaign_slug
24
26
  name.delete_suffix('Dripper').to_s.underscore
25
27
  end
@@ -17,16 +17,14 @@ module Caffeinate
17
17
  def perform!
18
18
  run_callbacks(:before_perform, self)
19
19
  Caffeinate::Mailing
20
- .upcoming
21
- .unsent
22
- .joins(:caffeinate_campaign_subscription)
23
- .merge(Caffeinate::CampaignSubscription.active)
24
- .in_batches(of: self.class.batch_size)
25
- .each do |batch|
20
+ .upcoming
21
+ .unsent
22
+ .joins(:caffeinate_campaign_subscription)
23
+ .merge(Caffeinate::CampaignSubscription.active)
24
+ .in_batches(of: self.class.batch_size)
25
+ .each do |batch|
26
26
  run_callbacks(:on_perform, self, batch)
27
- batch.each do |mailing|
28
- mailing.process!
29
- end
27
+ batch.each(&:process!)
30
28
  end
31
29
  run_callbacks(:after_perform, self)
32
30
  nil
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Caffeinate
2
4
  module Dripper
3
5
  module Periodical
@@ -18,8 +18,8 @@ module Caffeinate
18
18
  end
19
19
 
20
20
  # Returns the campaign's `Caffeinate::CampaignSubscriber`
21
- def subscribers
22
- caffeinate_campaign.caffeinate_campaign_subscribers
21
+ def subscriptions
22
+ caffeinate_campaign.caffeinate_campaign_subscriptions
23
23
  end
24
24
 
25
25
  # Subscribes to the campaign.
@@ -34,6 +34,18 @@ module Caffeinate
34
34
  caffeinate_campaign.subscribe(subscriber, **args)
35
35
  end
36
36
 
37
+ # Unsubscribes from the campaign.
38
+ #
39
+ # OrderDripper.unsubscribe(order, user: order.user)
40
+ #
41
+ # @param [ActiveRecord::Base] subscriber The object subscribing
42
+ # @option [ActiveRecord::Base] :user The associated user (optional)
43
+ #
44
+ # @return [Caffeinate::CampaignSubscriber] the CampaignSubscriber
45
+ def unsubscribe(subscriber, **args)
46
+ caffeinate_campaign.unsubscribe(subscriber, **args)
47
+ end
48
+
37
49
  # :nodoc:
38
50
  def subscribes_block
39
51
  raise(NotImplementedError, 'Define subscribes') unless @subscribes_block
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Caffeinate
4
+ class DripperCollection
5
+ def initialize
6
+ @registry = {}
7
+ end
8
+
9
+ def register(name, klass)
10
+ @registry[name.to_sym] = klass
11
+ end
12
+
13
+ def resolve(campaign)
14
+ @registry[campaign.slug.to_sym].constantize
15
+ end
16
+ end
17
+ end
@@ -9,11 +9,13 @@ module Caffeinate
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace Caffeinate
11
11
 
12
+ # :nocov:
12
13
  config.to_prepare do
13
- Dir.glob(Rails.root.join(Caffeinate.config.drippers_path, "**", "*.rb")).each do |dripper|
14
+ Dir.glob(Rails.root.join(Caffeinate.config.drippers_path, '**', '*.rb')).sort.each do |dripper|
14
15
  require dripper
15
16
  end
16
17
  end
18
+ # :nocov:
17
19
 
18
20
  ActiveSupport.on_load(:action_mailer) do
19
21
  include ::Caffeinate::ActionMailer::Extension
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caffeinate
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -31,4 +31,14 @@ Caffeinate.setup do |config|
31
31
  # config.batch_size = 1_000
32
32
  #
33
33
  # config.batch_size = 100
34
+ #
35
+ # == Implicit Campaigns
36
+ #
37
+ # Instead of manually having to create a Campaign, you can let Caffeinate do a `find_or_create_by` at runtime.
38
+ # This is probably dangerous but it hasn't burned me yet so here you go:
39
+ #
40
+ # Default:
41
+ # config.implicit_campaigns = true
42
+ #
43
+ # config.implicit_campaigns = false
34
44
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: caffeinate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Brody
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-06 00:00:00.000000000 Z
11
+ date: 2020-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: sqlite3
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -149,10 +163,12 @@ files:
149
163
  - lib/caffeinate/dripper/defaults.rb
150
164
  - lib/caffeinate/dripper/delivery.rb
151
165
  - lib/caffeinate/dripper/drip.rb
166
+ - lib/caffeinate/dripper/drip_collection.rb
152
167
  - lib/caffeinate/dripper/inferences.rb
153
168
  - lib/caffeinate/dripper/perform.rb
154
169
  - lib/caffeinate/dripper/periodical.rb
155
170
  - lib/caffeinate/dripper/subscriber.rb
171
+ - lib/caffeinate/dripper_collection.rb
156
172
  - lib/caffeinate/engine.rb
157
173
  - lib/caffeinate/helpers.rb
158
174
  - lib/caffeinate/mail_ext.rb