feat(lifecycle): add Dispatcher service with guards→render→send pipeline
Orchestrates guards → render (Liquid) → send pipeline for one delivery. Handles skip, reschedule, sent, failed states and re-enqueues on reschedule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d84a7586b
commit
0d4583a21a
127
enterprise/app/services/captain/lifecycle/dispatcher.rb
Normal file
127
enterprise/app/services/captain/lifecycle/dispatcher.rb
Normal file
@ -0,0 +1,127 @@
|
||||
class Captain::Lifecycle::Dispatcher
|
||||
GUARDS = [
|
||||
Captain::Lifecycle::Guards::ReservationActive,
|
||||
Captain::Lifecycle::Guards::OptOutLabel,
|
||||
Captain::Lifecycle::Guards::MaxPerReservation,
|
||||
Captain::Lifecycle::Guards::QuietHours,
|
||||
Captain::Lifecycle::Guards::MinInterval,
|
||||
Captain::Lifecycle::Guards::CustomerReplied
|
||||
].freeze
|
||||
|
||||
def initialize(delivery)
|
||||
@delivery = delivery
|
||||
end
|
||||
|
||||
def call
|
||||
return unless @delivery.status == 'scheduled'
|
||||
|
||||
return if handle_guard_result(run_guards)
|
||||
|
||||
rendered = render_template
|
||||
message = send_message(rendered)
|
||||
@delivery.mark_sent!(message: message, conversation: message.conversation, rendered_body: rendered)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[LifecycleDispatcher] delivery #{@delivery.id} failed: #{e.class} #{e.message}")
|
||||
@delivery.mark_failed!(e.message)
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns true if the guard handled (and halted) the delivery, false to proceed
|
||||
def handle_guard_result(result)
|
||||
case result[:action]
|
||||
when :skip
|
||||
@delivery.mark_skipped!(result[:reason])
|
||||
true
|
||||
when :reschedule
|
||||
apply_reschedule(result[:fire_at])
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def run_guards
|
||||
GUARDS.each do |klass|
|
||||
result = klass.new(@delivery).check
|
||||
return result if result[:action] != :pass
|
||||
end
|
||||
{ action: :pass }
|
||||
end
|
||||
|
||||
def apply_reschedule(new_fire_at)
|
||||
@delivery.update!(fire_at: new_fire_at)
|
||||
Captain::Lifecycle::DispatcherJob.perform_at(new_fire_at, @delivery.id)
|
||||
end
|
||||
|
||||
def render_template
|
||||
ctx = Captain::Lifecycle::ContextBuilder.build(@delivery.captain_reservation)
|
||||
rule = @delivery.lifecycle_rule
|
||||
Captain::PromptRenderer.render_string(rule.message_body.to_s, ctx)
|
||||
end
|
||||
|
||||
def send_message(rendered_body)
|
||||
reservation = @delivery.captain_reservation
|
||||
inbox = reservation.unit&.concierge_inbox
|
||||
raise 'Concierge inbox not configured for unit' if inbox.blank?
|
||||
|
||||
conversation = find_or_create_conversation(inbox, reservation)
|
||||
merge_unit_attribute(conversation, reservation)
|
||||
|
||||
rule = @delivery.lifecycle_rule
|
||||
assistant = concierge_assistant_for(inbox)
|
||||
msg = Messages::MessageBuilder.new(
|
||||
assistant, conversation,
|
||||
{ content: rendered_body, message_type: 'outgoing' }
|
||||
).perform
|
||||
|
||||
dispatch_interactive_if_needed(rule, reservation)
|
||||
msg
|
||||
end
|
||||
|
||||
def merge_unit_attribute(conversation, reservation)
|
||||
attrs = (conversation.custom_attributes || {}).merge(
|
||||
'current_unit_id' => reservation.captain_unit_id
|
||||
)
|
||||
conversation.update!(custom_attributes: attrs)
|
||||
end
|
||||
|
||||
def find_or_create_conversation(inbox, reservation)
|
||||
contact = reservation.contact
|
||||
existing = inbox.conversations.where(contact_id: contact.id).order(last_activity_at: :desc).first
|
||||
return existing if existing.present?
|
||||
|
||||
contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |ci|
|
||||
ci.source_id = contact.phone_number.to_s.gsub(/\D/, '')
|
||||
end
|
||||
|
||||
::Conversation.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id
|
||||
)
|
||||
end
|
||||
|
||||
def concierge_assistant_for(inbox)
|
||||
inbox.captain_inbox&.assistant
|
||||
end
|
||||
|
||||
def dispatch_interactive_if_needed(rule, reservation)
|
||||
return if rule.message_type == 'text' || rule.message_payload.blank?
|
||||
|
||||
inbox = reservation.unit.concierge_inbox
|
||||
provider = inbox.channel.try(:create_messaging_service) || inbox.channel
|
||||
return unless provider.respond_to?(:send_interactive_message)
|
||||
|
||||
payload = render_payload(rule.message_payload, reservation)
|
||||
provider.send_interactive_message(reservation.contact.phone_number, payload)
|
||||
end
|
||||
|
||||
def render_payload(payload, reservation)
|
||||
ctx = Captain::Lifecycle::ContextBuilder.build(reservation)
|
||||
rendered = Captain::PromptRenderer.render_string(payload.to_json, ctx)
|
||||
JSON.parse(rendered)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,79 @@
|
||||
# rubocop:disable RSpec/AnyInstance
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Lifecycle::Dispatcher do
|
||||
subject(:dispatcher) { described_class.new(delivery) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:brand) { create(:captain_brand, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:unit) { Captain::Unit.create!(account: account, brand: brand, name: 'Test', concierge_inbox_id: inbox.id) }
|
||||
let(:contact) { create(:contact, account: account, name: 'João Silva', phone_number: '+5561999999999') }
|
||||
let(:reservation) do
|
||||
create(:captain_reservation,
|
||||
account: account, unit: unit, contact: contact,
|
||||
suite_identifier: 'Alexa',
|
||||
check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now)
|
||||
end
|
||||
let(:rule) do
|
||||
create(:captain_lifecycle_rule,
|
||||
account: account, event: 'checkin.scheduled_at', offset_minutes: -10,
|
||||
message_body: 'Oi {{ customer.first_name }}, suíte {{ reservation.suite }}!')
|
||||
end
|
||||
let(:delivery) do
|
||||
create(:captain_lifecycle_delivery,
|
||||
account: account, captain_reservation: reservation,
|
||||
lifecycle_rule: rule, inbox: inbox, fire_at: 1.hour.from_now)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'when all guards pass (happy path)' do
|
||||
before do
|
||||
real_conversation = create(:conversation, account: account, inbox: inbox, contact: contact)
|
||||
real_msg = create(:message, account: account, inbox: inbox, conversation: real_conversation)
|
||||
allow_any_instance_of(described_class)
|
||||
.to receive(:send_message).and_return(real_msg)
|
||||
end
|
||||
|
||||
it 'renders the template and marks delivery sent' do
|
||||
dispatcher.call
|
||||
expect(delivery.reload.status).to eq('sent')
|
||||
expect(delivery.rendered_body).to include('João')
|
||||
expect(delivery.rendered_body).to include('Alexa')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when guard blocks with skip' do
|
||||
before { reservation.update!(status: 'cancelled') }
|
||||
|
||||
it 'marks delivery skipped with reason' do
|
||||
dispatcher.call
|
||||
expect(delivery.reload.status).to eq('skipped')
|
||||
expect(delivery.skip_reason).to eq('reservation_cancelled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when guard blocks with reschedule' do
|
||||
it 'reschedules the delivery and does not send' do
|
||||
new_time = 2.hours.from_now
|
||||
allow_any_instance_of(Captain::Lifecycle::Guards::QuietHours)
|
||||
.to receive(:check).and_return(action: :reschedule, fire_at: new_time)
|
||||
|
||||
expect(Captain::Lifecycle::DispatcherJob).to receive(:perform_at)
|
||||
dispatcher.call
|
||||
expect(delivery.reload.status).to eq('scheduled')
|
||||
expect(delivery.fire_at).to be_within(1.minute).of(new_time)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when delivery is not in scheduled state' do
|
||||
before { delivery.update!(status: 'cancelled') }
|
||||
|
||||
it 'aborts without side effects' do
|
||||
dispatcher.call
|
||||
expect(delivery.reload.status).to eq('cancelled')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable RSpec/AnyInstance
|
||||
Loading…
Reference in New Issue
Block a user