caffeinate 0.2.0 → 0.6.0

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