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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class Step < ApplicationRecord
5
+ include JsonData
6
+
7
+ DELAY_UNITS = %w[minutes hours days].freeze
8
+
9
+ belongs_to :campaign_version,
10
+ class_name: "RailsDrips::CampaignVersion",
11
+ inverse_of: :steps
12
+ has_many :deliveries,
13
+ class_name: "RailsDrips::Delivery",
14
+ dependent: :restrict_with_error,
15
+ inverse_of: :step
16
+
17
+ enum :kind, { email: 0 }, prefix: true
18
+
19
+ validates :position,
20
+ presence: true,
21
+ numericality: { only_integer: true, greater_than: 0 },
22
+ uniqueness: { scope: :campaign_version_id }
23
+ validates :name, :kind, :delay_unit, :email_key, presence: true
24
+ validates :delay_amount,
25
+ presence: true,
26
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
27
+ validates :delay_unit, inclusion: { in: DELAY_UNITS }
28
+ validate :email_key_registered
29
+
30
+ def resolved_email_category
31
+ email_category.presence || campaign_version.campaign.default_email_category
32
+ end
33
+
34
+ def delay_duration
35
+ case delay_unit
36
+ when "minutes" then delay_amount.minutes
37
+ when "hours" then delay_amount.hours
38
+ when "days" then delay_amount.days
39
+ else
40
+ raise ArgumentError, "Unsupported delay unit: #{delay_unit.inspect}"
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def email_key_registered
47
+ return if email_key.blank? || RailsDrips.configuration.mailers.registered?(email_key)
48
+
49
+ errors.add(:email_key, "is not registered")
50
+ end
51
+ end
52
+ end
53
+
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ActivateCampaign
5
+ def self.call(campaign:, version:, actor: nil)
6
+ campaign.activate_version!(version, actor: actor)
7
+ Result.new(status: :activated, campaign: campaign)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ArchiveCampaign
5
+ def self.call(campaign:, reason: nil, actor: nil)
6
+ campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
7
+ return Result.new(status: :campaign_not_found) unless campaign
8
+ return Result.new(status: :already_archived, campaign: campaign) if campaign.archived?
9
+
10
+ Campaign.transaction do
11
+ campaign.update!(status: :archived)
12
+ campaign.enrollments.open.find_each do |enrollment|
13
+ enrollment.deliveries.scheduled.find_each { |delivery| delivery.cancel!(reason: :campaign_archived) }
14
+ RecordEvent.call(enrollment: enrollment, event_type: "campaign_archived", actor: actor,
15
+ data: { "reason" => reason }.compact)
16
+ end
17
+ end
18
+ Instrumentation.instrument("campaign.archived", campaign_id: campaign.id)
19
+ Result.new(status: :archived, campaign: campaign)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class CancelDelivery
5
+ def self.call(delivery:, reason:, actor: nil)
6
+ delivery.with_lock do
7
+ return Result.new(status: :not_cancellable, delivery: delivery) unless delivery.scheduled? || delivery.failed?
8
+
9
+ delivery.cancel!(reason: reason)
10
+ RecordEvent.call(enrollment: delivery.enrollment, event_type: "delivery_cancelled", actor: actor,
11
+ data: { "delivery_id" => delivery.id, "reason" => reason.to_s })
12
+ end
13
+ CompleteEnrollment.call(enrollment: delivery.enrollment)
14
+ Result.new(status: :cancelled, delivery: delivery, enrollment: delivery.enrollment)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsDrips
6
+ class CompareDefinition
7
+ EXECUTABLE_FIELDS = %w[position name kind delay_amount delay_unit email_key email_category data].freeze
8
+
9
+ def self.fingerprint_definition(definition)
10
+ steps = definition.fetch("campaign").fetch("steps").map do |step|
11
+ EXECUTABLE_FIELDS.to_h { |field| [field, step[field]] }
12
+ end
13
+ JSON.generate(steps.sort_by { |step| step["position"] })
14
+ end
15
+
16
+ def self.fingerprint_version(version)
17
+ steps = version.steps.order(:position).map do |step|
18
+ {
19
+ "position" => step.position, "name" => step.name, "kind" => step.kind,
20
+ "delay_amount" => step.delay_amount, "delay_unit" => step.delay_unit,
21
+ "email_key" => step.email_key, "email_category" => step.email_category,
22
+ "data" => step.data_value.presence
23
+ }
24
+ end
25
+ JSON.generate(steps)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class CompleteEnrollment
5
+ BLOCKING_STATUSES = %w[scheduled sending failed].freeze
6
+
7
+ def self.call(enrollment:)
8
+ enrollment.with_lock do
9
+ return Result.new(status: :not_active, enrollment: enrollment) unless enrollment.active?
10
+ return Result.new(status: :pending, enrollment: enrollment) if enrollment.deliveries.where(status: BLOCKING_STATUSES).exists?
11
+
12
+ enrollment.complete!
13
+ RecordEvent.call(enrollment: enrollment, event_type: "completed")
14
+ Result.new(status: :completed, enrollment: enrollment)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class EnqueueDueDeliveries
5
+ def self.call(campaign: nil, dry_run: false)
6
+ campaign = Campaign.find_by(key: campaign.to_s) unless campaign.nil? || campaign.is_a?(Campaign)
7
+ scope = Delivery.due.joins(:enrollment).merge(Enrollment.status_active)
8
+ scope = scope.where(rails_drips_enrollments: { campaign_id: campaign.id }) if campaign
9
+ ids = scope.pluck(:id)
10
+ unless dry_run
11
+ ids.each { |id| RailsDrips.configuration.delivery_job.perform_later(id) }
12
+ end
13
+ Result.new(status: dry_run ? :dry_run : :enqueued, campaign: campaign, count: ids.length)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class Enroll
5
+ def self.call(...) = new(...).call
6
+
7
+ def initialize(campaign:, recipient:, actor: nil, source: nil, data: {}, force: false)
8
+ @campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
9
+ @recipient = recipient
10
+ @actor = actor
11
+ @source = source
12
+ @data = data || {}
13
+ @force = force
14
+ end
15
+
16
+ def call
17
+ return result(:campaign_inactive) unless campaign&.active_for_enrollment?
18
+
19
+ existing = campaign.enrollments.open.find_by(recipient: recipient)
20
+ return result(:already_open, enrollment: existing) if existing
21
+ return result(:reenrollment_disallowed) if prior_enrollment? && !campaign.allow_reenrollment?
22
+
23
+ decision = eligibility_decision
24
+ return result(:ineligible, reason: decision.reason) unless force || decision.allowed
25
+
26
+ enrollment = persist_enrollment
27
+ result(:enrolled, enrollment: enrollment)
28
+ rescue ActiveRecord::RecordNotUnique
29
+ result(:already_open, enrollment: campaign.enrollments.open.find_by(recipient: recipient))
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :campaign, :recipient, :actor, :source, :data, :force
35
+
36
+ def prior_enrollment?
37
+ campaign.enrollments.where(recipient: recipient).exists?
38
+ end
39
+
40
+ def eligibility_decision
41
+ value = RailsDrips.configuration.eligibility_evaluator.call(
42
+ recipient: recipient, campaign: campaign, phase: :enrollment
43
+ )
44
+ PolicyDecision.call(value)
45
+ end
46
+
47
+ def persist_enrollment
48
+ email = RailsDrips.configuration.recipient_email(recipient)
49
+ now = RailsDrips.configuration.current_time
50
+ Enrollment.transaction do
51
+ enrollment = campaign.enrollments.create!(
52
+ campaign_version: campaign.current_version,
53
+ recipient: recipient,
54
+ added_by: actor,
55
+ entry_source: source,
56
+ enrolled_at: now,
57
+ data: data.stringify_keys.merge("forced_enrollment" => force)
58
+ )
59
+ RecordEvent.call(enrollment: enrollment, event_type: "enrolled", actor: actor,
60
+ data: { "source" => source, "forced" => force }.compact)
61
+ ScheduleDeliveries.call(enrollment: enrollment, recipient_email: email)
62
+ enrollment
63
+ end
64
+ end
65
+
66
+ def result(status, enrollment: nil, reason: nil)
67
+ Instrumentation.instrument("enrollment.#{status}", campaign_key: campaign&.key, campaign_id: campaign&.id,
68
+ enrollment_id: enrollment&.id, recipient_type: recipient.class.name)
69
+ Result.new(status: status, campaign: campaign, enrollment: enrollment, reason: reason)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ImportDefinition
5
+ METADATA_FIELDS = %w[name description allow_reenrollment default_email_category data].freeze
6
+
7
+ def self.call(definition:, dry_run: false, actor: nil)
8
+ new(definition, dry_run: dry_run, actor: actor).call
9
+ end
10
+
11
+ def initialize(definition, dry_run:, actor:)
12
+ @definition = definition
13
+ @attributes = definition.fetch("campaign")
14
+ @dry_run = dry_run
15
+ @actor = actor
16
+ end
17
+
18
+ def call
19
+ campaign = Campaign.find_by(key: attributes.fetch("key"))
20
+ return Result.new(status: :would_create) if dry_run && !campaign
21
+ return create_campaign unless campaign
22
+
23
+ latest = campaign.campaign_versions.order(version_number: :desc).first
24
+ executable_changed = latest.nil? || CompareDefinition.fingerprint_version(latest) != CompareDefinition.fingerprint_definition(definition)
25
+ metadata_changed = metadata_attributes.any? { |key, value| campaign.public_send(key) != value }
26
+ return Result.new(status: executable_changed ? :would_create_version : :would_update_metadata, campaign: campaign) if dry_run && (executable_changed || metadata_changed)
27
+ return Result.new(status: :unchanged, campaign: campaign) unless executable_changed || metadata_changed
28
+
29
+ Campaign.transaction do
30
+ campaign.update!(metadata_attributes) if metadata_changed
31
+ create_version(campaign) if executable_changed
32
+ end
33
+ Result.new(status: executable_changed ? :version_created : :metadata_updated, campaign: campaign)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :definition, :attributes, :dry_run, :actor
39
+
40
+ def create_campaign
41
+ campaign = Campaign.transaction do
42
+ created = Campaign.create!({ key: attributes.fetch("key"), status: :draft }.merge(metadata_attributes))
43
+ create_version(created)
44
+ created
45
+ end
46
+ Result.new(status: :created, campaign: campaign)
47
+ end
48
+
49
+ def create_version(campaign)
50
+ version = campaign.campaign_versions.create!(
51
+ version_number: campaign.campaign_versions.maximum(:version_number).to_i + 1,
52
+ status: :draft,
53
+ created_by: actor
54
+ )
55
+ attributes.fetch("steps").each do |step|
56
+ version.steps.create!(step.slice(*CompareDefinition::EXECUTABLE_FIELDS))
57
+ end
58
+ version
59
+ end
60
+
61
+ def metadata_attributes
62
+ @metadata_attributes ||= attributes.slice(*METADATA_FIELDS).tap do |values|
63
+ values["allow_reenrollment"] = false unless values.key?("allow_reenrollment")
64
+ values["data"] ||= {}
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class IntegrityAudit
5
+ Finding = Struct.new(:code, :severity, :record_type, :record_id, :details, keyword_init: true)
6
+
7
+ def self.call(stuck_before: 1.hour.ago)
8
+ findings = []
9
+ Enrollment.status_active.includes({ campaign_version: :steps }, :deliveries).find_each do |enrollment|
10
+ missing = enrollment.campaign_version.steps.map(&:id) - enrollment.deliveries.map(&:step_id)
11
+ missing.each do |step_id|
12
+ findings << Finding.new(code: :missing_delivery, severity: :error,
13
+ record_type: enrollment.class.name, record_id: enrollment.id, details: { step_id: step_id })
14
+ end
15
+ end
16
+ Delivery.joins(:step, :enrollment)
17
+ .where("rails_drips_steps.campaign_version_id != rails_drips_enrollments.campaign_version_id")
18
+ .find_each { |delivery| findings << finding(:cross_version_step, :error, delivery) }
19
+ Delivery.status_sending.where("updated_at < ?", stuck_before).find_each do |delivery|
20
+ findings << finding(:stuck_sending, :error, delivery)
21
+ end
22
+ Delivery.status_sent.where(subject_snapshot: nil).find_each do |delivery|
23
+ findings << finding(:sent_without_snapshot, :warning, delivery)
24
+ end
25
+ Delivery.status_scheduled.joins(:enrollment).merge(Enrollment.status_removed).find_each do |delivery|
26
+ findings << finding(:removed_with_scheduled, :error, delivery)
27
+ end
28
+ Delivery.status_failed.joins(:enrollment).merge(Enrollment.status_completed).find_each do |delivery|
29
+ findings << finding(:completed_with_failed, :error, delivery)
30
+ end
31
+ queue = QueueInventory.call
32
+ findings << Finding.new(code: :queue_inventory_available, severity: :warning,
33
+ record_type: "Queue", record_id: nil, details: { size: queue.size }) if queue
34
+ findings
35
+ end
36
+
37
+ def self.finding(code, severity, record)
38
+ Finding.new(code: code, severity: severity, record_type: record.class.name,
39
+ record_id: record.id, details: {})
40
+ end
41
+ private_class_method :finding
42
+ end
43
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RailsDrips
6
+ class MailSnapshot
7
+ def self.call(message)
8
+ html = body_for(message, :html)
9
+ text = body_for(message, :text)
10
+ encoded = message.encoded
11
+ {
12
+ subject: message.subject.to_s,
13
+ html_body: html,
14
+ text_body: text,
15
+ sha256: Digest::SHA256.hexdigest(encoded),
16
+ byte_size: encoded.bytesize
17
+ }
18
+ end
19
+
20
+ def self.body_for(message, kind)
21
+ part = kind == :html ? message.html_part : message.text_part
22
+ return part.body.decoded if part
23
+ return nil if message.multipart?
24
+
25
+ mime = message.mime_type.to_s
26
+ return message.body.decoded if kind == :html && mime.include?("html")
27
+ return message.body.decoded if kind == :text && !mime.include?("html")
28
+ end
29
+ private_class_method :body_for
30
+ end
31
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class PauseCampaign
5
+ def self.call(campaign:, actor: nil)
6
+ campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
7
+ return Result.new(status: :campaign_not_found) unless campaign
8
+ return Result.new(status: :already_paused, campaign: campaign) if campaign.paused?
9
+ return Result.new(status: :invalid_transition, campaign: campaign) unless campaign.active?
10
+
11
+ campaign.update!(status: :paused)
12
+ Instrumentation.instrument("campaign.paused", campaign_id: campaign.id, actor_type: actor&.class&.name)
13
+ Result.new(status: :paused, campaign: campaign)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class PolicyDecision
5
+ Decision = Struct.new(:allowed, :reason, keyword_init: true)
6
+
7
+ def self.call(value)
8
+ return Decision.new(allowed: value) if value == true || value == false
9
+
10
+ value = value.to_h.transform_keys(&:to_sym)
11
+ Decision.new(
12
+ allowed: value.fetch(:allowed, value.fetch(:eligible, false)),
13
+ reason: value[:reason]
14
+ )
15
+ end
16
+
17
+ def self.allowed?(value)
18
+ call(value).allowed
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class RecordEvent
5
+ def self.call(enrollment:, event_type:, actor: nil, data: {})
6
+ enrollment.enrollment_events.create!(
7
+ event_type: event_type,
8
+ actor: actor,
9
+ occurred_at: RailsDrips.configuration.current_time,
10
+ data: data || {}
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class Remove
5
+ def self.call(campaign:, recipient:, reason:, actor: nil, data: {})
6
+ campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
7
+ return Result.new(status: :campaign_not_found) unless campaign
8
+
9
+ enrollment = campaign.enrollments.open.find_by(recipient: recipient)
10
+ return Result.new(status: :not_open, campaign: campaign) unless enrollment
11
+
12
+ Enrollment.transaction do
13
+ enrollment.lock!
14
+ enrollment.remove!(reason: reason, actor: actor)
15
+ RecordEvent.call(enrollment: enrollment, event_type: "removed", actor: actor,
16
+ data: (data || {}).stringify_keys.merge("reason" => reason.to_s))
17
+ enrollment.deliveries.scheduled.find_each do |delivery|
18
+ delivery.cancel!(reason: :recipient_removed)
19
+ RecordEvent.call(enrollment: enrollment, event_type: "delivery_cancelled", actor: actor,
20
+ data: { "delivery_id" => delivery.id, "reason" => "recipient_removed" })
21
+ end
22
+ end
23
+ Instrumentation.instrument("enrollment.removed", campaign_key: campaign.key, enrollment_id: enrollment.id)
24
+ Result.new(status: :removed, campaign: campaign, enrollment: enrollment)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ResumeCampaign
5
+ def self.call(campaign:, actor: nil)
6
+ campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
7
+ return Result.new(status: :campaign_not_found) unless campaign
8
+ return Result.new(status: :already_active, campaign: campaign) if campaign.active?
9
+ return Result.new(status: :invalid_transition, campaign: campaign) unless campaign.paused?
10
+
11
+ campaign.update!(status: :active)
12
+ EnqueueDueDeliveries.call(campaign: campaign)
13
+ Instrumentation.instrument("campaign.resumed", campaign_id: campaign.id, actor_type: actor&.class&.name)
14
+ Result.new(status: :resumed, campaign: campaign)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class RetryDelivery
5
+ def self.call(delivery:, actor: nil)
6
+ delivery.with_lock do
7
+ enrollment = delivery.enrollment
8
+ unless delivery.failed? && enrollment.active? && enrollment.campaign.active?
9
+ return Result.new(status: :not_retryable, delivery: delivery, enrollment: enrollment)
10
+ end
11
+
12
+ delivery.update!(status: :scheduled, scheduled_for: RailsDrips.configuration.current_time,
13
+ failed_at: nil, last_error: nil)
14
+ RecordEvent.call(enrollment: enrollment, event_type: "delivery_retried", actor: actor,
15
+ data: { "delivery_id" => delivery.id })
16
+ end
17
+ RailsDrips.configuration.delivery_job.perform_later(delivery.id)
18
+ Result.new(status: :retried, delivery: delivery, enrollment: delivery.enrollment)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class ScheduleDeliveries
5
+ def self.call(enrollment:, recipient_email:)
6
+ scheduled_for = enrollment.enrolled_at
7
+
8
+ enrollment.campaign_version.steps.order(:position).map do |step|
9
+ scheduled_for += step.delay_duration
10
+ enrollment.deliveries.create!(
11
+ step: step,
12
+ recipient: enrollment.recipient,
13
+ recipient_email: recipient_email,
14
+ scheduled_for: scheduled_for,
15
+ campaign_key: enrollment.campaign.key,
16
+ email_key: step.email_key,
17
+ email_category: step.resolved_email_category,
18
+ data: { "step_position" => step.position, "version_number" => enrollment.campaign_version.version_number }
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDrips
4
+ class UnarchiveCampaign
5
+ def self.call(campaign:)
6
+ campaign = campaign.is_a?(Campaign) ? campaign : Campaign.find_by(key: campaign.to_s)
7
+ return Result.new(status: :campaign_not_found) unless campaign
8
+ return Result.new(status: :invalid_transition, campaign: campaign) unless campaign.archived?
9
+
10
+ campaign.update!(status: :draft, current_version: nil)
11
+ Result.new(status: :unarchived, campaign: campaign)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head><title>RailsDrips</title><meta name="viewport" content="width=device-width,initial-scale=1"></head>
4
+ <body><main><%= yield %></main></body>
5
+ </html>
@@ -0,0 +1,7 @@
1
+ <h1>Drip campaigns</h1>
2
+ <% if @campaigns.empty? %><p>No campaigns.</p><% end %>
3
+ <ul>
4
+ <% @campaigns.each do |campaign| %>
5
+ <li><%= link_to campaign.key, campaign_path(campaign) %> — <%= campaign.status %></li>
6
+ <% end %>
7
+ </ul>
@@ -0,0 +1,7 @@
1
+ <p><%= link_to "Campaigns", campaigns_path %></p>
2
+ <h1><%= @campaign.name %></h1>
3
+ <p><code><%= @campaign.key %></code> — <%= @campaign.status %></p>
4
+ <h2>Versions</h2>
5
+ <ul><% @campaign.campaign_versions.each do |version| %><li>v<%= version.version_number %> — <%= version.status %> — <%= version.steps.size %> steps</li><% end %></ul>
6
+ <h2>Enrollments</h2>
7
+ <ul><% @enrollments.each do |enrollment| %><li><%= link_to "Enrollment ##{enrollment.id}", enrollment_path(enrollment) %> — <%= enrollment.status %></li><% end %></ul>
@@ -0,0 +1,9 @@
1
+ <h1>Delivery #<%= @delivery.id %></h1>
2
+ <dl><dt>Status</dt><dd><%= @delivery.status %></dd><dt>Email key</dt><dd><%= @delivery.email_key %></dd><dt>Scheduled</dt><dd><%= @delivery.scheduled_for %></dd></dl>
3
+ <% if @show_snapshot %>
4
+ <h2><%= @delivery.subject_snapshot %></h2>
5
+ <h3>Text</h3><pre><%= @delivery.text_body_snapshot %></pre>
6
+ <h3>HTML source</h3><pre><%= @delivery.html_body_snapshot %></pre>
7
+ <% else %>
8
+ <%= link_to "Show protected snapshot", delivery_path(@delivery, snapshot: 1) %>
9
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <h1>Enrollment #<%= @enrollment.id %></h1>
2
+ <p><%= recipient_label(@enrollment.recipient) %> — <%= @enrollment.status %></p>
3
+ <h2>Events</h2>
4
+ <ol><% @enrollment.enrollment_events.chronological.each do |event| %><li><%= event.occurred_at %> — <%= event.event_type %></li><% end %></ol>
5
+ <h2>Deliveries</h2>
6
+ <ul><% @enrollment.deliveries.order(:scheduled_for).each do |delivery| %><li><%= link_to delivery.email_key, delivery_path(delivery) %> — <%= delivery.status %></li><% end %></ul>
data/config/routes.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsDrips::Engine.routes.draw do
4
+ resources :campaigns, only: %i[index show] do
5
+ resource :pause, only: :create, controller: :campaign_pauses
6
+ resource :resumption, only: :create, controller: :campaign_resumptions
7
+ resource :archival, only: :create, controller: :campaign_archivals
8
+ end
9
+ resources :enrollments, only: :show
10
+ resources :deliveries, only: :show do
11
+ resource :retry, only: :create, controller: :delivery_retries
12
+ resource :cancellation, only: :create, controller: :delivery_cancellations
13
+ end
14
+ end