moist 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +192 -0
  4. data/Rakefile +22 -0
  5. data/app/controllers/moist/application_controller.rb +5 -0
  6. data/app/models/moist/application_record.rb +5 -0
  7. data/app/models/moist/campaign.rb +11 -0
  8. data/app/models/moist/campaign_subscriber.rb +23 -0
  9. data/app/models/moist/mailing.rb +11 -0
  10. data/app/models/moist/receipt.rb +6 -0
  11. data/app/views/layouts/moist/application.html.erb +15 -0
  12. data/config/routes.rb +2 -0
  13. data/db/migrate/20201113220645_create_moist_moist_campaigns.rb +13 -0
  14. data/db/migrate/20201113220800_create_moist_moist_campaign_subscribers.rb +13 -0
  15. data/db/migrate/20201113220943_create_moist_mailings.rb +13 -0
  16. data/db/migrate/20201113220944_create_moist_moist_receipts.rb +12 -0
  17. data/lib/moist.rb +25 -0
  18. data/lib/moist/action_mailer/extension.rb +35 -0
  19. data/lib/moist/action_mailer/observer.rb +21 -0
  20. data/lib/moist/active_record/extension.rb +35 -0
  21. data/lib/moist/delivery.rb +15 -0
  22. data/lib/moist/engine.rb +14 -0
  23. data/lib/moist/errors.rb +5 -0
  24. data/lib/moist/mailer_registry.rb +20 -0
  25. data/lib/moist/models/campaign.rb +38 -0
  26. data/lib/moist/models/campaign_subscriber.rb +37 -0
  27. data/lib/moist/models/mailing.rb +19 -0
  28. data/lib/moist/registry_proxy.rb +8 -0
  29. data/lib/moist/scheduler.rb +23 -0
  30. data/lib/moist/steps/collection.rb +53 -0
  31. data/lib/moist/steps/registry.rb +20 -0
  32. data/lib/moist/steps/step.rb +16 -0
  33. data/lib/moist/subscription_manager.rb +25 -0
  34. data/lib/moist/version.rb +3 -0
  35. data/lib/tasks/moist_tasks.rake +0 -0
  36. metadata +153 -0
@@ -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
@@ -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.
@@ -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).
@@ -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,5 @@
1
+ module Moist
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Moist
2
+ class ApplicationRecord < ::ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -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
@@ -0,0 +1,6 @@
1
+ module Moist
2
+ class Receipt < ApplicationRecord
3
+ self.table_name = 'moist_receipts'
4
+ belongs_to :subscriber, class_name: '::Moist::CampaignSubscriber'
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Moist</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "moist/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
@@ -0,0 +1,2 @@
1
+ Moist::Engine.routes.draw do
2
+ end
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ module Moist
2
+ class CampaignComplete < StandardError; end
3
+ class MailingAlreadyDelivered < StandardError; end
4
+ class UnknownCampaign < StandardError; end
5
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Moist
2
+ VERSION = '0.1.0'
3
+ 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: []