caffeinate 0.2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -77
  3. data/app/controllers/caffeinate/campaign_subscriptions_controller.rb +3 -3
  4. data/app/models/caffeinate/application_record.rb +0 -1
  5. data/app/models/caffeinate/campaign.rb +49 -2
  6. data/app/models/caffeinate/campaign_subscription.rb +50 -13
  7. data/app/models/caffeinate/mailing.rb +14 -6
  8. data/app/views/layouts/{caffeinate.html.erb → _caffeinate.html.erb} +0 -0
  9. data/db/migrate/20201124183102_create_caffeinate_campaigns.rb +1 -0
  10. data/db/migrate/20201124183303_create_caffeinate_campaign_subscriptions.rb +6 -3
  11. data/db/migrate/20201124183419_create_caffeinate_mailings.rb +2 -1
  12. data/lib/caffeinate.rb +4 -8
  13. data/lib/caffeinate/action_mailer.rb +4 -4
  14. data/lib/caffeinate/action_mailer/extension.rb +11 -5
  15. data/lib/caffeinate/action_mailer/interceptor.rb +4 -2
  16. data/lib/caffeinate/action_mailer/observer.rb +4 -3
  17. data/lib/caffeinate/active_record/extension.rb +17 -11
  18. data/lib/caffeinate/configuration.rb +11 -2
  19. data/lib/caffeinate/drip.rb +15 -2
  20. data/lib/caffeinate/drip_evaluator.rb +3 -0
  21. data/lib/caffeinate/dripper/base.rb +12 -5
  22. data/lib/caffeinate/dripper/batching.rb +22 -0
  23. data/lib/caffeinate/dripper/callbacks.rb +89 -6
  24. data/lib/caffeinate/dripper/campaign.rb +20 -8
  25. data/lib/caffeinate/dripper/defaults.rb +4 -2
  26. data/lib/caffeinate/dripper/delivery.rb +8 -8
  27. data/lib/caffeinate/dripper/drip.rb +3 -42
  28. data/lib/caffeinate/dripper/drip_collection.rb +62 -0
  29. data/lib/caffeinate/dripper/inferences.rb +7 -2
  30. data/lib/caffeinate/dripper/perform.rb +14 -7
  31. data/lib/caffeinate/dripper/periodical.rb +26 -0
  32. data/lib/caffeinate/dripper/subscriber.rb +14 -2
  33. data/lib/caffeinate/dripper_collection.rb +17 -0
  34. data/lib/caffeinate/engine.rb +6 -4
  35. data/lib/caffeinate/helpers.rb +3 -0
  36. data/lib/caffeinate/mail_ext.rb +12 -0
  37. data/lib/caffeinate/url_helpers.rb +3 -0
  38. data/lib/caffeinate/version.rb +1 -1
  39. data/lib/generators/caffeinate/install_generator.rb +5 -1
  40. data/lib/generators/caffeinate/templates/caffeinate.rb +21 -1
  41. metadata +22 -4
  42. data/lib/caffeinate/action_mailer/helpers.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3032e43fad1742429e8961a3bfca1e522c9fcf19714c8cc998d6dea629c30a6e
4
- data.tar.gz: a51f59c2d0767d18002051827590d31a7ab0b96ae570a1c048d0a37ab4446007
3
+ metadata.gz: a3bf57f512b2d4760d99422a5696250ae68e29498fb013a6b7864441c028277f
4
+ data.tar.gz: 20402615978ccb8fe58a8dba9a229a10e972ea42f03bfad68894744263d2302d
5
5
  SHA512:
6
- metadata.gz: ede225ce8d0fbb71214411d38366ea43992da7b95428734af5c4134f496c04d2dbf2b4a940f19ffbbf24dde9c4536834b96051bd493ec3476897f6f376b2324a
7
- data.tar.gz: acc6124e88a4d8e6beced5afc80e7774de78b60858940e579f821ed172ad14f6a033e7b0419c105c233806f94005f98d4dab73f25bbb53e40175d7dca289c9f2
6
+ metadata.gz: 72698ac79d53fe73f71203a31d789916112dac478924e88369de9622195bb9f27d25ced846b44d5896b51d787295591e971c594ede370d0c62ba68f623f8c85b
7
+ data.tar.gz: 8e9a34fe244c2968f2c808f9a700d4e4502d7441de9b718a647a9dae615b4df36193f8c5e4da248b0722353447ea0d32455bc8a9c0dac99f1aa862958adc0ec6
data/README.md CHANGED
@@ -1,116 +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
- Ruby on Rails drip campaign engine.
9
+ Caffeinate is a drip campaign engine for Ruby on Rails applications.
4
10
 
