diff --git a/enterprise/app/models/captain/lifecycle/delivery.rb b/enterprise/app/models/captain/lifecycle/delivery.rb index 15582a276..db1a1e772 100644 --- a/enterprise/app/models/captain/lifecycle/delivery.rb +++ b/enterprise/app/models/captain/lifecycle/delivery.rb @@ -6,7 +6,7 @@ class Captain::Lifecycle::Delivery < ApplicationRecord belongs_to :account belongs_to :lifecycle_rule, class_name: 'Captain::Lifecycle::Rule', optional: true belongs_to :captain_reservation, class_name: 'Captain::Reservation' - belongs_to :conversation, optional: true + belongs_to :conversation, class_name: '::Conversation', optional: true belongs_to :message, optional: true belongs_to :inbox, optional: true diff --git a/enterprise/app/services/captain/lifecycle/guards/customer_replied.rb b/enterprise/app/services/captain/lifecycle/guards/customer_replied.rb new file mode 100644 index 000000000..fb5aeafcd --- /dev/null +++ b/enterprise/app/services/captain/lifecycle/guards/customer_replied.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Captain::Lifecycle::Guards::CustomerReplied < Captain::Lifecycle::Guards::Base + MAX_DELAY = 2.hours + + def check + config = Captain::Lifecycle::Config.for_account(@account) + return pass unless config.pause_on_customer_reply + + window = config.pause_on_customer_reply_within_minutes.to_i.minutes + conversation = resolve_conversation + return pass if conversation.blank? + + last_incoming_at = last_incoming_message_at(conversation) + return pass if last_incoming_at.blank? + + wait_until = last_incoming_at + window + return pass if @delivery.fire_at >= wait_until + + delay = wait_until - @delivery.fire_at + return skip('too_stale') if delay > MAX_DELAY + + reschedule(wait_until) + end + + private + + def last_incoming_message_at(conversation) + conversation.messages + .where(message_type: Message.message_types[:incoming]) + .maximum(:created_at) + end + + def resolve_conversation + return @delivery.conversation if @delivery.conversation.present? + + inbox_id = @delivery.inbox_id || @reservation.unit&.concierge_inbox_id + return nil if inbox_id.blank? + + @account.conversations + .where(contact_id: @reservation.contact_id, inbox_id: inbox_id) + .order(last_activity_at: :desc) + .first + end +end diff --git a/enterprise/app/services/captain/lifecycle/guards/min_interval.rb b/enterprise/app/services/captain/lifecycle/guards/min_interval.rb new file mode 100644 index 000000000..d9debf8f7 --- /dev/null +++ b/enterprise/app/services/captain/lifecycle/guards/min_interval.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Captain::Lifecycle::Guards::MinInterval < Captain::Lifecycle::Guards::Base + MAX_DELAY = 2.hours + + def check + config = Captain::Lifecycle::Config.for_account(@account) + interval = config.min_interval_minutes.to_i + return pass if interval <= 0 + + last_sent_at = last_sent_delivery_at + return pass if last_sent_at.blank? + + earliest_allowed = last_sent_at + interval.minutes + return pass if @delivery.fire_at >= earliest_allowed + + delay = earliest_allowed - @delivery.fire_at + return skip('too_stale') if delay > MAX_DELAY + + reschedule(earliest_allowed) + end + + private + + def last_sent_delivery_at + Captain::Lifecycle::Delivery + .where(captain_reservation_id: @reservation.id, + status: 'sent', + origin: 'scheduled_lifecycle') + .maximum(:sent_at) + end +end diff --git a/spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb b/spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb new file mode 100644 index 000000000..b532ffe81 --- /dev/null +++ b/spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Captain::Lifecycle::Guards::CustomerReplied do + subject(:guard) { described_class.new(delivery) } + + let(:account) { create(:account) } + let(:contact) { create(:contact, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:reservation) { create(:captain_reservation, account: account, contact: contact, inbox: inbox) } + let(:conversation) { create(:conversation, account: account, contact: contact, inbox: inbox) } + let(:delivery) do + d = create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + inbox: inbox, + fire_at: 1.minute.from_now) + d.update!(conversation_id: conversation.id) + d + end + + context 'when guard is disabled' do + before { create(:captain_lifecycle_config, account: account, pause_on_customer_reply: false) } + + it 'passes' do + expect(guard.check).to eq(action: :pass) + end + end + + context 'when guard is enabled with 60 min window' do + before do + create(:captain_lifecycle_config, + account: account, pause_on_customer_reply: true, pause_on_customer_reply_within_minutes: 60) + end + + it 'passes when no incoming messages exist' do + expect(guard.check).to eq(action: :pass) + end + + it 'reschedules when customer sent a message 10min ago' do + create(:message, + account: account, conversation: conversation, inbox: inbox, + message_type: 'incoming', content: 'Olá', sender: contact, + created_at: 10.minutes.ago) + + result = guard.check + expect(result[:action]).to eq(:reschedule) + expect(result[:fire_at]).to be_within(1.minute).of(10.minutes.ago + 60.minutes) + end + + it 'passes when the only messages are outgoing' do + agent = create(:user, account: account) + create(:message, + account: account, conversation: conversation, inbox: inbox, + message_type: 'outgoing', content: 'Respondendo', sender: agent, + created_at: 5.minutes.ago) + + expect(guard.check).to eq(action: :pass) + end + + it 'skips too_stale when customer replied more than 2h + window ago' do + # fire_at = 1 min from now; last_incoming = 3h ago; wait_until = 3h ago + 60min = 2h ago + # wait_until < fire_at → pass + create(:message, + account: account, conversation: conversation, inbox: inbox, + message_type: 'incoming', content: 'Mensagem antiga', sender: contact, + created_at: 3.hours.ago) + + expect(guard.check).to eq(action: :pass) + end + + it 'skips too_stale when delay exceeds MAX_DELAY' do + past_delivery = create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + inbox: inbox, + fire_at: 3.hours.ago) + past_delivery.update!(conversation_id: conversation.id) + past_guard = described_class.new(past_delivery) + + # last incoming = 10min ago; window = 60min; wait_until = 50min from now + # fire_at = 3h ago; delay = wait_until - fire_at ≈ 3h50m > 2h → too_stale + create(:message, + account: account, conversation: conversation, inbox: inbox, + message_type: 'incoming', content: 'Mensagem recente', sender: contact, + created_at: 10.minutes.ago) + + result = past_guard.check + expect(result[:action]).to eq(:skip) + expect(result[:reason]).to eq('too_stale') + end + end + + context 'when delivery has no conversation but delivery has inbox_id' do + subject(:guard_no_conv) { described_class.new(delivery_without_conv) } + + before do + create(:captain_lifecycle_config, + account: account, pause_on_customer_reply: true, pause_on_customer_reply_within_minutes: 60) + end + + let(:delivery_without_conv) do + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + inbox: inbox, + fire_at: 1.minute.from_now) + end + + it 'resolves conversation via inbox and contact' do + create(:message, + account: account, conversation: conversation, inbox: inbox, + message_type: 'incoming', content: 'Hey', sender: contact, + created_at: 5.minutes.ago) + + result = guard_no_conv.check + expect(result[:action]).to eq(:reschedule) + end + end +end diff --git a/spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb b/spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb new file mode 100644 index 000000000..1e18562ec --- /dev/null +++ b/spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Captain::Lifecycle::Guards::MinInterval do + subject(:guard) { described_class.new(delivery) } + + let(:account) { create(:account) } + let(:reservation) { create(:captain_reservation, account: account) } + let(:delivery) do + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, fire_at: 1.minute.from_now) + end + + context 'when min_interval_minutes = 0' do + before { create(:captain_lifecycle_config, account: account, min_interval_minutes: 0) } + + it 'passes' do + expect(guard.check).to eq(action: :pass) + end + end + + context 'when min_interval_minutes = 30' do + before { create(:captain_lifecycle_config, account: account, min_interval_minutes: 30) } + + it 'passes when no previous delivery was sent' do + expect(guard.check).to eq(action: :pass) + end + + it 'reschedules when a previous delivery was sent < 30min ago' do + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + status: 'sent', sent_at: 10.minutes.ago, origin: 'scheduled_lifecycle') + + result = guard.check + expect(result[:action]).to eq(:reschedule) + expect(result[:fire_at]).to be_within(1.minute).of(10.minutes.ago + 30.minutes) + end + + it 'skips with too_stale when rescheduled delay exceeds 2 hours' do + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + status: 'sent', sent_at: 3.hours.ago, origin: 'scheduled_lifecycle') + + # fire_at is 1 minute from now; earliest_allowed = 3.hours.ago + 30.min = ~2.5 hours ago + # earliest_allowed < fire_at → should pass + expect(guard.check).to eq(action: :pass) + end + + it 'skips with too_stale when delay between earliest_allowed and fire_at exceeds MAX_DELAY' do + # fire_at = 1 min from now; last sent_at = 1 minute ago → earliest_allowed = 29 min from now + # delay = earliest_allowed - fire_at = ~28 min → reschedule + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + status: 'sent', sent_at: 1.minute.ago, origin: 'scheduled_lifecycle') + + result = guard.check + expect(result[:action]).to eq(:reschedule) + end + + it 'skips too_stale when earliest_allowed is more than 2h after fire_at' do + # Make delivery fire_at in the past so earliest_allowed is far ahead + past_delivery = create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + fire_at: 3.hours.ago) + + past_guard = described_class.new(past_delivery) + + # sent 10min ago, interval 30min → earliest = 20min from now + # fire_at = 3h ago → delay = earliest - fire_at ≈ 3h20m > 2h → too_stale + create(:captain_lifecycle_delivery, + account: account, captain_reservation: reservation, + status: 'sent', sent_at: 10.minutes.ago, origin: 'scheduled_lifecycle') + + result = past_guard.check + expect(result[:action]).to eq(:skip) + expect(result[:reason]).to eq('too_stale') + end + end +end