diff --git a/enterprise/app/services/captain/lifecycle/dispatcher.rb b/enterprise/app/services/captain/lifecycle/dispatcher.rb new file mode 100644 index 000000000..31238eaa2 --- /dev/null +++ b/enterprise/app/services/captain/lifecycle/dispatcher.rb @@ -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 diff --git a/spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb b/spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb new file mode 100644 index 000000000..ace555a0e --- /dev/null +++ b/spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb @@ -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