5
- ## Are there docs?
11
+ Caffeinate tries to make creating and managing timed and scheduled email sequences fun. It works alongside ActionMailer
12
+ and has everything you need to get started and to successfully manage campaigns. It's only dependency is the stack you're
13
+ already familiar with: Ruby on Rails.
6
14
 
7
- [Since you asked](https://rubydoc.info/github/joshmn/caffeinate).
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).
8
16
 
9
- ## Usage
17
+ ## Do you suffer from ActionMailer tragedies?
10
18
 
11
- Given a mailer like this:
19
+ If you have _anything_ like this is your codebase, **you need Caffeinate**:
12
20
 
13
- ```ruby
14
- class AbandonedCartMailer < ActionMailer::Base
15
- def you_forgot_something(cart)
16
- mail(to: cart.user.email, subject: "You forgot something!")
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
+ ```
30
+
31
+ ```ruby
32
+ class OnboardingMailer < ActionMailer::Base
33
+ # Send on account creation
34
+ def welcome_to_my_cool_app(user)
35
+ mail(to: user.email, subject: "Welcome to CoolApp!")
17
36
  end
18
37
 
19
- def selling_out_soon(cart)
20
- mail(to: cart.user.email, subject: "Selling out soon!")
21
- end
22
- end
38
+ # Send 2 days after the user signs up
39
+ def some_cool_tips(user)
40
+ return if user.unsubscribed_from_onboarding_campaign?
41
+
42
+ mail(to: user.email, subject: "Here are some cool tips for MyCoolApp")
43
+ end
44
+
45
+ # Sends 3 days after the user signs up and hasn't added a company profile yet
46
+ def help_getting_started(user)
47
+ return if user.unsubscribed_from_onboarding_campaign?
48
+ return if user.onboarding_completed?
49
+
50
+ mail(to: user.email, subject: "Do you need help getting started?")
51
+ end
52
+ end
23
53
  ```
24
54
 
25
- ### Create a Campaign
55
+ ### What's wrong with this?
56
+
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
26
60
 
27
- ```ruby
28
- Caffeinate::Campaign.create!(name: "Abandoned Cart", slug: "abandoned_cart")
61
+ ## Caffeinate to the rescue
62
+
63
+ Caffeinate combines a simple scheduling DSL, ActionMailer, and your data models to create scheduled email sequences.
64
+
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!
71
+
72
+ ## Onboarding in Caffeinate
73
+
74
+ In five minutes you can implement this onboarding campaign, and it won't even hijack your entire app!
75
+
76
+ ### Install it
77
+
78
+ Add to Gemfile, run the installer, migrate:
79
+
80
+ ```bash
81
+ $ bundle add caffeinate
82
+ $ rails g caffeinate:install
83
+ $ rake db:migrate
29
84
  ```
30
85
 
31
- ### Create a Caffeinate::Dripper
32
-
33
- ```ruby
34
- class AbandonedCartDripper < Caffeinate::Dripper::Base
35
- # This should match a Caffeinate::Campaign#slug
36
- campaign :abandoned_cart
37
-
38
- # A block to subscribe your users automatically
39
- # You can invoke this by calling `AbandonedCartDripper.subscribe!`,
40
- # probably in a background process, run at a given interval
41
- subscribes do
42
- Cart.left_joins(:cart_items)
43
- .includes(:user)
44
- .where(completed_at: nil)
45
- .where(updated_at: 1.day.ago..2.days.ago)
46
- .having('count(cart_items.id) = 0').each do |cart|
47
- subscribe(cart, user: cart.user)
48
- end
49
- end
50
-
51
- # Register your drips! Syntax is
52
- # drip <mailer_action_name>, mailer: <MailerClass>, delay: <ActiveSupport::Interval>
53
- drip :you_forgot_something, mailer: "AbandonedCartMailer", delay: 1.hour
54
- drip :selling_out_soon, mailer: "AbandonedCartMailer", delay: 8.hours do
55
- cart = mailing.subscriber
56
- if cart.completed?
57
- end! # you can also invoke `unsubscribe!` to cancel this mailing and all future mailings
58
- return false
59
- end
60
- end
61
- end
86
+ ### Remove that ActionMailer logic
87
+
88
+ Just delete it. Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
89
+
90
+ The only other change you need to make is the argument that the mailer action receives:
91
+
92
+ ```ruby
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
62
109
  ```
63
110
 
64
- Automatically subscribe eligible carts to it by running:
111
+ While we're there, let's add an unsubscribe link to the views or layout:
65
112
 
