From 1afa35aa6f1449be7ae3a268f491f55fbf892b7d Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Fri, 20 Mar 2026 16:38:34 -0700 Subject: [PATCH 1/8] allow 2 campaign emails to be created --- .../diy/diy_email_address_controller.rb | 19 ++++-- app/models/campaign_email.rb | 2 +- ...emove_unique_index_from_campaign_emails.rb | 22 +++++++ db/schema.rb | 4 +- .../diy/diy_email_address_controller_spec.rb | 63 +++++++++++++++---- spec/factories/campaign_emails.rb | 2 +- spec/models/campaign_email_spec.rb | 2 +- 7 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb diff --git a/app/controllers/diy/diy_email_address_controller.rb b/app/controllers/diy/diy_email_address_controller.rb index 3008f70654..fc7922d9dc 100644 --- a/app/controllers/diy/diy_email_address_controller.rb +++ b/app/controllers/diy/diy_email_address_controller.rb @@ -31,12 +31,19 @@ def after_update_success contact = current_diy_intake.campaign_contact return unless contact.present? - CampaignEmail.create( - campaign_contact_id: contact.id, - message_name: "diy_followup_survey", - to_email: contact.email_address, - scheduled_send_at: Time.current + 1.day - ) + existing_send_count = CampaignEmail.where( + campaign_contact_id: contact.id, + message_name: "diy_followup_survey" + ).count + + return if existing_send_count >= 2 + + CampaignEmail.create!( + campaign_contact_id: contact.id, + message_name: "diy_followup_survey", + to_email: contact.email_address, + scheduled_send_at: Time.current + 1.day + ) end end diff --git a/app/models/campaign_email.rb b/app/models/campaign_email.rb index 6bec78b50a..4aafad18d9 100644 --- a/app/models/campaign_email.rb +++ b/app/models/campaign_email.rb @@ -20,7 +20,7 @@ # Indexes # # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) -# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) UNIQUE +# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE # # Foreign Keys diff --git a/db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb b/db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb new file mode 100644 index 0000000000..5770b23973 --- /dev/null +++ b/db/migrate/20260320232943_remove_unique_index_from_campaign_emails.rb @@ -0,0 +1,22 @@ +class RemoveUniqueIndexFromCampaignEmails < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + remove_index :campaign_emails, name: "index_campaign_emails_on_contact_id_and_message_name" + + add_index :campaign_emails, + [:campaign_contact_id, :message_name], + name: "index_campaign_emails_on_contact_id_and_message_name", + algorithm: :concurrently + end + + def down + remove_index :campaign_emails, name: "index_campaign_emails_on_contact_id_and_message_name" + + add_index :campaign_emails, + [:campaign_contact_id, :message_name], + name: "index_campaign_emails_on_contact_id_and_message_name", + unique: true, + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index d3098f8c88..1cbca9452e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_03_001000) do +ActiveRecord::Schema[7.1].define(version: 2026_03_20_232943) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -615,7 +615,7 @@ t.text "subject" t.string "to_email" t.datetime "updated_at", null: false - t.index ["campaign_contact_id", "message_name"], name: "index_campaign_emails_on_contact_id_and_message_name", unique: true + t.index ["campaign_contact_id", "message_name"], name: "index_campaign_emails_on_contact_id_and_message_name" t.index ["campaign_contact_id"], name: "index_campaign_emails_on_campaign_contact_id" t.index ["mailgun_message_id"], name: "index_campaign_emails_on_mailgun_message_id", unique: true end diff --git a/spec/controllers/diy/diy_email_address_controller_spec.rb b/spec/controllers/diy/diy_email_address_controller_spec.rb index 945d1f4ede..a95db5e347 100644 --- a/spec/controllers/diy/diy_email_address_controller_spec.rb +++ b/spec/controllers/diy/diy_email_address_controller_spec.rb @@ -141,36 +141,73 @@ before do allow(Flipper).to receive(:enabled?).and_call_original - allow(CampaignEmail).to receive(:create) allow(Flipper).to receive(:enabled?).with(:send_diy_survey).and_return(true) end context "when flipper flag 'send_diy_survey' is off" do - it "doesn't create the send survey email" do + before do allow(Flipper).to receive(:enabled?).with(:send_diy_survey).and_return(false) + end - post :update, params: params, session: { diy_intake_id: diy_intake.id } - - expect(CampaignEmail).not_to have_received(:create) + it "does not create the send survey email" do + expect do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + end.not_to change(CampaignEmail, :count) end end context "with flipper flag 'send_diy_survey' enabled and contact present" do it "creates a campaign contact" do - expect { + expect do post :update, params: params, session: { diy_intake_id: diy_intake.id } - }.to change(CampaignContact, :count).by(1) + end.to change(CampaignContact, :count).by(1) end - it "creates a campaign email for the diy survey" do + it "creates the first campaign email for the diy survey" do + expect do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + end.to change(CampaignEmail, :count).by(1) + + campaign_email = CampaignEmail.order(:created_at).last + + expect(campaign_email.campaign_contact_id).to eq(diy_intake.reload.campaign_contact.id) + expect(campaign_email.message_name).to eq("diy_followup_survey") + expect(campaign_email.to_email).to eq("iloveplant@example.test") + expect(campaign_email.scheduled_send_at).to be_within(1.minute).of(Time.current + 1.day) + end + + it "creates a second campaign email for the same contact and message" do post :update, params: params, session: { diy_intake_id: diy_intake.id } - expect(CampaignEmail).to have_received(:create).with( - campaign_contact_id: diy_intake.campaign_contact.id, - message_name: "diy_followup_survey", - to_email: diy_intake.email_address, - scheduled_send_at: be_within(1.minute).of(Time.current + 1.day) + expect do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + end.to change(CampaignEmail, :count).by(1) + + contact = diy_intake.reload.campaign_contact + campaign_emails = CampaignEmail.where( + campaign_contact_id: contact.id, + message_name: "diy_followup_survey" + ) + + expect(campaign_emails.count).to eq(2) + end + + it "does not create a third campaign email for the same contact and message" do + 2.times do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + end + + expect do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + end.not_to change(CampaignEmail, :count) + + contact = diy_intake.reload.campaign_contact + campaign_emails = CampaignEmail.where( + campaign_contact_id: contact.id, + message_name: "diy_followup_survey" ) + + expect(campaign_emails.count).to eq(2) end end end diff --git a/spec/factories/campaign_emails.rb b/spec/factories/campaign_emails.rb index 67a0d11cc6..2d18c07a93 100644 --- a/spec/factories/campaign_emails.rb +++ b/spec/factories/campaign_emails.rb @@ -20,7 +20,7 @@ # Indexes # # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) -# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) UNIQUE +# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE # # Foreign Keys diff --git a/spec/models/campaign_email_spec.rb b/spec/models/campaign_email_spec.rb index 43a30ee29f..6e831a0d4f 100644 --- a/spec/models/campaign_email_spec.rb +++ b/spec/models/campaign_email_spec.rb @@ -20,7 +20,7 @@ # Indexes # # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) -# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) UNIQUE +# index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE # # Foreign Keys From abe2633ebe374c47af0bcfde26826ec6c58854c4 Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Tue, 24 Mar 2026 11:02:33 -0700 Subject: [PATCH 2/8] Gate second diy_followup_survey email on first being sent Only create a second campaign email once the first has left the in-progress state (delivered/failed), preventing back-to-back sends. Co-Authored-By: Claude Sonnet 4.6 --- .../diy/diy_email_address_controller.rb | 9 +++++---- .../diy/diy_email_address_controller_spec.rb | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/controllers/diy/diy_email_address_controller.rb b/app/controllers/diy/diy_email_address_controller.rb index fc7922d9dc..69774e7e97 100644 --- a/app/controllers/diy/diy_email_address_controller.rb +++ b/app/controllers/diy/diy_email_address_controller.rb @@ -31,14 +31,15 @@ def after_update_success contact = current_diy_intake.campaign_contact return unless contact.present? - existing_send_count = CampaignEmail.where( + existing_emails = CampaignEmail.where( campaign_contact_id: contact.id, message_name: "diy_followup_survey" - ).count + ) - return if existing_send_count >= 2 + return if existing_emails.count >= 2 + return if existing_emails.in_progress.any? - CampaignEmail.create!( + CampaignEmail.create!( campaign_contact_id: contact.id, message_name: "diy_followup_survey", to_email: contact.email_address, diff --git a/spec/controllers/diy/diy_email_address_controller_spec.rb b/spec/controllers/diy/diy_email_address_controller_spec.rb index a95db5e347..f7e702b740 100644 --- a/spec/controllers/diy/diy_email_address_controller_spec.rb +++ b/spec/controllers/diy/diy_email_address_controller_spec.rb @@ -176,9 +176,18 @@ expect(campaign_email.scheduled_send_at).to be_within(1.minute).of(Time.current + 1.day) end - it "creates a second campaign email for the same contact and message" do + it "does not create a second campaign email when the first is still in progress" do post :update, params: params, session: { diy_intake_id: diy_intake.id } + expect do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + end.not_to change(CampaignEmail, :count) + end + + it "creates a second campaign email once the first has been sent" do + post :update, params: params, session: { diy_intake_id: diy_intake.id } + CampaignEmail.last.update!(mailgun_status: "delivered") + expect do post :update, params: params, session: { diy_intake_id: diy_intake.id } end.to change(CampaignEmail, :count).by(1) @@ -193,9 +202,10 @@ end it "does not create a third campaign email for the same contact and message" do - 2.times do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - end + post :update, params: params, session: { diy_intake_id: diy_intake.id } + CampaignEmail.last.update!(mailgun_status: "delivered") + post :update, params: params, session: { diy_intake_id: diy_intake.id } + CampaignEmail.last.update!(mailgun_status: "delivered") expect do post :update, params: params, session: { diy_intake_id: diy_intake.id } From 0166e8f49dc85e7658a7fa4525a9b222175b5221 Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Tue, 24 Mar 2026 11:07:15 -0700 Subject: [PATCH 3/8] Add CLAUDE.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b22c5ea101..fb7c675d55 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ jmeter_test/jmeter.log .env mise.toml +CLAUDE.md \ No newline at end of file From 8168b18b632c2eb4a9111f882435ef30e5991ffb Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Wed, 25 Mar 2026 09:59:26 -0700 Subject: [PATCH 4/8] unique constraint on a per message basis --- app/models/campaign_email.rb | 1 + ...nique_constraint_start_of_season_outreach.rb | 17 +++++++++++++++++ db/schema.rb | 3 ++- spec/factories/campaign_emails.rb | 1 + spec/models/campaign_email_spec.rb | 1 + 5 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb diff --git a/app/models/campaign_email.rb b/app/models/campaign_email.rb index 4aafad18d9..cb53c52af0 100644 --- a/app/models/campaign_email.rb +++ b/app/models/campaign_email.rb @@ -22,6 +22,7 @@ # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) # index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE +# index_unique_start_of_season_outreach_per_contact (campaign_contact_id,message_name) UNIQUE WHERE ((message_name)::text = 'start_of_season_outreach'::text) # # Foreign Keys # diff --git a/db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb b/db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb new file mode 100644 index 0000000000..677f184d14 --- /dev/null +++ b/db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb @@ -0,0 +1,17 @@ +class UniqueConstraintStartOfSeasonOutreach < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + add_index :campaign_emails, + [:campaign_contact_id, :message_name], + unique: true, + where: "message_name = 'start_of_season_outreach'", + name: "index_unique_start_of_season_outreach_per_contact", + algorithm: :concurrently + end + + def down + remove_index :campaign_emails, + name: "index_unique_start_of_season_outreach_per_contact" + end +end diff --git a/db/schema.rb b/db/schema.rb index 1cbca9452e..ee3912e4c0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_20_232943) do +ActiveRecord::Schema[7.1].define(version: 2026_03_25_165313) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -616,6 +616,7 @@ t.string "to_email" t.datetime "updated_at", null: false t.index ["campaign_contact_id", "message_name"], name: "index_campaign_emails_on_contact_id_and_message_name" + t.index ["campaign_contact_id", "message_name"], name: "index_unique_start_of_season_outreach_per_contact", unique: true, where: "((message_name)::text = 'start_of_season_outreach'::text)" t.index ["campaign_contact_id"], name: "index_campaign_emails_on_campaign_contact_id" t.index ["mailgun_message_id"], name: "index_campaign_emails_on_mailgun_message_id", unique: true end diff --git a/spec/factories/campaign_emails.rb b/spec/factories/campaign_emails.rb index 2d18c07a93..e6dd27ac43 100644 --- a/spec/factories/campaign_emails.rb +++ b/spec/factories/campaign_emails.rb @@ -22,6 +22,7 @@ # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) # index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE +# index_unique_start_of_season_outreach_per_contact (campaign_contact_id,message_name) UNIQUE WHERE ((message_name)::text = 'start_of_season_outreach'::text) # # Foreign Keys # diff --git a/spec/models/campaign_email_spec.rb b/spec/models/campaign_email_spec.rb index 6e831a0d4f..1da86fd08b 100644 --- a/spec/models/campaign_email_spec.rb +++ b/spec/models/campaign_email_spec.rb @@ -22,6 +22,7 @@ # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) # index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE +# index_unique_start_of_season_outreach_per_contact (campaign_contact_id,message_name) UNIQUE WHERE ((message_name)::text = 'start_of_season_outreach'::text) # # Foreign Keys # From 3e8a5a984b665f9b1c2afb1820709dea5da1be3c Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Wed, 25 Mar 2026 13:29:38 -0700 Subject: [PATCH 5/8] create max sends on campaign emails --- app/jobs/campaign/send_campaign_email_job.rb | 19 +++++ app/models/campaign_email.rb | 1 - .../campaign_message/campaign_message.rb | 4 + .../campaign_message/diy_followup_survey.rb | 4 + .../start_of_season_outreach.rb | 3 + ...que_constraint_start_of_season_outreach.rb | 17 ---- db/schema.rb | 3 +- spec/factories/campaign_emails.rb | 1 - .../campaign/send_campaign_email_job_spec.rb | 78 ++++++++++++++++++- spec/models/campaign_email_spec.rb | 1 - 10 files changed, 108 insertions(+), 23 deletions(-) delete mode 100644 db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb diff --git a/app/jobs/campaign/send_campaign_email_job.rb b/app/jobs/campaign/send_campaign_email_job.rb index 890d51a4b4..18f531cf87 100644 --- a/app/jobs/campaign/send_campaign_email_job.rb +++ b/app/jobs/campaign/send_campaign_email_job.rb @@ -8,6 +8,7 @@ def perform(email_id) return unless email && contact return if email.mailgun_message_id.present? + return if max_sends_reached?(email) send_at = email.scheduled_send_at || Time.current if send_at > Time.current @@ -34,4 +35,22 @@ def perform(email_id) def priority PRIORITY_LOW end + + private + + def max_sends_reached?(email) + max_sends = campaign_message_class(email).max_sends_per_contact + return false if max_sends.nil? + + sent_count = CampaignEmail.where( + campaign_contact_id: email.campaign_contact_id, + message_name: email.message_name + ).where.not(sent_at: nil).count + + sent_count >= max_sends + end + + def campaign_message_class(email) + "CampaignMessage::#{email.message_name.camelize}".constantize + end end \ No newline at end of file diff --git a/app/models/campaign_email.rb b/app/models/campaign_email.rb index cb53c52af0..4aafad18d9 100644 --- a/app/models/campaign_email.rb +++ b/app/models/campaign_email.rb @@ -22,7 +22,6 @@ # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) # index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE -# index_unique_start_of_season_outreach_per_contact (campaign_contact_id,message_name) UNIQUE WHERE ((message_name)::text = 'start_of_season_outreach'::text) # # Foreign Keys # diff --git a/app/models/campaign_message/campaign_message.rb b/app/models/campaign_message/campaign_message.rb index bcbb9353d2..748d23a426 100644 --- a/app/models/campaign_message/campaign_message.rb +++ b/app/models/campaign_message/campaign_message.rb @@ -7,5 +7,9 @@ def vars(contact) locale: contact&.locale || :en, } end + + def self.max_sends_per_contact + 1 + end end end \ No newline at end of file diff --git a/app/models/campaign_message/diy_followup_survey.rb b/app/models/campaign_message/diy_followup_survey.rb index a6e59eb6f8..856263cbe0 100644 --- a/app/models/campaign_message/diy_followup_survey.rb +++ b/app/models/campaign_message/diy_followup_survey.rb @@ -19,5 +19,9 @@ def email_body(contact:, **args) I18n.t("campaign_messages.diy_followup_survey.email.body_html", **vars(contact), **args) end + + def self.max_sends_per_contact + 2 + end end end \ No newline at end of file diff --git a/app/models/campaign_message/start_of_season_outreach.rb b/app/models/campaign_message/start_of_season_outreach.rb index 69add9e1e6..1f30e42725 100644 --- a/app/models/campaign_message/start_of_season_outreach.rb +++ b/app/models/campaign_message/start_of_season_outreach.rb @@ -12,6 +12,9 @@ def email_subject(contact:, **args) I18n.t("campaign_messages.start_of_season_outreach.email.subject", **vars(contact), **args) end + def self.max_sends_per_contact + 1 + end def email_body(contact:, **args) I18n.t("campaign_messages.start_of_season_outreach.email.body_html", **vars(contact), **args) end diff --git a/db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb b/db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb deleted file mode 100644 index 677f184d14..0000000000 --- a/db/migrate/20260325165313_unique_constraint_start_of_season_outreach.rb +++ /dev/null @@ -1,17 +0,0 @@ -class UniqueConstraintStartOfSeasonOutreach < ActiveRecord::Migration[7.1] - disable_ddl_transaction! - - def up - add_index :campaign_emails, - [:campaign_contact_id, :message_name], - unique: true, - where: "message_name = 'start_of_season_outreach'", - name: "index_unique_start_of_season_outreach_per_contact", - algorithm: :concurrently - end - - def down - remove_index :campaign_emails, - name: "index_unique_start_of_season_outreach_per_contact" - end -end diff --git a/db/schema.rb b/db/schema.rb index ee3912e4c0..1cbca9452e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_25_165313) do +ActiveRecord::Schema[7.1].define(version: 2026_03_20_232943) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -616,7 +616,6 @@ t.string "to_email" t.datetime "updated_at", null: false t.index ["campaign_contact_id", "message_name"], name: "index_campaign_emails_on_contact_id_and_message_name" - t.index ["campaign_contact_id", "message_name"], name: "index_unique_start_of_season_outreach_per_contact", unique: true, where: "((message_name)::text = 'start_of_season_outreach'::text)" t.index ["campaign_contact_id"], name: "index_campaign_emails_on_campaign_contact_id" t.index ["mailgun_message_id"], name: "index_campaign_emails_on_mailgun_message_id", unique: true end diff --git a/spec/factories/campaign_emails.rb b/spec/factories/campaign_emails.rb index e6dd27ac43..2d18c07a93 100644 --- a/spec/factories/campaign_emails.rb +++ b/spec/factories/campaign_emails.rb @@ -22,7 +22,6 @@ # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) # index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE -# index_unique_start_of_season_outreach_per_contact (campaign_contact_id,message_name) UNIQUE WHERE ((message_name)::text = 'start_of_season_outreach'::text) # # Foreign Keys # diff --git a/spec/jobs/campaign/send_campaign_email_job_spec.rb b/spec/jobs/campaign/send_campaign_email_job_spec.rb index e469e049bf..0877ac9b57 100644 --- a/spec/jobs/campaign/send_campaign_email_job_spec.rb +++ b/spec/jobs/campaign/send_campaign_email_job_spec.rb @@ -32,6 +32,82 @@ end end + context "when max sends for the message has already been reached" do + let(:email) do + create( + :campaign_email, + campaign_contact: contact, + message_name: "start_of_season_outreach", + mailgun_message_id: nil, + scheduled_send_at: nil + ) + end + + before do + create( + :campaign_email, + campaign_contact: contact, + message_name: "start_of_season_outreach", + sent_at: 1.hour.ago, + mailgun_message_id: "" + ) + end + + it "does not send the email" do + expect(CampaignMailer).not_to receive(:email_message) + + perform_job + end + + it "does not update the email as sent" do + expect { perform_job }.not_to change { email.reload.mailgun_message_id } + expect(email.reload.sent_at).to be_nil + end + end + + context "when max sends for the message has not been reached" do + let(:email) do + create( + :campaign_email, + campaign_contact: contact, + message_name: "diy_followup_survey", + mailgun_message_id: nil, + scheduled_send_at: nil + ) + end + + before do + create( + :campaign_email, + campaign_contact: contact, + message_name: "diy_followup_survey", + sent_at: 1.hour.ago, + mailgun_message_id: "" + ) + end + + it "still sends the email" do + response = instance_double( + "Mail::Message", + message_id: "", + to: ["a@example.com"], + from: ["noreply@example.com"], + subject: "Hello!", + date: Time.current + ) + + mailer_delivery = instance_double("MailerDelivery", deliver_now: response) + + expect(CampaignMailer).to receive(:email_message).with( + campaign_email: email + ).and_return(mailer_delivery) + + perform_job + + expect(email.reload.mailgun_message_id).to eq("") + end + end + context "when scheduled_send_at is blank (send now)" do it "sends via CampaignMailer with locale fallback and updates email fields" do response = instance_double( @@ -143,4 +219,4 @@ expect(described_class.new.priority).to eq(100) end end -end +end \ No newline at end of file diff --git a/spec/models/campaign_email_spec.rb b/spec/models/campaign_email_spec.rb index 1da86fd08b..6e831a0d4f 100644 --- a/spec/models/campaign_email_spec.rb +++ b/spec/models/campaign_email_spec.rb @@ -22,7 +22,6 @@ # index_campaign_emails_on_campaign_contact_id (campaign_contact_id) # index_campaign_emails_on_contact_id_and_message_name (campaign_contact_id,message_name) # index_campaign_emails_on_mailgun_message_id (mailgun_message_id) UNIQUE -# index_unique_start_of_season_outreach_per_contact (campaign_contact_id,message_name) UNIQUE WHERE ((message_name)::text = 'start_of_season_outreach'::text) # # Foreign Keys # From d4c8e70ed172abfc4ecf6ba1daa196c17daf9cfd Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Wed, 25 Mar 2026 13:30:29 -0700 Subject: [PATCH 6/8] remove redundent guards in the controller --- app/controllers/diy/diy_email_address_controller.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/controllers/diy/diy_email_address_controller.rb b/app/controllers/diy/diy_email_address_controller.rb index 69774e7e97..4974914791 100644 --- a/app/controllers/diy/diy_email_address_controller.rb +++ b/app/controllers/diy/diy_email_address_controller.rb @@ -36,9 +36,6 @@ def after_update_success message_name: "diy_followup_survey" ) - return if existing_emails.count >= 2 - return if existing_emails.in_progress.any? - CampaignEmail.create!( campaign_contact_id: contact.id, message_name: "diy_followup_survey", From 8b002b44080ad1a82c5a016c5782fc79fd10ce19 Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Wed, 25 Mar 2026 14:01:35 -0700 Subject: [PATCH 7/8] restore diy email address controller spec --- .../diy/diy_email_address_controller_spec.rb | 73 ++++--------------- 1 file changed, 13 insertions(+), 60 deletions(-) diff --git a/spec/controllers/diy/diy_email_address_controller_spec.rb b/spec/controllers/diy/diy_email_address_controller_spec.rb index f7e702b740..945d1f4ede 100644 --- a/spec/controllers/diy/diy_email_address_controller_spec.rb +++ b/spec/controllers/diy/diy_email_address_controller_spec.rb @@ -141,83 +141,36 @@ before do allow(Flipper).to receive(:enabled?).and_call_original + allow(CampaignEmail).to receive(:create) allow(Flipper).to receive(:enabled?).with(:send_diy_survey).and_return(true) end context "when flipper flag 'send_diy_survey' is off" do - before do + it "doesn't create the send survey email" do allow(Flipper).to receive(:enabled?).with(:send_diy_survey).and_return(false) - end - it "does not create the send survey email" do - expect do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - end.not_to change(CampaignEmail, :count) + post :update, params: params, session: { diy_intake_id: diy_intake.id } + + expect(CampaignEmail).not_to have_received(:create) end end context "with flipper flag 'send_diy_survey' enabled and contact present" do it "creates a campaign contact" do - expect do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - end.to change(CampaignContact, :count).by(1) - end - - it "creates the first campaign email for the diy survey" do - expect do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - end.to change(CampaignEmail, :count).by(1) - - campaign_email = CampaignEmail.order(:created_at).last - - expect(campaign_email.campaign_contact_id).to eq(diy_intake.reload.campaign_contact.id) - expect(campaign_email.message_name).to eq("diy_followup_survey") - expect(campaign_email.to_email).to eq("iloveplant@example.test") - expect(campaign_email.scheduled_send_at).to be_within(1.minute).of(Time.current + 1.day) - end - - it "does not create a second campaign email when the first is still in progress" do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - - expect do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - end.not_to change(CampaignEmail, :count) - end - - it "creates a second campaign email once the first has been sent" do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - CampaignEmail.last.update!(mailgun_status: "delivered") - - expect do + expect { post :update, params: params, session: { diy_intake_id: diy_intake.id } - end.to change(CampaignEmail, :count).by(1) - - contact = diy_intake.reload.campaign_contact - campaign_emails = CampaignEmail.where( - campaign_contact_id: contact.id, - message_name: "diy_followup_survey" - ) - - expect(campaign_emails.count).to eq(2) + }.to change(CampaignContact, :count).by(1) end - it "does not create a third campaign email for the same contact and message" do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - CampaignEmail.last.update!(mailgun_status: "delivered") + it "creates a campaign email for the diy survey" do post :update, params: params, session: { diy_intake_id: diy_intake.id } - CampaignEmail.last.update!(mailgun_status: "delivered") - expect do - post :update, params: params, session: { diy_intake_id: diy_intake.id } - end.not_to change(CampaignEmail, :count) - - contact = diy_intake.reload.campaign_contact - campaign_emails = CampaignEmail.where( - campaign_contact_id: contact.id, - message_name: "diy_followup_survey" + expect(CampaignEmail).to have_received(:create).with( + campaign_contact_id: diy_intake.campaign_contact.id, + message_name: "diy_followup_survey", + to_email: diy_intake.email_address, + scheduled_send_at: be_within(1.minute).of(Time.current + 1.day) ) - - expect(campaign_emails.count).to eq(2) end end end From d88e98815448a65e29228dfd14a397ce154a4c5c Mon Sep 17 00:00:00 2001 From: Drew Proebstel Date: Wed, 25 Mar 2026 14:45:05 -0700 Subject: [PATCH 8/8] checkout diy email address controller from main --- .../diy/diy_email_address_controller.rb | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/controllers/diy/diy_email_address_controller.rb b/app/controllers/diy/diy_email_address_controller.rb index 4974914791..3008f70654 100644 --- a/app/controllers/diy/diy_email_address_controller.rb +++ b/app/controllers/diy/diy_email_address_controller.rb @@ -31,17 +31,12 @@ def after_update_success contact = current_diy_intake.campaign_contact return unless contact.present? - existing_emails = CampaignEmail.where( - campaign_contact_id: contact.id, - message_name: "diy_followup_survey" - ) - - CampaignEmail.create!( - campaign_contact_id: contact.id, - message_name: "diy_followup_survey", - to_email: contact.email_address, - scheduled_send_at: Time.current + 1.day - ) + CampaignEmail.create( + campaign_contact_id: contact.id, + message_name: "diy_followup_survey", + to_email: contact.email_address, + scheduled_send_at: Time.current + 1.day + ) end end