railsdrip-engine 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +4 -0
- data/README.md +80 -0
- data/app/controllers/concerns/rails_drips/admin_access.rb +28 -0
- data/app/controllers/rails_drips/application_controller.rb +7 -0
- data/app/controllers/rails_drips/campaign_archivals_controller.rb +11 -0
- data/app/controllers/rails_drips/campaign_pauses_controller.rb +11 -0
- data/app/controllers/rails_drips/campaign_resumptions_controller.rb +11 -0
- data/app/controllers/rails_drips/campaigns_controller.rb +14 -0
- data/app/controllers/rails_drips/deliveries_controller.rb +14 -0
- data/app/controllers/rails_drips/delivery_cancellations_controller.rb +11 -0
- data/app/controllers/rails_drips/delivery_retries_controller.rb +11 -0
- data/app/controllers/rails_drips/enrollments_controller.rb +11 -0
- data/app/jobs/rails_drips/delivery_job.rb +89 -0
- data/app/models/concerns/rails_drips/json_data.rb +29 -0
- data/app/models/rails_drips/application_record.rb +8 -0
- data/app/models/rails_drips/campaign.rb +62 -0
- data/app/models/rails_drips/campaign_version.rb +35 -0
- data/app/models/rails_drips/delivery.rb +90 -0
- data/app/models/rails_drips/enrollment.rb +77 -0
- data/app/models/rails_drips/enrollment_event.rb +28 -0
- data/app/models/rails_drips/step.rb +53 -0
- data/app/services/rails_drips/activate_campaign.rb +10 -0
- data/app/services/rails_drips/archive_campaign.rb +22 -0
- data/app/services/rails_drips/cancel_delivery.rb +17 -0
- data/app/services/rails_drips/compare_definition.rb +28 -0
- data/app/services/rails_drips/complete_enrollment.rb +18 -0
- data/app/services/rails_drips/enqueue_due_deliveries.rb +16 -0
- data/app/services/rails_drips/enroll.rb +72 -0
- data/app/services/rails_drips/import_definition.rb +68 -0
- data/app/services/rails_drips/integrity_audit.rb +43 -0
- data/app/services/rails_drips/mail_snapshot.rb +31 -0
- data/app/services/rails_drips/pause_campaign.rb +16 -0
- data/app/services/rails_drips/policy_decision.rb +21 -0
- data/app/services/rails_drips/record_event.rb +14 -0
- data/app/services/rails_drips/remove.rb +27 -0
- data/app/services/rails_drips/resume_campaign.rb +17 -0
- data/app/services/rails_drips/retry_delivery.rb +21 -0
- data/app/services/rails_drips/schedule_deliveries.rb +23 -0
- data/app/services/rails_drips/unarchive_campaign.rb +14 -0
- data/app/views/layouts/rails_drips/application.html.erb +5 -0
- data/app/views/rails_drips/campaigns/index.html.erb +7 -0
- data/app/views/rails_drips/campaigns/show.html.erb +7 -0
- data/app/views/rails_drips/deliveries/show.html.erb +9 -0
- data/app/views/rails_drips/enrollments/show.html.erb +6 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260829123000_create_rails_drips_tables.rb +142 -0
- data/docs/README.md +13 -0
- data/docs/architecture.md +95 -0
- data/docs/campaign-definitions.md +100 -0
- data/docs/getting-started.md +170 -0
- data/docs/host-integration.md +160 -0
- data/docs/operations.md +104 -0
- data/lib/generators/rails_drips/install/install_generator.rb +32 -0
- data/lib/generators/rails_drips/install/templates/initializer.rb +8 -0
- data/lib/generators/rails_drips/install/templates/sample.yml +13 -0
- data/lib/rails_drips/configuration.rb +65 -0
- data/lib/rails_drips/definition_loader.rb +42 -0
- data/lib/rails_drips/definition_schema.rb +40 -0
- data/lib/rails_drips/engine.rb +9 -0
- data/lib/rails_drips/errors.rb +17 -0
- data/lib/rails_drips/instrumentation.rb +11 -0
- data/lib/rails_drips/mail_registry.rb +62 -0
- data/lib/rails_drips/queue_inventory.rb +10 -0
- data/lib/rails_drips/result.rb +9 -0
- data/lib/rails_drips/version.rb +5 -0
- data/lib/rails_drips.rb +65 -0
- data/lib/tasks/rails_drips_tasks.rake +28 -0
- metadata +179 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '0087d769a2177893bb95fe903b455d38e619e33d7274ac3d271c8f2c6204af3f'
|
|
4
|
+
data.tar.gz: 461a3ad1a89b7b0f75b599c15abaae649aa1d0a3fc3910b65b3e6029b1db946c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e8c44a70be5cde255f649f8732696c42f16c906634857b50f54b887f01900e1c3d77a8de9c73d4fff358e12f57350d27d4d32f490f8734ea86dcc25d018fa3c4
|
|
7
|
+
data.tar.gz: 9eb078a5628ea4d649427fd153b9ae29c76fbc58130d926d00fe5a0fbd5afac0d1ea1c3dd50c06f9e4408e1a4a93cba72d782b00f36df5e30cc456e35e922c40
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# RailsDrips
|
|
2
|
+
|
|
3
|
+
RailsDrips is a Rails engine for versioned, auditable email drip campaigns. It stores campaign definitions, enrolls any Active Record model as a recipient, schedules steps through Active Job, and delivers mail through the host application's Action Mailer classes.
|
|
4
|
+
|
|
5
|
+
RailsDrips owns the workflow. Your application owns the product decisions and email content.
|
|
6
|
+
|
|
7
|
+
| RailsDrips provides | Your application provides |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Campaigns, immutable versions, and ordered steps | Action Mailer classes and templates |
|
|
10
|
+
| Enrollment, scheduling, retries, and cancellation | The business event that enrolls or removes a recipient |
|
|
11
|
+
| Active Job delivery orchestration | A production Active Job adapter and worker process |
|
|
12
|
+
| Delivery snapshots and an append-only event history | Optional eligibility, preference, and transport hooks |
|
|
13
|
+
| YAML definitions and import tooling | Admin authentication and authorization if the UI is mounted |
|
|
14
|
+
|
|
15
|
+
RailsDrips supports Rails 7.0 through Rails 8.x. Its public API is still pre-release.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Add the gem, install its files, and migrate:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "railsdrip-engine"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle install
|
|
28
|
+
bin/rails generate rails_drips:install
|
|
29
|
+
bin/rails db:migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Register a host mailer in `config/initializers/rails_drips.rb`:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
RailsDrips.configure do |config|
|
|
36
|
+
config.delivery_queue_name = :mailers
|
|
37
|
+
|
|
38
|
+
config.mailers.register "welcome", label: "Welcome" do |delivery|
|
|
39
|
+
OnboardingMailer.welcome(delivery)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Define `OnboardingMailer#welcome`, edit the generated campaign YAML, import and activate it, then enroll a recipient:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bin/rails rails_drips:validate DEFINITION=config/rails_drips/onboarding.yml
|
|
48
|
+
bin/rails rails_drips:import DEFINITION=config/rails_drips/onboarding.yml APPLY=true
|
|
49
|
+
bin/rails rails_drips:activate CAMPAIGN=onboarding VERSION=1
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
RailsDrips.enroll(
|
|
54
|
+
campaign: "onboarding",
|
|
55
|
+
recipient: account,
|
|
56
|
+
source: "account_created"
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That call creates the enrollment, event history, and scheduled deliveries in one transaction. Delivery jobs are enqueued only after the transaction commits.
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
- [Getting started](docs/getting-started.md) — complete host setup and first campaign
|
|
65
|
+
- [Host integration](docs/host-integration.md) — every code path a host can or must implement
|
|
66
|
+
- [Architecture](docs/architecture.md) — ownership boundaries, records, and runtime flow
|
|
67
|
+
- [Campaign definitions](docs/campaign-definitions.md) — YAML format, versioning, and import behavior
|
|
68
|
+
- [Operations](docs/operations.md) — admin access, audits, failures, and recovery
|
|
69
|
+
|
|
70
|
+
The current admin pages are an operational prototype, not the finished UI. The [admin UI and dummy-app TODO](docs/todo/admin-ui-and-dummy-app.md) defines the next design and demonstration iteration.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bundle install
|
|
76
|
+
bundle exec rspec
|
|
77
|
+
bundle exec rake build
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Extraction research and implementation plans live under `docs/research/extracting-drip-engine/` and `plans/`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
module AdminAccess
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_action :require_admin_access
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def require_admin_access
|
|
14
|
+
config = RailsDrips.configuration
|
|
15
|
+
return head(:not_found) unless config.admin_enabled && config.admin_authenticator && config.admin_authorizer
|
|
16
|
+
return head(:unauthorized) unless config.admin_authenticator.call(controller: self)
|
|
17
|
+
return head(:forbidden) unless config.admin_authorizer.call(controller: self, action: action_name, record: nil)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current_rails_drips_actor
|
|
21
|
+
RailsDrips.configuration.current_actor_resolver.call(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def recipient_label(recipient)
|
|
25
|
+
RailsDrips.configuration.recipient_label_resolver.call(recipient)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CampaignArchivalsController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
campaign = Campaign.find(params[:campaign_id])
|
|
7
|
+
ArchiveCampaign.call(campaign: campaign, actor: current_rails_drips_actor, reason: params[:reason])
|
|
8
|
+
redirect_to campaign_path(campaign)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CampaignPausesController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
campaign = Campaign.find(params[:campaign_id])
|
|
7
|
+
PauseCampaign.call(campaign: campaign, actor: current_rails_drips_actor)
|
|
8
|
+
redirect_to campaign_path(campaign)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CampaignResumptionsController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
campaign = Campaign.find(params[:campaign_id])
|
|
7
|
+
ResumeCampaign.call(campaign: campaign, actor: current_rails_drips_actor)
|
|
8
|
+
redirect_to campaign_path(campaign)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CampaignsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@campaigns = Campaign.order(:key)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@campaign = Campaign.includes(campaign_versions: :steps).find(params[:id])
|
|
11
|
+
@enrollments = @campaign.enrollments.includes(:recipient).recent_first.limit(100)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class DeliveriesController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
@delivery = Delivery.includes(enrollment: :recipient).find(params[:id])
|
|
7
|
+
@show_snapshot = params[:snapshot] == "1"
|
|
8
|
+
return unless @show_snapshot
|
|
9
|
+
|
|
10
|
+
allowed = RailsDrips.configuration.snapshot_authorizer.call(controller: self, delivery: @delivery)
|
|
11
|
+
head(:forbidden) unless allowed
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class DeliveryCancellationsController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
delivery = Delivery.find(params[:delivery_id])
|
|
7
|
+
CancelDelivery.call(delivery: delivery, actor: current_rails_drips_actor, reason: params[:reason].presence || "operator")
|
|
8
|
+
redirect_to delivery_path(delivery)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class DeliveryRetriesController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
delivery = Delivery.find(params[:delivery_id])
|
|
7
|
+
RetryDelivery.call(delivery: delivery, actor: current_rails_drips_actor)
|
|
8
|
+
redirect_to delivery_path(delivery)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class EnrollmentsController < ApplicationController
|
|
5
|
+
helper_method :recipient_label
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@enrollment = Enrollment.includes(:recipient, :campaign, :campaign_version, :enrollment_events, :deliveries).find(params[:id])
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class DeliveryJob < ActiveJob::Base
|
|
5
|
+
queue_as { RailsDrips.configuration.delivery_queue_name }
|
|
6
|
+
|
|
7
|
+
discard_on ActiveRecord::RecordNotFound
|
|
8
|
+
|
|
9
|
+
def perform(delivery_id)
|
|
10
|
+
delivery = Delivery.includes(enrollment: :campaign).find(delivery_id)
|
|
11
|
+
return unless prepare?(delivery)
|
|
12
|
+
|
|
13
|
+
message = RailsDrips.configuration.mailers.fetch(delivery.email_key).call(delivery).message
|
|
14
|
+
message = RailsDrips.configuration.transport_metadata_hook.call(message: message, delivery: delivery)
|
|
15
|
+
snapshot = MailSnapshot.call(message)
|
|
16
|
+
|
|
17
|
+
delivery.with_lock do
|
|
18
|
+
return unless delivery.scheduled?
|
|
19
|
+
delivery.update!(
|
|
20
|
+
subject_snapshot: snapshot[:subject],
|
|
21
|
+
html_body_snapshot: snapshot[:html_body],
|
|
22
|
+
text_body_snapshot: snapshot[:text_body],
|
|
23
|
+
data: delivery.data_value.merge("message_sha256" => snapshot[:sha256], "message_byte_size" => snapshot[:byte_size])
|
|
24
|
+
)
|
|
25
|
+
delivery.mark_sending!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
message.deliver
|
|
29
|
+
|
|
30
|
+
delivery.with_lock do
|
|
31
|
+
delivery.mark_sent!(provider: "action_mailer", provider_message_id: message.message_id)
|
|
32
|
+
RecordEvent.call(enrollment: delivery.enrollment, event_type: "delivery_sent",
|
|
33
|
+
data: { "delivery_id" => delivery.id, "step_id" => delivery.step_id })
|
|
34
|
+
end
|
|
35
|
+
CompleteEnrollment.call(enrollment: delivery.enrollment)
|
|
36
|
+
Instrumentation.instrument("delivery.sent", delivery_id: delivery.id,
|
|
37
|
+
enrollment_id: delivery.enrollment_id, campaign_key: delivery.campaign_key)
|
|
38
|
+
rescue ActiveRecord::RecordNotFound
|
|
39
|
+
nil
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
record_failure(delivery, error) if delivery
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def prepare?(delivery)
|
|
48
|
+
delivery.with_lock do
|
|
49
|
+
return false unless delivery.scheduled?
|
|
50
|
+
enrollment = delivery.enrollment
|
|
51
|
+
campaign = enrollment.campaign
|
|
52
|
+
return false if campaign.paused?
|
|
53
|
+
return cancel(delivery, "campaign_inactive") unless campaign.active?
|
|
54
|
+
return cancel(delivery, "enrollment_inactive") unless enrollment.active?
|
|
55
|
+
return true if enrollment.forced?
|
|
56
|
+
|
|
57
|
+
preference = RailsDrips.configuration.preference_evaluator.call(
|
|
58
|
+
recipient: enrollment.recipient, category: delivery.email_category, delivery: delivery
|
|
59
|
+
)
|
|
60
|
+
return cancel(delivery, "preference_rejected") unless PolicyDecision.allowed?(preference)
|
|
61
|
+
|
|
62
|
+
eligibility = RailsDrips.configuration.eligibility_evaluator.call(
|
|
63
|
+
recipient: enrollment.recipient, campaign: campaign, delivery: delivery, phase: :delivery
|
|
64
|
+
)
|
|
65
|
+
return cancel(delivery, "ineligible") unless PolicyDecision.allowed?(eligibility)
|
|
66
|
+
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cancel(delivery, reason)
|
|
72
|
+
delivery.cancel!(reason: reason)
|
|
73
|
+
RecordEvent.call(enrollment: delivery.enrollment, event_type: "delivery_cancelled",
|
|
74
|
+
data: { "delivery_id" => delivery.id, "reason" => reason })
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def record_failure(delivery, error)
|
|
79
|
+
delivery.with_lock do
|
|
80
|
+
return if delivery.sent? || delivery.cancelled?
|
|
81
|
+
delivery.mark_failed!(message: error.message)
|
|
82
|
+
RecordEvent.call(enrollment: delivery.enrollment, event_type: "delivery_failed",
|
|
83
|
+
data: { "delivery_id" => delivery.id, "error_class" => error.class.name })
|
|
84
|
+
end
|
|
85
|
+
Instrumentation.instrument("delivery.failed", delivery_id: delivery.id,
|
|
86
|
+
enrollment_id: delivery.enrollment_id, error_class: error.class.name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
module JsonData
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def normalize_json_value(value)
|
|
9
|
+
case value
|
|
10
|
+
when Hash
|
|
11
|
+
value.each_with_object({}) do |(key, nested_value), normalized|
|
|
12
|
+
normalized[key.to_s] = normalize_json_value(nested_value)
|
|
13
|
+
end
|
|
14
|
+
when Array
|
|
15
|
+
value.map { |item| normalize_json_value(item) }
|
|
16
|
+
when Symbol
|
|
17
|
+
value.to_s
|
|
18
|
+
else
|
|
19
|
+
value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def data_value
|
|
25
|
+
data || {}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Campaign < ApplicationRecord
|
|
5
|
+
include JsonData
|
|
6
|
+
|
|
7
|
+
has_many :campaign_versions,
|
|
8
|
+
class_name: "RailsDrips::CampaignVersion",
|
|
9
|
+
dependent: :destroy,
|
|
10
|
+
inverse_of: :campaign
|
|
11
|
+
has_many :steps, through: :campaign_versions
|
|
12
|
+
has_many :enrollments,
|
|
13
|
+
class_name: "RailsDrips::Enrollment",
|
|
14
|
+
dependent: :restrict_with_error,
|
|
15
|
+
inverse_of: :campaign
|
|
16
|
+
belongs_to :current_version,
|
|
17
|
+
class_name: "RailsDrips::CampaignVersion",
|
|
18
|
+
optional: true
|
|
19
|
+
|
|
20
|
+
enum :status, { draft: 0, active: 1, archived: 2, paused: 3 }, prefix: true
|
|
21
|
+
|
|
22
|
+
validates :key, presence: true, uniqueness: true
|
|
23
|
+
validates :name, presence: true
|
|
24
|
+
validates :status, presence: true
|
|
25
|
+
validate :current_version_belongs_to_campaign
|
|
26
|
+
|
|
27
|
+
def draft? = status_draft?
|
|
28
|
+
def active? = status_active?
|
|
29
|
+
def archived? = status_archived?
|
|
30
|
+
def paused? = status_paused?
|
|
31
|
+
|
|
32
|
+
def active_version
|
|
33
|
+
current_version if active? && current_version&.active?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def active_for_enrollment?
|
|
37
|
+
active_version.present?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def activate_version!(version, actor: nil)
|
|
41
|
+
raise ArgumentError, "Version does not belong to this campaign" unless version.campaign == self
|
|
42
|
+
raise ArgumentError, "Archived campaigns must be unarchived before activation" if archived?
|
|
43
|
+
raise ArgumentError, "Only draft versions can be activated" unless version.draft?
|
|
44
|
+
|
|
45
|
+
with_lock do
|
|
46
|
+
campaign_versions.status_active.where.not(id: version.id).find_each(&:retire!)
|
|
47
|
+
version.created_by ||= actor if actor
|
|
48
|
+
version.update!(status: :active, activated_at: Time.current)
|
|
49
|
+
update!(status: :active, current_version: version)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def current_version_belongs_to_campaign
|
|
56
|
+
return if current_version.blank? || current_version.campaign_id == id
|
|
57
|
+
|
|
58
|
+
errors.add(:current_version, "must belong to the campaign")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class CampaignVersion < ApplicationRecord
|
|
5
|
+
belongs_to :campaign,
|
|
6
|
+
class_name: "RailsDrips::Campaign",
|
|
7
|
+
inverse_of: :campaign_versions
|
|
8
|
+
belongs_to :created_by, polymorphic: true, optional: true
|
|
9
|
+
has_many :steps,
|
|
10
|
+
class_name: "RailsDrips::Step",
|
|
11
|
+
dependent: :destroy,
|
|
12
|
+
inverse_of: :campaign_version
|
|
13
|
+
has_many :enrollments,
|
|
14
|
+
class_name: "RailsDrips::Enrollment",
|
|
15
|
+
dependent: :restrict_with_error,
|
|
16
|
+
inverse_of: :campaign_version
|
|
17
|
+
|
|
18
|
+
enum :status, { draft: 0, active: 1, retired: 2 }, prefix: true
|
|
19
|
+
|
|
20
|
+
validates :version_number,
|
|
21
|
+
presence: true,
|
|
22
|
+
numericality: { only_integer: true, greater_than: 0 },
|
|
23
|
+
uniqueness: { scope: :campaign_id }
|
|
24
|
+
validates :status, presence: true
|
|
25
|
+
|
|
26
|
+
def draft? = status_draft?
|
|
27
|
+
def active? = status_active?
|
|
28
|
+
def retired? = status_retired?
|
|
29
|
+
|
|
30
|
+
def retire!
|
|
31
|
+
update!(status: :retired)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Delivery < ApplicationRecord
|
|
5
|
+
include JsonData
|
|
6
|
+
|
|
7
|
+
belongs_to :enrollment,
|
|
8
|
+
class_name: "RailsDrips::Enrollment",
|
|
9
|
+
inverse_of: :deliveries
|
|
10
|
+
belongs_to :step,
|
|
11
|
+
class_name: "RailsDrips::Step",
|
|
12
|
+
inverse_of: :deliveries
|
|
13
|
+
belongs_to :recipient, polymorphic: true, optional: true
|
|
14
|
+
|
|
15
|
+
enum :status, { scheduled: 0, sending: 1, sent: 2, failed: 3, cancelled: 4 }, prefix: true
|
|
16
|
+
|
|
17
|
+
validates :recipient_email, :scheduled_for, :campaign_key, :email_key, :status, presence: true
|
|
18
|
+
validate :step_belongs_to_enrollment_version
|
|
19
|
+
validate :preserve_sent_snapshots, on: :update
|
|
20
|
+
|
|
21
|
+
scope :scheduled, -> { where(status: :scheduled) }
|
|
22
|
+
scope :due, -> { scheduled.where("scheduled_for <= ?", Time.current) }
|
|
23
|
+
scope :future, -> { scheduled.where("scheduled_for > ?", Time.current) }
|
|
24
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
25
|
+
|
|
26
|
+
after_create_commit :enqueue_delivery_job
|
|
27
|
+
|
|
28
|
+
def scheduled? = status_scheduled?
|
|
29
|
+
def sending? = status_sending?
|
|
30
|
+
def sent? = status_sent?
|
|
31
|
+
def failed? = status_failed?
|
|
32
|
+
def cancelled? = status_cancelled?
|
|
33
|
+
|
|
34
|
+
def cancel!(reason:)
|
|
35
|
+
update!(
|
|
36
|
+
status: :cancelled,
|
|
37
|
+
cancelled_at: Time.current,
|
|
38
|
+
data: data_value.merge("cancel_reason" => reason.to_s)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mark_sending!
|
|
43
|
+
update!(status: :sending, attempts: attempts.to_i + 1, failed_at: nil, last_error: nil)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def mark_sent!(provider: nil, provider_message_id: nil)
|
|
47
|
+
update!(
|
|
48
|
+
status: :sent,
|
|
49
|
+
sent_at: Time.current,
|
|
50
|
+
provider: provider,
|
|
51
|
+
provider_message_id: provider_message_id
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def mark_failed!(message:)
|
|
56
|
+
update!(
|
|
57
|
+
status: :failed,
|
|
58
|
+
failed_at: Time.current,
|
|
59
|
+
attempts: attempts.to_i + (sending? ? 0 : 1),
|
|
60
|
+
last_error: message.to_s.byteslice(0, 10_000)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def enqueue_delivery_job
|
|
67
|
+
return unless scheduled?
|
|
68
|
+
|
|
69
|
+
RailsDrips.configuration.delivery_job
|
|
70
|
+
.set(queue: RailsDrips.configuration.delivery_queue_name, wait_until: scheduled_for)
|
|
71
|
+
.perform_later(id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def step_belongs_to_enrollment_version
|
|
75
|
+
return if step.blank? || enrollment.blank?
|
|
76
|
+
return if step.campaign_version_id == enrollment.campaign_version_id
|
|
77
|
+
|
|
78
|
+
errors.add(:step, "must belong to the enrollment campaign version")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def preserve_sent_snapshots
|
|
82
|
+
return unless status_in_database == "sent"
|
|
83
|
+
|
|
84
|
+
snapshot_attributes = %w[subject_snapshot html_body_snapshot text_body_snapshot]
|
|
85
|
+
return unless snapshot_attributes.any? { |attribute| will_save_change_to_attribute?(attribute) }
|
|
86
|
+
|
|
87
|
+
errors.add(:base, "Sent email snapshots are immutable")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class Enrollment < ApplicationRecord
|
|
5
|
+
include JsonData
|
|
6
|
+
|
|
7
|
+
belongs_to :campaign,
|
|
8
|
+
class_name: "RailsDrips::Campaign",
|
|
9
|
+
inverse_of: :enrollments
|
|
10
|
+
belongs_to :campaign_version,
|
|
11
|
+
class_name: "RailsDrips::CampaignVersion",
|
|
12
|
+
inverse_of: :enrollments
|
|
13
|
+
belongs_to :recipient, polymorphic: true
|
|
14
|
+
belongs_to :added_by, polymorphic: true, optional: true
|
|
15
|
+
belongs_to :removed_by, polymorphic: true, optional: true
|
|
16
|
+
has_many :deliveries,
|
|
17
|
+
class_name: "RailsDrips::Delivery",
|
|
18
|
+
dependent: :destroy,
|
|
19
|
+
inverse_of: :enrollment
|
|
20
|
+
has_many :enrollment_events,
|
|
21
|
+
class_name: "RailsDrips::EnrollmentEvent",
|
|
22
|
+
dependent: :destroy,
|
|
23
|
+
inverse_of: :enrollment
|
|
24
|
+
|
|
25
|
+
enum :status, { active: 0, paused: 1, removed: 2, completed: 3, failed: 4 }, prefix: true
|
|
26
|
+
|
|
27
|
+
validates :status, presence: true
|
|
28
|
+
validate :campaign_version_belongs_to_campaign
|
|
29
|
+
|
|
30
|
+
scope :open, -> { where(status: [:active, :paused]) }
|
|
31
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
32
|
+
|
|
33
|
+
def active? = status_active?
|
|
34
|
+
def paused? = status_paused?
|
|
35
|
+
def removed? = status_removed?
|
|
36
|
+
def completed? = status_completed?
|
|
37
|
+
def failed? = status_failed?
|
|
38
|
+
|
|
39
|
+
def open?
|
|
40
|
+
active? || paused?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def forced?
|
|
44
|
+
data_value["forced_enrollment"] == true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove!(reason:, actor: nil)
|
|
48
|
+
update!(
|
|
49
|
+
status: :removed,
|
|
50
|
+
exit_reason: reason.to_s,
|
|
51
|
+
removed_by: actor,
|
|
52
|
+
removed_at: Time.current
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def complete!
|
|
57
|
+
update!(status: :completed, completed_at: Time.current)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fail!(message:)
|
|
61
|
+
update!(
|
|
62
|
+
status: :failed,
|
|
63
|
+
failed_at: Time.current,
|
|
64
|
+
data: data_value.merge("error_message" => message.to_s)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def campaign_version_belongs_to_campaign
|
|
71
|
+
return if campaign.blank? || campaign_version.blank?
|
|
72
|
+
return if campaign_version.campaign_id == campaign_id
|
|
73
|
+
|
|
74
|
+
errors.add(:campaign_version, "must belong to the campaign")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDrips
|
|
4
|
+
class EnrollmentEvent < ApplicationRecord
|
|
5
|
+
include JsonData
|
|
6
|
+
|
|
7
|
+
belongs_to :enrollment,
|
|
8
|
+
class_name: "RailsDrips::Enrollment",
|
|
9
|
+
inverse_of: :enrollment_events
|
|
10
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
11
|
+
|
|
12
|
+
validates :event_type, :occurred_at, presence: true
|
|
13
|
+
|
|
14
|
+
scope :chronological, -> { order(:occurred_at, :id) }
|
|
15
|
+
scope :recent_first, -> { order(occurred_at: :desc, id: :desc) }
|
|
16
|
+
|
|
17
|
+
before_update :prevent_mutation
|
|
18
|
+
before_destroy :prevent_mutation
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def prevent_mutation
|
|
23
|
+
errors.add(:base, "Enrollment events are append-only")
|
|
24
|
+
throw(:abort)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|