66
- ```ruby
67
- AbandonedCartDripper.subscribe!
113
+ ```erb
114
+ <%= link_to "Stop receiving onboarding tips :(", caffeinate_unsubscribe_url %>
68
115
  ```
69
116
 
70
- This would typically run in a background job, queued up at a given interval.
117
+ ### Create a Dripper
118
+
119
+ A Dripper has all the logic for your Campaign and coordinates with ActionMailer on what to send.
71
120
 
72
- And then, once it's done, start your engines!
121
+ In `app/drippers/onboarding_dripper.rb`:
73
122
 
74
- ```ruby
75
- AbandonedCartDripper.perform!
123
+ ```ruby
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
76
129
  ```
77
130
 
78
- This, too, would typically run in a background job, queued up at a given interval.
131
+ The `drip` syntax is `def drip(mailer_action, options = {})`.
79
132
 
80
- ## Installation
133
+ ### Add a subscriber to the Campaign
81
134
 
82
- Add this line to your application's Gemfile:
135
+ Call `OnboardingDripper.subscribe` to subscribe a polymorphic `subscriber` to the Campaign, which creates
136
+ a `Caffeinate::CampaignSubscription`.
83
137
 
84
138
  ```ruby
85
- gem 'caffeinate'
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
86
152
  ```
87
153
 
88
- And then do the bundle:
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`.
89
157
 
90
- ```bash
91
- $ bundle
92
- ```
158
+ ### Run the Dripper
93
159
 
94
- Add do some housekeeping:
160
+ Running `OnboardingDripper.perform!` every `x` minutes will call `Caffeinate::Mailing#process!` on `Caffeinate::Mailing`
161
+ records that have `send_at < Time.now`.
95
162
 
96
- ```bash
97
- $ rails g caffeinate:install
163
+ ```ruby
164
+ OnboardingDripper.perform!
98
165
  ```
99
166
 
100
- Followed by a migrate:
167
+ ### Done. But wait, there's more fun if you want
101
168
 
102
- ```bash
103
- $ rails db:migrate
104
- ```
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
+
183
+ ## Documentation
184
+
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)
105
187
 
106
188
  ## Upcoming features/todo
107
189
 
108
- * Ability to optionally use relative start time when creating a step
109
- * Logo
110
- * Conversion tracking
111
- * Custom field support on CampaignSubscription
112
- * GUI (?)
113
- * 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
114
199
 
115
200
  ## Contributing
116
201
 
@@ -118,8 +203,8 @@ Just do it.
118
203
 
119
204
  ## Contributors & thanks
120
205
 
121
- * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
122
-
206
+ * Thanks to [sourdoughdev](https://github.com/sourdoughdev/caffeinate) for releasing the gem name to me. :)
207
+
123
208
  ## License
124
209
 
125
210
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Caffeinate
4
4
  class CampaignSubscriptionsController < ApplicationController
5
- layout 'caffeinate'
5
+ layout '_caffeinate'
6
6
 
7
7
  helper_method :caffeinate_unsubscribe_url, :caffeinate_subscribe_url
8
8
 
@@ -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,17 +14,64 @@ 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)
26
+ end
27
+
28
+ # Convenience method for find_by!(slug: value)
29
+ #
30
+ # ::Caffeinate::Campaign[:onboarding]
31
+ # # is the same as
32
+ # ::Caffeinate::Campaign.find_by(slug: :onboarding)
33
+ def self.[](val)
34
+ find_by!(slug: val)
35
+ end
36
+
37
+ # Checks to see if the subscriber exists.
38
+ #
39
+ # Use `find_by` so that we don't have to load the record twice. Often used with `subscribes?`
40
+ def subscriber(record, **args)
41
+ @subscriber ||= caffeinate_campaign_subscriptions.find_by(subscriber: record, **args)
42
+ end
43
+
44
+ # Check if the subscriber exists
45
+ def subscribes?(record, **args)
46
+ subscriber(record, **args).present?
47
+ end
48
+
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)
23
64
  end
24
65
 
25
- # Subscribes an object to a campaign.
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`.
26
68
  def subscribe(subscriber, **args)
27
69
  caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
28
70
  end
71
+
72
+ # Subscribes an object to a campaign. Raises `ActiveRecord::RecordInvalid` if the record was invalid.
73
+ def subscribe!(subscriber, **args)
74
+ subscribe(subscriber, **args)
75
+ end
29
76
  end
30
77
  end
@@ -17,23 +17,30 @@
17
17
  # updated_at :datetime not null
18
18
  #
19
19
  module Caffeinate
20
+ # If a record tries to be `unsubscribed!` or `ended!` or `resubscribe!` and it's in a state that is not
21
+ # correct, raise this
22
+ class InvalidState < ::ActiveRecord::RecordInvalid; end
23
+
20
24
  # CampaignSubscription associates an object and its optional user to a Campaign
