moist 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +192 -0
- data/Rakefile +22 -0
- data/app/controllers/moist/application_controller.rb +5 -0
- data/app/models/moist/application_record.rb +5 -0
- data/app/models/moist/campaign.rb +11 -0
- data/app/models/moist/campaign_subscriber.rb +23 -0
- data/app/models/moist/mailing.rb +11 -0
- data/app/models/moist/receipt.rb +6 -0
- data/app/views/layouts/moist/application.html.erb +15 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20201113220645_create_moist_moist_campaigns.rb +13 -0
- data/db/migrate/20201113220800_create_moist_moist_campaign_subscribers.rb +13 -0
- data/db/migrate/20201113220943_create_moist_mailings.rb +13 -0
- data/db/migrate/20201113220944_create_moist_moist_receipts.rb +12 -0
- data/lib/moist.rb +25 -0
- data/lib/moist/action_mailer/extension.rb +35 -0
- data/lib/moist/action_mailer/observer.rb +21 -0
- data/lib/moist/active_record/extension.rb +35 -0
- data/lib/moist/delivery.rb +15 -0
- data/lib/moist/engine.rb +14 -0
- data/lib/moist/errors.rb +5 -0
- data/lib/moist/mailer_registry.rb +20 -0
- data/lib/moist/models/campaign.rb +38 -0
- data/lib/moist/models/campaign_subscriber.rb +37 -0
- data/lib/moist/models/mailing.rb +19 -0
- data/lib/moist/registry_proxy.rb +8 -0
- data/lib/moist/scheduler.rb +23 -0
- data/lib/moist/steps/collection.rb +53 -0
- data/lib/moist/steps/registry.rb +20 -0
- data/lib/moist/steps/step.rb +16 -0
- data/lib/moist/subscription_manager.rb +25 -0
- data/lib/moist/version.rb +3 -0
- data/lib/tasks/moist_tasks.rake +0 -0
- metadata +153 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 92bb66edca74385939a1d6cb2328a48e78f806d8c03bfaa4c559895f7f5b1f5f
|
4
|
+
data.tar.gz: b40cc1b72132ca1e1de92db784ad12b836e95cc2f322b3cd652be92239fb165e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4e321d9898fcf22fceb0eb1a3d44efe3e425dbd88347b14e9ae3764a5c920148495bbfd65ba92281560244d85bc16cb0784c78c36aa9042828e0e96f3cc68a65
|
7
|
+
data.tar.gz: 975a0f7bceb6feb52fdc203b1cac0714535a286565c9dba313de83a6c7bc945a41b2aa7f22151292a31e8a2f8e9c96b6d777d1a5d53bf52b0e2460565eea1c8c
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2020 Josh Brody
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,192 @@
|
|
1
|
+
# Moist
|
2
|
+
|
3
|
+
Drip engine, for Ruby on Rails.
|
4
|
+
|
5
|
+
## Why the name?
|
6
|
+
|
7
|
+
Because `drip` was taken and `moist` was available.
|
8
|
+
|
9
|
+
There is also a measurable amount of people who hate the word moist. Which results in a poor adoption strategy. In turn,
|
10
|
+
fewer stars. Conclusion: recruiters won't bother me?
|
11
|
+
|
12
|
+
Goals?
|
13
|
+
|
14
|
+
## Should you use this?
|
15
|
+
|
16
|
+
Maybe, here's the gist:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
# Create a campaign:
|
20
|
+
::Moist::Campaign.create(name: "Abandoned cart", slug: "cart")
|
21
|
+
|
22
|
+
# Add `moist` to mailer:
|
23
|
+
class AbandonedCartMailer < ActionMailer::Base
|
24
|
+
moist :you_forgot_something, campaign: :cart, step: 1, delay: 1.hour
|
25
|
+
def you_forgot_something(cart)
|
26
|
+
# ...
|
27
|
+
end
|
28
|
+
|
29
|
+
moist :selling_out_soon, campaign: :cart, step: 2, delay: 4.hours
|
30
|
+
def selling_out_soon(cart)
|
31
|
+
# ...
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Add to a drip campaign:
|
36
|
+
::Moist::Campaign.subscribe(cart, user: cart.user).to(:cart)
|
37
|
+
|
38
|
+
# Remove from a drip campaign:
|
39
|
+
::Moist::Campaign.unsubscribe(cart, user: cart.user).from(:cart)
|
40
|
+
```
|
41
|
+
|
42
|
+
## Installation
|
43
|
+
Add this line to your application's Gemfile:
|
44
|
+
|
45
|
+
```ruby
|
46
|
+
gem 'moist'
|
47
|
+
```
|
48
|
+
|
49
|
+
And then execute:
|
50
|
+
```bash
|
51
|
+
$ bundle
|
52
|
+
```
|
53
|
+
|
54
|
+
Bring in the initializer and migrations:
|
55
|
+
|
56
|
+
```bash
|
57
|
+
$ rails g moist:setup
|
58
|
+
```
|
59
|
+
|
60
|
+
And do the migrate:
|
61
|
+
|
62
|
+
```bash
|
63
|
+
$ rails db:migrate
|
64
|
+
```
|
65
|
+
|
66
|
+
## Setup Moist
|
67
|
+
|
68
|
+
How to use `Moist` to create an abandoned cart drip campaign.
|
69
|
+
|
70
|
+
#### 1. First, create a `Moist::Campaign`.
|
71
|
+
|
72
|
+
`Moist::Campaign.create(name: "Abandoned cart`, slug: "cart")`
|
73
|
+
|
74
|
+
The `slug` used here will be referenced later. Note it.
|
75
|
+
|
76
|
+
#### 2. Use `moist` in your mailer to create steps.
|
77
|
+
|
78
|
+
You'll need to define `@moist_subscriber` and `@moist_user` in each mailer. This tells Moist what `Moist::Mailing` to
|
79
|
+
associate with the mailer.
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
class AbandonedCartMailer < ActionMailer::Base
|
83
|
+
moist :you_forgot_something, campaign: :cart, step: 1, delay: 1.hour
|
84
|
+
def you_forgot_something(cart)
|
85
|
+
@cart = cart
|
86
|
+
@moist_subscriber = cart
|
87
|
+
@moist_user = cart.user
|
88
|
+
mail(subject: "You forgot something...", to: @cart.user.email)
|
89
|
+
end
|
90
|
+
|
91
|
+
moist :free_shipping_if_you_order_now, campaign: :cart, step: 2, delay: 2.days
|
92
|
+
def free_shipping_if_you_order_now(cart)
|
93
|
+
@cart = cart
|
94
|
+
@moist_subscriber = cart
|
95
|
+
@moist_user = cart.user
|
96
|
+
mail(subject: "Free shipping? Yep. Complete your purchase right meow!", to: @cart.user.email)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
In these mailer methods, make sure you assign a `@moist_subscriber` and a `@moist_user`. If you don't, bad things will happen.
|
102
|
+
|
103
|
+
#### 3. Add `@cart` to the campaign
|
104
|
+
|
105
|
+
Probably a background job.
|
106
|
+
|
107
|
+
```
|
108
|
+
class AbandonedCartJob < ApplicationJob
|
109
|
+
TIME_TO_LIVE = 1.hour
|
110
|
+
def perform
|
111
|
+
Cart.where(checked_out: false).where('updated_at < ?', TIME_TO_LIVE.ago).each do |cart|
|
112
|
+
::Moist::Campaign.subscribe(cart, user: cart.user).to(:cart)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
#### 4. Run the scheduler
|
119
|
+
|
120
|
+
This manages the mailers and the drips.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class MoistSchedulerJob < ApplicationJob
|
124
|
+
def perform
|
125
|
+
::Moist::Scheduler.run
|
126
|
+
end
|
127
|
+
end
|
128
|
+
```
|
129
|
+
|
130
|
+
#### 5. Done!
|
131
|
+
|
132
|
+
All done. Some nice-to-haves:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
class Cart
|
136
|
+
has_moist_campaigns
|
137
|
+
end
|
138
|
+
```
|
139
|
+
|
140
|
+
Gives you
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
@cart.moist_campaigns
|
144
|
+
```
|
145
|
+
|
146
|
+
```
|
147
|
+
class User
|
148
|
+
acts_as_moist_user
|
149
|
+
end
|
150
|
+
```
|
151
|
+
|
152
|
+
Gives you:
|
153
|
+
|
154
|
+
```ruby
|
155
|
+
@user.moist_subscriptions
|
156
|
+
```
|
157
|
+
|
158
|
+
## Data models
|
159
|
+
|
160
|
+
Three core concepts:
|
161
|
+
|
162
|
+
### Campaign
|
163
|
+
|
164
|
+
This is just an object for reference.
|
165
|
+
|
166
|
+
### CampaignSubscriber
|
167
|
+
|
168
|
+
This joins a `Campaign` to a `subscriber` (polymorphic anything), and a `user`. In theory, a `user` can have many `moist_campaign_subscribers`,
|
169
|
+
which would relate it to objects that belong to a `user`.
|
170
|
+
|
171
|
+
This is useful if you have an `Order` object and a `Subscription` object that you want to create different campaigns for.
|
172
|
+
|
173
|
+
### Mailing
|
174
|
+
|
175
|
+
Takes your ActionMailer `moist` calls and turns them into database records. They have `send_at` and `sent_at` columns,
|
176
|
+
in addition to information about the mailers they belong to.
|
177
|
+
|
178
|
+
## Contributing
|
179
|
+
|
180
|
+
Just do it.
|
181
|
+
|
182
|
+
## Todo
|
183
|
+
|
184
|
+
* Logo, duh
|
185
|
+
* Web UI examples
|
186
|
+
* Ability to pause a campaign?
|
187
|
+
* Conversion stuff
|
188
|
+
* Handle updating steps
|
189
|
+
* Use block for handling `delay`
|
190
|
+
|
191
|
+
## License
|
192
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'Moist'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
18
|
+
load 'rails/tasks/engine.rake'
|
19
|
+
|
20
|
+
load 'rails/tasks/statistics.rake'
|
21
|
+
|
22
|
+
require 'bundler/gem_tasks'
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Moist
|
2
|
+
class Campaign < ApplicationRecord
|
3
|
+
self.table_name = 'moist_campaigns'
|
4
|
+
include ::Moist::Models::Campaign
|
5
|
+
|
6
|
+
has_many :moist_campaign_subscribers, class_name: 'Moist::CampaignSubscriber', foreign_key: :moist_campaign_id
|
7
|
+
|
8
|
+
validates :name, uniqueness: true, presence: true
|
9
|
+
validates :slug, uniqueness: true, presence: true
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Moist
|
2
|
+
class CampaignSubscriber < ApplicationRecord
|
3
|
+
self.table_name = 'moist_campaign_subscribers'
|
4
|
+
|
5
|
+
include ::Moist::Models::CampaignSubscriber
|
6
|
+
|
7
|
+
has_many :moist_receipts, class_name: '::Moist::Receipt'
|
8
|
+
has_many :moist_mailings, class_name: '::Moist::Mailing', foreign_key: :moist_campaign_subscriber_id
|
9
|
+
has_one :next_moist_mailing, -> { unsent.order('send_at asc') }, class_name: '::Moist::Mailing', foreign_key: :moist_campaign_subscriber_id
|
10
|
+
has_one :most_recent_moist_mailing, -> { sent.order('sent_at desc') }, class_name: '::Moist::Mailing', foreign_key: :moist_campaign_subscriber_id
|
11
|
+
belongs_to :moist_campaign, class_name: '::Moist::Campaign'
|
12
|
+
belongs_to :subscriber, polymorphic: true
|
13
|
+
belongs_to :user
|
14
|
+
|
15
|
+
after_create_commit :create_mailings!
|
16
|
+
|
17
|
+
def create_mailings!
|
18
|
+
moist_campaign.steps.each do |step|
|
19
|
+
::Moist::Mailing.create!(moist_campaign_subscriber: self, send_at: step.options[:delay].from_now, mailer_class: step.mailer_class, mailer_action: step.mailer_action)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Moist
|
2
|
+
class Mailing < ApplicationRecord
|
3
|
+
belongs_to :moist_campaign_subscriber, class_name: '::Moist::CampaignSubscriber'
|
4
|
+
|
5
|
+
scope :unsent, -> { where(sent_at: nil) }
|
6
|
+
scope :sent, -> { where.not(sent_at: nil) }
|
7
|
+
scope :upcoming, -> { where('send_at > ?', Time.current) }
|
8
|
+
|
9
|
+
include ::Moist::Models::Mailing
|
10
|
+
end
|
11
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
class CreateMoistMoistCampaigns < ActiveRecord::Migration[6.0]
|
2
|
+
def change
|
3
|
+
create_table :moist_campaigns do |t|
|
4
|
+
t.string :name, null: false
|
5
|
+
t.string :slug, null: false
|
6
|
+
t.boolean :enabled, null: false, default: true
|
7
|
+
|
8
|
+
t.timestamps
|
9
|
+
end
|
10
|
+
|
11
|
+
add_index :moist_campaigns, :slug, unique: true
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class CreateMoistMoistCampaignSubscribers < ActiveRecord::Migration[6.0]
|
2
|
+
def change
|
3
|
+
create_table :moist_campaign_subscribers do |t|
|
4
|
+
t.references :moist_campaign, null: false, foreign_key: true
|
5
|
+
t.string :subscriber_type, null: false
|
6
|
+
t.integer :subscriber_id, null: false
|
7
|
+
t.integer :user_id, null: false
|
8
|
+
|
9
|
+
t.timestamps
|
10
|
+
end
|
11
|
+
add_index :moist_campaign_subscribers, [:moist_campaign_id, :subscriber_id, :subscriber_type, :user_id], name: "index_moist_campaign_subscribers"
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class CreateMoistMailings < ActiveRecord::Migration[6.0]
|
2
|
+
def change
|
3
|
+
create_table :moist_mailings do |t|
|
4
|
+
t.references :moist_campaign_subscriber, null: false, foreign_key: true
|
5
|
+
t.datetime :send_at, null: false
|
6
|
+
t.datetime :sent_at
|
7
|
+
t.string :mailer_class, null: false
|
8
|
+
t.string :mailer_action, null: false
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class CreateMoistMoistReceipts < ActiveRecord::Migration[6.0]
|
2
|
+
def change
|
3
|
+
create_table :moist_receipts do |t|
|
4
|
+
t.references :moist_campaign_subscriber, null: false, foreign_key: true
|
5
|
+
t.references :moist_campaign_mailing, null: false, foreign_key: true
|
6
|
+
t.integer :step, null: false
|
7
|
+
|
8
|
+
t.timestamps
|
9
|
+
end
|
10
|
+
add_index :moist_receipts, [:moist_campaign_subscriber, :moist_campaign_mailing, :step]
|
11
|
+
end
|
12
|
+
end
|
data/lib/moist.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'rails/all'
|
2
|
+
require 'moist/errors'
|
3
|
+
require 'moist/engine'
|
4
|
+
require 'moist/mailer_registry'
|
5
|
+
require 'moist/subscription_manager'
|
6
|
+
require 'moist/registry_proxy'
|
7
|
+
require 'moist/steps/registry'
|
8
|
+
require 'moist/steps/collection'
|
9
|
+
require 'moist/action_mailer/extension'
|
10
|
+
require 'moist/active_record/extension'
|
11
|
+
require 'moist/action_mailer/observer'
|
12
|
+
require 'moist/models/campaign'
|
13
|
+
require 'moist/models/campaign_subscriber'
|
14
|
+
require 'moist/models/mailing'
|
15
|
+
require 'moist/delivery'
|
16
|
+
|
17
|
+
module Moist
|
18
|
+
def self.step_registry
|
19
|
+
@step_registry ||= Steps::Registry.new
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.mailer_registry
|
23
|
+
@mailer_registry ||= MailerRegistry.new
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Moist
|
2
|
+
module ActionMailer
|
3
|
+
module Extension
|
4
|
+
MOIST_VARS = [:@moist_user, :@moist_subscriber]
|
5
|
+
def self.included(klass)
|
6
|
+
klass.extend ClassMethods
|
7
|
+
klass.after_action :attach_metadata
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def attach_metadata
|
13
|
+
mailer_class = self.class.to_s
|
14
|
+
mailer_action = self.action_name
|
15
|
+
self.message.instance_variable_set(:@mailer_class, mailer_class)
|
16
|
+
self.message.instance_variable_set(:@mailer_action, mailer_action)
|
17
|
+
self.message.class.send(:attr_reader, :mailer_class)
|
18
|
+
self.message.class.send(:attr_reader, :mailer_action)
|
19
|
+
|
20
|
+
MOIST_VARS.each do |var|
|
21
|
+
if instance_variable_defined?(var)
|
22
|
+
self.message.instance_variable_set(var, instance_variable_get(var))
|
23
|
+
self.message.class.send(:attr_reader, var.to_s[1..].to_sym)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module ClassMethods
|
29
|
+
def moist(mailer_action, campaign:, step:, delay:)
|
30
|
+
::Moist::RegistryProxy.register(campaign, self.name, mailer_action, step: step, delay: delay)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Moist
|
2
|
+
module ActionMailer
|
3
|
+
class Observer
|
4
|
+
def self.delivered_email(message)
|
5
|
+
moist_enabled = ::Moist.mailer_registry.enabled?(message.mailer_class, message.mailer_action)
|
6
|
+
return unless moist_enabled
|
7
|
+
|
8
|
+
moist = [message.moist_user, message.moist_subscriber]
|
9
|
+
if moist.any?(&:nil?) && moist.any?
|
10
|
+
Rails.logger.warn("Moist was skipped, but at least one Moist variable was set.")
|
11
|
+
Rails.logger.warn("@moist_user: #{message.moist_user.inspect}\n@moist_subscriber:#{message.moist_subscriber}")
|
12
|
+
return
|
13
|
+
end
|
14
|
+
|
15
|
+
subscriber = ::Moist::CampaignSubscriber.find_by(user: message.moist_user, subscriber: message.moist_subscriber)
|
16
|
+
mailing = ::Moist::Mailing.find_by(mailer_class: message.mailer_class, mailer_action: message.mailer_action, moist_campaign_subscriber_id: subscriber)
|
17
|
+
mailing.update_column(:sent_at, Time.current)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Moist
|
2
|
+
module ActiveRecord
|
3
|
+
module Extension
|
4
|
+
def self.included(klass)
|
5
|
+
klass.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module Campaigns
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
|
11
|
+
included do
|
12
|
+
has_many :moist_campaign_subscribers, class_name: '::Moist::CampaignSubscriber', as: :subscriber
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module Users
|
17
|
+
extend ActiveSupport::Concern
|
18
|
+
|
19
|
+
included do
|
20
|
+
has_many :moist_subscriptions, class_name: '::Moist::CampaignSubscriber'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module ClassMethods
|
25
|
+
def has_moist_campaigns
|
26
|
+
include ::Moist::ActiveRecord::Extension::Campaigns
|
27
|
+
end
|
28
|
+
|
29
|
+
def acts_as_moist_user
|
30
|
+
include ::Moist::ActiveRecord::Extension::Users
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Moist
|
2
|
+
class Delivery
|
3
|
+
def self.call(mailing)
|
4
|
+
new(mailing).call
|
5
|
+
end
|
6
|
+
|
7
|
+
def initialize(mailing)
|
8
|
+
@mailing = mailing
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
@mailing.mailer_class.constantize.send(@mailing.mailer_action, @mailing.moist_campaign_subscriber.subscriber).deliver!
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/moist/engine.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
module Moist
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace Moist
|
4
|
+
|
5
|
+
ActiveSupport.on_load(:action_mailer) do
|
6
|
+
include ::Moist::ActionMailer::Extension
|
7
|
+
::ActionMailer::Base.register_observer(::Moist::ActionMailer::Observer)
|
8
|
+
end
|
9
|
+
|
10
|
+
ActiveSupport.on_load(:active_record) do
|
11
|
+
include ::Moist::ActiveRecord::Extension
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/moist/errors.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
module Moist
|
2
|
+
class MailerRegistry
|
3
|
+
def initialize
|
4
|
+
@table = Hash.new
|
5
|
+
end
|
6
|
+
|
7
|
+
def register(mailer, action)
|
8
|
+
@table[mailer] ||= Set.new
|
9
|
+
@table[mailer] << action.to_s
|
10
|
+
end
|
11
|
+
|
12
|
+
def [](val)
|
13
|
+
@table[val]
|
14
|
+
end
|
15
|
+
|
16
|
+
def enabled?(klass, action)
|
17
|
+
@table[klass] && @table[klass].include?(action)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Moist
|
2
|
+
module Models
|
3
|
+
module Campaign
|
4
|
+
def self.included(klass)
|
5
|
+
klass.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
def steps
|
9
|
+
Steps::Collection.for(self)
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_slug
|
13
|
+
slug.to_sym
|
14
|
+
end
|
15
|
+
|
16
|
+
def subscribed?(subscriber, **args)
|
17
|
+
moist_campaign_subscribers.exists?(subscriber: subscriber, **args)
|
18
|
+
end
|
19
|
+
|
20
|
+
module ClassMethods
|
21
|
+
def [](val)
|
22
|
+
campaign = ::Moist::Campaign.find_by(slug: val)
|
23
|
+
raise ::Moist::UnknownCampaign, "Unknown campaign slug #{val}" if campaign.nil?
|
24
|
+
|
25
|
+
campaign
|
26
|
+
end
|
27
|
+
|
28
|
+
def subscribe(subscriber, user:)
|
29
|
+
SubscriptionManager.new(subscriber, user: user, action: :subscribe)
|
30
|
+
end
|
31
|
+
|
32
|
+
def unsubscribe(subscriber, user:)
|
33
|
+
SubscriptionManager.new(subscriber, user: user, action: :unsubscribe)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Moist
|
2
|
+
module Models
|
3
|
+
module CampaignSubscriber
|
4
|
+
def self.included(klass)
|
5
|
+
# klass.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
def current_step
|
9
|
+
most_recent_moist_mailing
|
10
|
+
end
|
11
|
+
|
12
|
+
def next_step
|
13
|
+
next_moist_mailing || raise(::Moist::CampaignComplete)
|
14
|
+
end
|
15
|
+
|
16
|
+
def next_mailing
|
17
|
+
return nil unless next_step
|
18
|
+
|
19
|
+
moist_mailings.find_by(mailer_class: next_step.mailer_class, mailer_action: next_step.mailer_action)
|
20
|
+
end
|
21
|
+
|
22
|
+
def next_step?
|
23
|
+
next_moist_mailing.present?
|
24
|
+
end
|
25
|
+
|
26
|
+
def ready_for_next_mailing?
|
27
|
+
return false unless next_mailing
|
28
|
+
|
29
|
+
next_mailing.send_at < Time.current
|
30
|
+
end
|
31
|
+
|
32
|
+
def mail!
|
33
|
+
next_mailing.deliver!
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Moist
|
2
|
+
module Models
|
3
|
+
module Mailing
|
4
|
+
def deliver?
|
5
|
+
raise ::Moist::MailingAlreadyDelivered if sent?
|
6
|
+
|
7
|
+
send_at < Time.current
|
8
|
+
end
|
9
|
+
|
10
|
+
def sent?
|
11
|
+
sent_at.present?
|
12
|
+
end
|
13
|
+
|
14
|
+
def deliver!
|
15
|
+
::Moist::Delivery.call(self)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
module Moist
|
2
|
+
class RegistryProxy
|
3
|
+
def self.register(campaign_slug, mailer_class, mailer_action, options = {})
|
4
|
+
::Moist.step_registry.register(campaign_slug, mailer_class, mailer_action, options)
|
5
|
+
::Moist.mailer_registry.register(mailer_class, mailer_action)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Moist
|
2
|
+
class Scheduler
|
3
|
+
def self.run
|
4
|
+
new.run
|
5
|
+
end
|
6
|
+
|
7
|
+
def run
|
8
|
+
::Moist::Campaign.where(enabled: true).each do |campaign|
|
9
|
+
campaign.subscribers.each do |subscriber|
|
10
|
+
tick!(subscriber)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def tick!(subscriber)
|
18
|
+
if subscriber.ready_for_next_mail?
|
19
|
+
subscriber.send_next_mail!
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Moist
|
2
|
+
module Steps
|
3
|
+
class Collection
|
4
|
+
include Enumerable
|
5
|
+
|
6
|
+
def self.for(campaign)
|
7
|
+
new(campaign, ::Moist.step_registry[campaign.to_slug])
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :steps
|
11
|
+
def initialize(campaign, steps)
|
12
|
+
@campaign = campaign
|
13
|
+
@steps = sort(steps)
|
14
|
+
end
|
15
|
+
|
16
|
+
def each(&block)
|
17
|
+
@steps.each { |step| block.call(step) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def size
|
21
|
+
@steps.size
|
22
|
+
end
|
23
|
+
|
24
|
+
def [](val)
|
25
|
+
@steps[val]
|
26
|
+
end
|
27
|
+
|
28
|
+
def find(step_number)
|
29
|
+
return nil unless step_number.is_a?(Integer)
|
30
|
+
|
31
|
+
steps.detect { |step| step.options[:step] == step_number } || find_next(step_number)
|
32
|
+
end
|
33
|
+
|
34
|
+
def find_next(step_number)
|
35
|
+
return nil unless step_number.is_a?(Integer)
|
36
|
+
|
37
|
+
steps.detect { |step| step.options[:step] > step_number } || last_step?(step_number)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def last_step?(step_number)
|
43
|
+
raise(::Moist::CampaignComplete) if steps.last.options[:step] == step_number
|
44
|
+
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
|
48
|
+
def sort(steps)
|
49
|
+
steps.sort_by { |step| step.options[:step] }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'moist/steps/step'
|
2
|
+
|
3
|
+
module Moist
|
4
|
+
module Steps
|
5
|
+
class Registry
|
6
|
+
def initialize
|
7
|
+
@table = Hash.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def register(campaign_slug, mailer_class, mailer_action, options = {})
|
11
|
+
@table[campaign_slug] ||= Set.new
|
12
|
+
@table[campaign_slug] << Step.new(mailer_class, mailer_action, options)
|
13
|
+
end
|
14
|
+
|
15
|
+
def [](val)
|
16
|
+
@table[val] || []
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Moist
|
2
|
+
module Steps
|
3
|
+
class Step
|
4
|
+
attr_reader :mailer_class, :mailer_action, :options
|
5
|
+
def initialize(mailer_class, mailer_action, options = {})
|
6
|
+
@mailer_class = mailer_class
|
7
|
+
@mailer_action = mailer_action
|
8
|
+
@options = options
|
9
|
+
end
|
10
|
+
|
11
|
+
def name
|
12
|
+
"#{mailer_class}##{mailer_action}"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Moist
|
2
|
+
class SubscriptionManager
|
3
|
+
attr_reader :subscriber, :user, :action
|
4
|
+
def initialize(subscriber, user:, action:)
|
5
|
+
@subscriber = subscriber
|
6
|
+
@user = user
|
7
|
+
@action = action
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(*campaign_slugs)
|
11
|
+
campaign_slugs.map do |campaign_slug|
|
12
|
+
campaign = ::Moist::Campaign[campaign_slug]
|
13
|
+
if @action == :subscribe
|
14
|
+
::Moist::CampaignSubscriber.first_or_create!(subscriber: subscriber, user: user, moist_campaign: campaign)
|
15
|
+
elsif @action == :unsubscribe
|
16
|
+
sub = ::Moist::CampaignSubscriber.find_by!(subscriber: subscriber, user: user, moist_campaign: campaign)
|
17
|
+
sub.destroy!
|
18
|
+
sub
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
alias to call
|
23
|
+
alias from call
|
24
|
+
end
|
25
|
+
end
|
File without changes
|
metadata
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: moist
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Josh Brody
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-11-17 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.3
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 6.0.3.4
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 6.0.3
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 6.0.3.4
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: sqlite3
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rspec-rails
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: pry
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '0'
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: pry-rails
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
82
|
+
type: :development
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - ">="
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '0'
|
89
|
+
description: Opinionated drip campaign framework.
|
90
|
+
email:
|
91
|
+
- josh@josh.mn
|
92
|
+
executables: []
|
93
|
+
extensions: []
|
94
|
+
extra_rdoc_files: []
|
95
|
+
files:
|
96
|
+
- MIT-LICENSE
|
97
|
+
- README.md
|
98
|
+
- Rakefile
|
99
|
+
- app/controllers/moist/application_controller.rb
|
100
|
+
- app/models/moist/application_record.rb
|
101
|
+
- app/models/moist/campaign.rb
|
102
|
+
- app/models/moist/campaign_subscriber.rb
|
103
|
+
- app/models/moist/mailing.rb
|
104
|
+
- app/models/moist/receipt.rb
|
105
|
+
- app/views/layouts/moist/application.html.erb
|
106
|
+
- config/routes.rb
|
107
|
+
- db/migrate/20201113220645_create_moist_moist_campaigns.rb
|
108
|
+
- db/migrate/20201113220800_create_moist_moist_campaign_subscribers.rb
|
109
|
+
- db/migrate/20201113220943_create_moist_mailings.rb
|
110
|
+
- db/migrate/20201113220944_create_moist_moist_receipts.rb
|
111
|
+
- lib/moist.rb
|
112
|
+
- lib/moist/action_mailer/extension.rb
|
113
|
+
- lib/moist/action_mailer/observer.rb
|
114
|
+
- lib/moist/active_record/extension.rb
|
115
|
+
- lib/moist/delivery.rb
|
116
|
+
- lib/moist/engine.rb
|
117
|
+
- lib/moist/errors.rb
|
118
|
+
- lib/moist/mailer_registry.rb
|
119
|
+
- lib/moist/models/campaign.rb
|
120
|
+
- lib/moist/models/campaign_subscriber.rb
|
121
|
+
- lib/moist/models/mailing.rb
|
122
|
+
- lib/moist/registry_proxy.rb
|
123
|
+
- lib/moist/scheduler.rb
|
124
|
+
- lib/moist/steps/collection.rb
|
125
|
+
- lib/moist/steps/registry.rb
|
126
|
+
- lib/moist/steps/step.rb
|
127
|
+
- lib/moist/subscription_manager.rb
|
128
|
+
- lib/moist/version.rb
|
129
|
+
- lib/tasks/moist_tasks.rake
|
130
|
+
homepage: https://github.com/joshmn/moist
|
131
|
+
licenses:
|
132
|
+
- MIT
|
133
|
+
metadata: {}
|
134
|
+
post_install_message:
|
135
|
+
rdoc_options: []
|
136
|
+
require_paths:
|
137
|
+
- lib
|
138
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
139
|
+
requirements:
|
140
|
+
- - ">="
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: '0'
|
143
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
144
|
+
requirements:
|
145
|
+
- - ">="
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
version: '0'
|
148
|
+
requirements: []
|
149
|
+
rubygems_version: 3.0.3
|
150
|
+
signing_key:
|
151
|
+
specification_version: 4
|
152
|
+
summary: Opinionated drip campaign framework.
|
153
|
+
test_files: []
|