Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/jobs/schedule_bounce_notifications_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class ScheduleBounceNotificationsJob < ApplicationJob
queue_as :bounce_notifications

def perform
CloudWatchService.record_job_started_metric(self.class.name)
CurrentJobLoggingAttributes.job_class = self.class.name
CurrentJobLoggingAttributes.job_id = job_id

SendBounceNotificationsJob.perform_later(bounced_on_date: Time.zone.yesterday.to_date)
end
end
21 changes: 21 additions & 0 deletions app/jobs/send_bounce_notifications_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class SendBounceNotificationsJob < ApplicationJob
queue_as :bounce_notifications

def perform(bounced_on_date:)
CloudWatchService.record_job_started_metric(self.class.name)
CurrentJobLoggingAttributes.job_class = self.class.name
CurrentJobLoggingAttributes.job_id = job_id

bounced_deliveries = Delivery.bounced_on_day(bounced_on_date)
bounced_deliveries.group_by(&:form_id).each do |form_id, deliveries|
form = deliveries.first.form
group = Api::V2::GroupResource.find(form_id)

group.group_admin_users.each do |user|
BounceNotificationMailer.bounce_notification_to_group_admins_email(form:, user:, deliveries:).deliver_now
end

Rails.logger.info "Sent bounce notifications to group admins for bounced deliveries on #{bounced_on_date.strftime('%-d %B %Y')} for form #{form_id}"
end
end
end
5 changes: 5 additions & 0 deletions app/lib/notify_utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module NotifyUtils
def make_notify_boolean(bool)
bool ? "yes" : "no"
end
end
67 changes: 67 additions & 0 deletions app/mailers/bounce_notification_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
class BounceNotificationMailer < GovukNotifyRails::Mailer
include NotifyUtils

def bounce_notification_to_group_admins_email(form:, user:, deliveries:)
set_template(Settings.govuk_notify.bounce_notification_to_group_admins_template_id)

# We're assuming that all bounces are for the same reason
hard_bounce = hard_bounce?(deliveries.first)

bounced_submissions = bounced_submissions(deliveries)
bounced_batches = bounced_batches(deliveries)

set_personalisation(
user_name: user.name,
submission_email: form.submission_email,
form_name: form.name,
has_bounced_submissions: make_notify_boolean(bounced_submissions.any?),
bounced_submissions_list: bounced_submissions,
has_bounced_batches: make_notify_boolean(bounced_batches.any?),
bounced_batches_list: bounced_batches,
hard_bounce: make_notify_boolean(hard_bounce),
soft_bounce: make_notify_boolean(!hard_bounce),
deadline_date: deadline_date(deliveries),
)

mail(to: user.email)
end

private

def hard_bounce?(delivery)
delivery.failure_details&.[]("bounceType") == "Permanent"
end

def bounced_submissions(deliveries)
deliveries.select(&:immediate?).sort_by(&:last_attempt_at).map do |delivery|
sent_at = delivery.last_attempt_at.in_time_zone(TimeZoneUtils.submission_time_zone)

I18n.t("mailer.bounce_notification.bounced_submission",
submission_reference: delivery.submissions.sole.reference,
sent_at_time: sent_at.strftime("%l:%M%P").strip,
sent_at_date: sent_at.strftime("%-d %B %Y"))
end
end

def bounced_batches(deliveries)
deliveries.select { |d| %w[daily weekly].include?(d.delivery_schedule) }.sort_by(&:last_attempt_at).map do |delivery|
sent_at = delivery.last_attempt_at.in_time_zone(TimeZoneUtils.submission_time_zone)
sent_at_time = sent_at.strftime("%l:%M%P").strip
sent_at_date = sent_at.strftime("%-d %B %Y")

if delivery.daily?
I18n.t("mailer.bounce_notification.bounced_daily_batch", sent_at_time:, sent_at_date:)
else
I18n.t("mailer.bounce_notification.bounced_weekly_batch", sent_at_time:, sent_at_date:)
end
end
end

def deadline_date(deliveries)
delivery_ids = deliveries.map(&:id)
earliest_submission_created_at = Submission.joins(:submission_deliveries).where(submission_deliveries: { delivery_id: delivery_ids }).minimum(:created_at)
earliest_submission_date_time = earliest_submission_created_at.in_time_zone(TimeZoneUtils.submission_time_zone)
deadline_date_time = earliest_submission_date_time + Settings.submissions.maximum_retention_seconds.seconds
deadline_date_time.strftime("%-d %B %Y")
end
end
6 changes: 2 additions & 4 deletions app/mailers/form_submission_confirmation_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class FormSubmissionConfirmationMailer < GovukNotifyRails::Mailer
include NotifyUtils

