From eed9480a9eee04703aaadd82c36a226c91a07f04 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 7 Nov 2025 11:49:21 +0100 Subject: [PATCH] Move sending family invitation email to a background job --- app/jobs/family/invitations/cleanup_job.rb | 7 +- app/jobs/family/invitations/sending_job.rb | 13 ++++ app/services/families/invite.rb | 20 +++--- .../family/invitations/sending_job_spec.rb | 71 +++++++++++++++++++ spec/services/families/invite_spec.rb | 5 ++ 5 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 app/jobs/family/invitations/sending_job.rb create mode 100644 spec/jobs/family/invitations/sending_job_spec.rb diff --git a/app/jobs/family/invitations/cleanup_job.rb b/app/jobs/family/invitations/cleanup_job.rb index 2f00cdd0..a80ad443 100644 --- a/app/jobs/family/invitations/cleanup_job.rb +++ b/app/jobs/family/invitations/cleanup_job.rb @@ -13,9 +13,10 @@ class Family::Invitations::CleanupJob < ApplicationJob Rails.logger.info "Updated #{expired_count} expired family invitations" cleanup_threshold = 30.days.ago - deleted_count = Family::Invitation.where(status: [:expired, :cancelled]) - .where('updated_at < ?', cleanup_threshold) - .delete_all + deleted_count = + Family::Invitation.where(status: %i[expired cancelled]) + .where('updated_at < ?', cleanup_threshold) + .delete_all Rails.logger.info "Deleted #{deleted_count} old family invitations" diff --git a/app/jobs/family/invitations/sending_job.rb b/app/jobs/family/invitations/sending_job.rb new file mode 100644 index 00000000..da74dc52 --- /dev/null +++ b/app/jobs/family/invitations/sending_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Family::Invitations::SendingJob < ApplicationJob + queue_as :families + + def perform(invitation_id) + invitation = Family::Invitation.find_by(id: invitation_id) + + return unless invitation&.pending? + + FamilyMailer.invitation(invitation).deliver_now + end +end diff --git a/app/services/families/invite.rb b/app/services/families/invite.rb index c1d7796b..607ab94f 100644 --- a/app/services/families/invite.rb +++ b/app/services/families/invite.rb @@ -19,8 +19,8 @@ module Families return false unless invite_sendable? ActiveRecord::Base.transaction do - create_invitation - send_invitation_email + invitation = create_invitation + send_invitation_email(invitation) send_notification end @@ -80,16 +80,18 @@ module Families ) end - def send_invitation_email - # Send email in background with retry logic - FamilyMailer.invitation(@invitation).deliver_later( - queue: :mailer, - retry: 3, - wait: 30.seconds - ) + def send_invitation_email(invitation) + Families::Invitations::SendingJob.perform_later(invitation.id) end def send_notification + message = + if DawarichSettings.self_hosted? + "Family invitation sent to #{email} if SMTP is configured properly. If you're not using SMTP, copy the invitation link from the family page and share it manually." + else + "Family invitation sent to #{email}" + end + Notification.create!( user: invited_by, kind: :info, diff --git a/spec/jobs/family/invitations/sending_job_spec.rb b/spec/jobs/family/invitations/sending_job_spec.rb new file mode 100644 index 00000000..18ca8ffa --- /dev/null +++ b/spec/jobs/family/invitations/sending_job_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::Invitations::SendingJob, type: :job do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let(:invitation) { create(:family_invitation, family: family, invited_by: user, status: :pending) } + + describe '#perform' do + context 'when invitation exists and is pending' do + it 'sends the invitation email' do + mailer_double = double('mailer') + expect(FamilyMailer).to receive(:invitation).with(invitation).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_now) + + described_class.perform_now(invitation.id) + end + end + + context 'when invitation does not exist' do + it 'does not raise an error' do + expect do + described_class.perform_now(999_999) + end.not_to raise_error + end + + it 'does not send any email' do + expect(FamilyMailer).not_to receive(:invitation) + + described_class.perform_now(999_999) + end + end + + context 'when invitation is not pending' do + let(:accepted_invitation) do + create(:family_invitation, family: family, invited_by: user, status: :accepted) + end + + it 'does not send the invitation email' do + expect(FamilyMailer).not_to receive(:invitation) + + described_class.perform_now(accepted_invitation.id) + end + end + + context 'when invitation is cancelled' do + let(:cancelled_invitation) do + create(:family_invitation, family: family, invited_by: user, status: :cancelled) + end + + it 'does not send the invitation email' do + expect(FamilyMailer).not_to receive(:invitation) + + described_class.perform_now(cancelled_invitation.id) + end + end + + context 'integration test' do + it 'actually sends the email' do + expect do + described_class.perform_now(invitation.id) + end.to change { ActionMailer::Base.deliveries.count }.by(1) + + email = ActionMailer::Base.deliveries.last + expect(email.to).to include(invitation.email) + expect(email.subject).to include("You've been invited to join #{family.name}") + end + end + end +end diff --git a/spec/services/families/invite_spec.rb b/spec/services/families/invite_spec.rb index 087d3331..19ec4b5f 100644 --- a/spec/services/families/invite_spec.rb +++ b/spec/services/families/invite_spec.rb @@ -21,6 +21,11 @@ RSpec.describe Families::Invite do expect(invitation.invited_by).to eq(owner) end + it 'enqueues invitation sending job' do + expect(Family::Invitations::SendingJob).to receive(:perform_later).with(an_instance_of(Integer)) + service.call + end + it 'sends invitation email' do expect(FamilyMailer).to receive(:invitation).and_call_original expect_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_later)