21
25
  # and its relevant Mailings.
22
26
  class CampaignSubscription < ApplicationRecord
23
27
  self.table_name = 'caffeinate_campaign_subscriptions'
24
28
 
25
- has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
26
- has_one :next_caffeinate_mailing, -> { upcoming.unsent.limit(1).first }, class_name: '::Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id
29
+ has_many :caffeinate_mailings, class_name: 'Caffeinate::Mailing', foreign_key: :caffeinate_campaign_subscription_id, dependent: :destroy
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
+
27
35
  belongs_to :caffeinate_campaign, class_name: 'Caffeinate::Campaign', foreign_key: :caffeinate_campaign_id
36
+ alias_attribute :campaign, :caffeinate_campaign
37
+
28
38
  belongs_to :subscriber, polymorphic: true
29
39
  belongs_to :user, polymorphic: true, optional: true
30
40
 
31
41
  # All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
32
42
  scope :active, -> { where(unsubscribed_at: nil, ended_at: nil) }
33
-
34
- # All CampaignSubscriptions that where `unsubscribed_at` is nil and `ended_at` is nil
35
43
  scope :subscribed, -> { active }
36
-
37
44
  scope :unsubscribed, -> { where.not(unsubscribed_at: nil) }
38
45
 
39
46
  # All CampaignSubscriptions that where `ended_at` is not nil
@@ -44,42 +51,70 @@ module Caffeinate
44
51
 
45
52
  after_commit :create_mailings!, on: :create
46
53
 
54
+ after_commit :on_complete, if: :completed?
55
+
47
56
  # Actually deliver and process the mail
48
57
  def deliver!(mailing)
49
58
  caffeinate_campaign.to_dripper.deliver!(mailing)
50
59
  end
51
60
 
52
- # Checks if the subscription is not ended and not unsubscribed
61
+ # Checks if the `CampaignSubscription` is not ended and not unsubscribed
53
62
  def subscribed?
54
63
  !ended? && !unsubscribed?
55
64
  end
56
65
 
57
- # 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`
58
67
  def unsubscribed?
59
68
  unsubscribed_at.present?
60
69
  end
61
70
 
62
- # 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`
63
72
  def ended?
64
73
  ended_at.present?
65
74
  end
66
75
 
67
76
  # Updates `ended_at` and runs `on_complete` callbacks
68
- def end!
69
- update!(ended_at: ::Caffeinate.config.time_now)
77
+ def end!(reason = nil)
78
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed?
70
79
 
71
- caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
80
+ update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
81
+
82
+ caffeinate_campaign.to_dripper.run_callbacks(:on_end, self)
83
+ true
72
84
  end
73
85
 
74
86
  # Updates `unsubscribed_at` and runs `on_subscribe` callbacks
75
- def unsubscribe!
76
- update!(unsubscribed_at: ::Caffeinate.config.time_now)
87
+ def unsubscribe!(reason = nil)
88
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended?
89
+
90
+ update!(unsubscribed_at: ::Caffeinate.config.time_now, unsubscribe_reason: reason)
77
91
 
78
92
  caffeinate_campaign.to_dripper.run_callbacks(:on_unsubscribe, self)
93
+ true
94
+ end
95
+
96
+ # Updates `unsubscribed_at` to nil and runs `on_subscribe` callbacks.
97
+ # Use `force` to forcefully reset. Does not create the mailings.
98
+ def resubscribe!(force = false)
99
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already ended.' if ended? && !force
100
+ raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed? && !force
101
+
102
+ update!(unsubscribed_at: nil, resubscribed_at: ::Caffeinate.config.time_now)
103
+
104
+ caffeinate_campaign.to_dripper.run_callbacks(:on_resubscribe, self)
105
+ true
106
+ end
107
+
108
+ def completed?
109
+ caffeinate_mailings.unsent.count.zero?
79
110
  end
80
111
 
81
112
  private
82
113
 
114
+ def on_complete
115
+ caffeinate_campaign.to_dripper.run_callbacks(:on_complete, self)
116
+ end
117
+
83
118
  # Create mailings according to the drips registered in the Campaign
84
119
  def create_mailings!
85
120
  caffeinate_campaign.to_dripper.drips.each do |drip|
@@ -87,8 +122,10 @@ module Caffeinate
87
122
  mailing.save!
88
123
  end
89
124
  caffeinate_campaign.to_dripper.run_callbacks(:on_subscribe, self)
125
+ true
90
126
  end
91
127
 
128
+ # Sets a unique token
92
129
  def set_token!
93
130
  loop do
94
131
  self.token = SecureRandom.uuid