def send_confirmation_email(what_happens_next_markdown:, support_contact_details:, notify_response_id:, confirmation_email_address:, mailer_options:, submission_locale: :en, what_happens_next_markdown_cy: nil, support_contact_details_cy: nil)
@submission_locale = submission_locale.to_sym
set_template(template_id)
Expand Down Expand Up @@ -54,10 +56,6 @@ def default_support_contact_details_text
I18n.t("mailer.submission_confirmation.default_support_contact_details")
end

def make_notify_boolean(bool)
bool ? "yes" : "no"
end

def template_id
return Settings.govuk_notify.form_filler_confirmation_email_welsh_template_id if @submission_locale == :cy

Expand Down
13 changes: 13 additions & 0 deletions app/models/delivery.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ class Delivery < ApplicationRecord
scope :delivered, -> { where.not(delivered_at: nil).where(failed_at: nil).or(where("#{table_name}.delivered_at > failed_at")) }
scope :failed, -> { where.not(failed_at: nil).where(delivered_at: nil).or(where("#{table_name}.delivered_at <= failed_at")) }

scope :bounced_on_day, lambda { |date|
range = date.in_time_zone(TimeZoneUtils.submission_time_zone).all_day
failed.where(failure_reason: "bounced", failed_at: range)
}

enum :delivery_schedule, {
immediate: "immediate",
daily: "daily",
Expand Down Expand Up @@ -35,4 +40,12 @@ def new_attempt!
failure_details: nil,
)
end

def form_id
submissions.first&.form_id
end

def form
submissions.first&.form
end
end
10 changes: 10 additions & 0 deletions app/resources/api/v2/group_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class Api::V2::GroupResource < ActiveResource::Base
self.element_name = "group"
self.site = Settings.forms_api.base_url
self.prefix = "/api/v2/forms/:form_id/"
self.include_format_in_path = false

def self.find(form_id)
super(:one, from: "/api/v2/forms/#{form_id}/group")
end
end
4 changes: 4 additions & 0 deletions config/locales/cy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ cy:
layout:
skip_link: Neidio i’r prif gynnwys
mailer:
bounce_notification:
bounced_daily_batch: Daily batch sent at %{sent_at_time} on %{sent_at_date}
bounced_submission: "%{submission_reference} at %{sent_at_time} on %{sent_at_date}"
bounced_weekly_batch: Weekly batch sent at %{sent_at_time} on %{sent_at_date}
cannot_reply:
contact_form_filler_html: "<p>If you need to contact the person who completed this form, you’ll need to contact them directly.</p>"
contact_form_filler_plain: If you need to contact the person who completed this form, you’ll need to contact them directly.
Expand Down
4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ en:
layout:
skip_link: Skip to main content
mailer:
bounce_notification:
bounced_daily_batch: Daily batch sent at %{sent_at_time} on %{sent_at_date}
bounced_submission: "%{submission_reference} at %{sent_at_time} on %{sent_at_date}"
bounced_weekly_batch: Weekly batch sent at %{sent_at_time} on %{sent_at_date}
cannot_reply:
contact_form_filler_html: "<p>If you need to contact the person who completed this form, you’ll need to contact them directly.</p>"
contact_form_filler_plain: If you need to contact the person who completed this form, you’ll need to contact them directly.
Expand Down
3 changes: 3 additions & 0 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ development:
schedule_weekly_batch_deliveries_job:
class: ScheduleWeeklyBatchDeliveriesJob
schedule: every Monday at 2:15am Europe/London
schedule_bounce_notifications_job:
class: ScheduleBounceNotificationsJob
schedule: every day at 2:30am Europe/London
1 change: 1 addition & 0 deletions config/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ govuk_notify:
form_submission_email_template_id: 427eb8bc-ce0d-40a3-bf54-d76e8c3ec916
form_filler_confirmation_email_template_id: 2d1f36dc-9799-43dd-8673-b631f9e0b4a5
form_filler_confirmation_email_welsh_template_id: b297c8f1-419e-4af4-b067-b8cad6364daf
bounce_notification_to_group_admins_template_id: 9a93a110-c97d-4b51-87f3-a7ec5b9daddc

# Configuration for Sentry
# Sentry will only initialise if dsn is set to some other value
Expand Down
6 changes: 6 additions & 0 deletions spec/factories/admin_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :admin_user, class: DataStruct do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
end
end
43 changes: 43 additions & 0 deletions spec/factories/deliveries.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
delivery_reference { Faker::Alphanumeric.alphanumeric }
created_at { Time.current }
delivery_schedule { :immediate }
last_attempt_at { Time.current + 10.seconds }

trait :not_sent do
delivery_reference { nil }
Expand All @@ -24,24 +25,66 @@
failure_reason { "example" }
end

trait :bounced do
transient do
bounce_type { "Permanent" }
end

failed
failure_reason { "bounced" }
failure_details { { "bounceType" => bounce_type } }
end

