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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +4 -0
  4. data/README.md +80 -0
  5. data/app/controllers/concerns/rails_drips/admin_access.rb +28 -0
  6. data/app/controllers/rails_drips/application_controller.rb +7 -0
  7. data/app/controllers/rails_drips/campaign_archivals_controller.rb +11 -0
  8. data/app/controllers/rails_drips/campaign_pauses_controller.rb +11 -0
  9. data/app/controllers/rails_drips/campaign_resumptions_controller.rb +11 -0
  10. data/app/controllers/rails_drips/campaigns_controller.rb +14 -0
  11. data/app/controllers/rails_drips/deliveries_controller.rb +14 -0
  12. data/app/controllers/rails_drips/delivery_cancellations_controller.rb +11 -0
  13. data/app/controllers/rails_drips/delivery_retries_controller.rb +11 -0
  14. data/app/controllers/rails_drips/enrollments_controller.rb +11 -0
  15. data/app/jobs/rails_drips/delivery_job.rb +89 -0
  16. data/app/models/concerns/rails_drips/json_data.rb +29 -0
  17. data/app/models/rails_drips/application_record.rb +8 -0
  18. data/app/models/rails_drips/campaign.rb +62 -0
  19. data/app/models/rails_drips/campaign_version.rb +35 -0
  20. data/app/models/rails_drips/delivery.rb +90 -0
  21. data/app/models/rails_drips/enrollment.rb +77 -0
  22. data/app/models/rails_drips/enrollment_event.rb +28 -0
  23. data/app/models/rails_drips/step.rb +53 -0
  24. data/app/services/rails_drips/activate_campaign.rb +10 -0
  25. data/app/services/rails_drips/archive_campaign.rb +22 -0
  26. data/app/services/rails_drips/cancel_delivery.rb +17 -0
  27. data/app/services/rails_drips/compare_definition.rb +28 -0
  28. data/app/services/rails_drips/complete_enrollment.rb +18 -0
  29. data/app/services/rails_drips/enqueue_due_deliveries.rb +16 -0
  30. data/app/services/rails_drips/enroll.rb +72 -0
  31. data/app/services/rails_drips/import_definition.rb +68 -0
  32. data/app/services/rails_drips/integrity_audit.rb +43 -0
  33. data/app/services/rails_drips/mail_snapshot.rb +31 -0
  34. data/app/services/rails_drips/pause_campaign.rb +16 -0
  35. data/app/services/rails_drips/policy_decision.rb +21 -0
  36. data/app/services/rails_drips/record_event.rb +14 -0
  37. data/app/services/rails_drips/remove.rb +27 -0
  38. data/app/services/rails_drips/resume_campaign.rb +17 -0
  39. data/app/services/rails_drips/retry_delivery.rb +21 -0
  40. data/app/services/rails_drips/schedule_deliveries.rb +23 -0
  41. data/app/services/rails_drips/unarchive_campaign.rb +14 -0
  42. data/app/views/layouts/rails_drips/application.html.erb +5 -0
  43. data/app/views/rails_drips/campaigns/index.html.erb +7 -0
  44. data/app/views/rails_drips/campaigns/show.html.erb +7 -0
  45. data/app/views/rails_drips/deliveries/show.html.erb +9 -0
  46. data/app/views/rails_drips/enrollments/show.html.erb +6 -0
  47. data/config/routes.rb +14 -0
  48. data/db/migrate/20260829123000_create_rails_drips_tables.rb +142 -0
  49. data/docs/README.md +13 -0
  50. data/docs/architecture.md +95 -0
  51. data/docs/campaign-definitions.md +100 -0
  52. data/docs/getting-started.md +170 -0
  53. data/docs/host-integration.md +160 -0
  54. data/docs/operations.md +104 -0
  55. data/lib/generators/rails_drips/install/install_generator.rb +32 -0
  56. data/lib/generators/rails_drips/install/templates/initializer.rb +8 -0
  57. data/lib/generators/rails_drips/install/templates/sample.yml +13 -0
  58. data/lib/rails_drips/configuration.rb +65 -0
  59. data/lib/rails_drips/definition_loader.rb +42 -0
  60. data/lib/rails_drips/definition_schema.rb +40 -0
  61. data/lib/rails_drips/engine.rb +9 -0
  62. data/lib/rails_drips/errors.rb +17 -0
  63. data/lib/rails_drips/instrumentation.rb +11 -0
  64. data/lib/rails_drips/mail_registry.rb +62 -0
  65. data/lib/rails_drips/queue_inventory.rb +10 -0
  66. data/lib/rails_drips/result.rb +9 -0
  67. data/lib/rails_drips/version.rb +5 -0
  68. data/lib/rails_drips.rb +65 -0
  69. data/lib/tasks/rails_drips_tasks.rake +28 -0
  70. 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
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Add the initial isolated Rails engine, configuration object, and mail registry.
6
+ - Add namespaced campaign, version, step, enrollment, event, and delivery persistence with a Rails 7 migration API.
data/LICENSE.txt ADDED
@@ -0,0 +1,4 @@
1
+ Copyright (c) 2026 RailsDrips contributors.
2
+
3
+ All rights reserved. No license is granted at this time.
4
+
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ApplicationController < ActionController::Base
5
+ include AdminAccess
6
+ end
7
+ 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
8
+
@@ -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
+