feat(lifecycle): add MinInterval and CustomerReplied guards
Implement guards following the same pass/reschedule/too_stale pattern as QuietHours. Also fix belongs_to :conversation on Delivery to use class_name: '::Conversation' to avoid namespace resolution failure inside Captain::Lifecycle module. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fcdc2054b5
commit
6d84a7586b
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user