trait :delivered_after_failure do
failed_at { created_at + 5.minutes }
delivered_at { created_at + 10.minutes }
failure_reason { "example" }
end

trait :delivered_after_bounce do
delivered_after_failure
failure_reason { "bounced" }
end

trait :failed_after_delivery do
delivered_at { created_at + 5.minutes }
failed_at { created_at + 10.minutes }
failure_reason { "example" }
end

trait :bounced_after_delivery do
failed_after_delivery
failure_reason { "bounced" }
end

trait :with_submissions do
transient do
submissions_count { 2 }
form_id { 101 }
end

submissions do
Array.new(submissions_count) { association(:submission, form_id:) }
end
end

trait :immediate do
submissions_count { 1 }
with_submissions

delivery_schedule { "immediate" }
end

trait :daily_scheduled_delivery do
with_submissions

delivery_schedule { "daily" }
batch_begin_at { created_at.beginning_of_day }
end

trait :weekly_scheduled_delivery do
with_submissions

delivery_schedule { "weekly" }
batch_begin_at { (created_at - 7.days).beginning_of_day }
end
Expand Down
22 changes: 22 additions & 0 deletions spec/factories/groups.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
FactoryBot.define do
factory :group, class: Api::V2::GroupResource do
sequence(:name) { |n| "Group #{n}" }

transient do
group_admin_users_count { 1 }
organisation_admin_users_count { 1 }
organisation_name { Faker::Company.name }
end

group_admin_users do
build_list(:admin_user, group_admin_users_count)
end

organisation do
{
name: organisation_name,
admin_users: build_list(:admin_user, organisation_admin_users_count),
}
end
end
end
9 changes: 9 additions & 0 deletions spec/jobs/schedule_bounce_notifications_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "rails_helper"

RSpec.describe ScheduleBounceNotificationsJob do
it "schedules a SendBounceNotifications job with yesterday's date" do
expect(SendBounceNotificationsJob).to receive(:perform_later).with(bounced_on_date: Time.zone.yesterday.to_date)

described_class.perform_now
end
end
70 changes: 70 additions & 0 deletions spec/jobs/send_bounce_notifications_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require "rails_helper"

RSpec.describe SendBounceNotificationsJob do
include ActiveSupport::Testing::TimeHelpers
include ActiveJob::TestHelper

let(:bounced_on_date) { Date.new(2026, 5, 6) }

let(:form_with_bounces) { build :form }
let(:other_form_with_bounces) { build :form }
let(:form_without_bounces) { build :form }
let(:group) { build :group, group_admin_users_count: 1 }
let(:other_group) { build :group, group_admin_users_count: 1 }

let(:req_headers) { { "Accept" => "application/json" } }

before do
create_list :delivery, 2, :bounced, :immediate, form_id: form_with_bounces.id, failed_at: Time.zone.local(2026, 5, 5, 23, 0, 0)
create :delivery, :delivered, :immediate, form_id: form_without_bounces.id

ActiveResource::HttpMock.respond_to do |mock|
mock.get "/api/v2/forms/#{form_with_bounces.form_id}/group", req_headers, group.to_json, 200
mock.get "/api/v2/forms/#{other_form_with_bounces.form_id}/group", req_headers, other_group.to_json, 200
end
end

context "when there are multiple forms with bounces on the date", :capture_logging do
before do
create_list :delivery, 2, :bounced, :daily_scheduled_delivery, form_id: other_form_with_bounces.id, failed_at: Time.zone.local(2026, 5, 6, 22, 59, 0)
described_class.perform_now(bounced_on_date:)
end

it "sends an email per form with bounced submissions" do
expect(ActionMailer::Base.deliveries.size).to eq 2
expect(ActionMailer::Base.deliveries.map(&:to).flatten).to contain_exactly(
group.group_admin_users.first.email,
other_group.group_admin_users.first.email,
)
end

it "logs a message for each form with bounced submissions" do
expect(log_lines).to include(
hash_including(
"level" => "INFO",
"message" => "Sent bounce notifications to group admins for bounced deliveries on 6 May 2026 for form #{form_with_bounces.form_id}",
),
hash_including(
"level" => "INFO",
"message" => "Sent bounce notifications to group admins for bounced deliveries on 6 May 2026 for form #{other_form_with_bounces.form_id}",
),
)
end
end

context "when the group has multiple group admins" do
let(:group) { build :group, group_admin_users_count: 2 }

before do
described_class.perform_now(bounced_on_date:)
end

it "sends an email to each group admin" do
expect(ActionMailer::Base.deliveries.size).to eq 2
expect(ActionMailer::Base.deliveries.map(&:to).flatten).to contain_exactly(
group.group_admin_users.first.email,
group.group_admin_users.second.email,
)
end
end
end
Loading