diff --git a/enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb b/enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb new file mode 100644 index 000000000..69ebe66d1 --- /dev/null +++ b/enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Captain::Lifecycle::DispatcherJob < ApplicationJob + queue_as :default + + def self.perform_at(fire_at, delivery_id) + set(wait_until: fire_at).perform_later(delivery_id) + end + + def perform(delivery_id) + # Stub — full implementation lands in Task 16. + end +end diff --git a/enterprise/app/services/captain/lifecycle/scheduler.rb b/enterprise/app/services/captain/lifecycle/scheduler.rb new file mode 100644 index 000000000..18bf23e7b --- /dev/null +++ b/enterprise/app/services/captain/lifecycle/scheduler.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Captain::Lifecycle::Scheduler + CHECKIN_EVENTS = %w[checkin.scheduled_at checkin.detected].freeze + + class << self + def schedule_for(reservation) + rules = Captain::Lifecycle::Rule + .where(account_id: reservation.account_id) + .active + rules.each { |rule| schedule_one_rule(reservation, rule) } + end + + def cancel_pending(reservation) + Captain::Lifecycle::Delivery + .where(captain_reservation_id: reservation.id, status: 'scheduled') + .update_all(status: 'cancelled', updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + def reschedule_for_checkin_change(reservation) + Captain::Lifecycle::Delivery + .where(captain_reservation_id: reservation.id, status: 'scheduled') + .joins('INNER JOIN captain_lifecycle_rules ON captain_lifecycle_rules.id = captain_lifecycle_deliveries.lifecycle_rule_id') + .where(captain_lifecycle_rules: { event: CHECKIN_EVENTS }) + .update_all(status: 'cancelled', updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + + Captain::Lifecycle::Rule + .where(account_id: reservation.account_id, event: CHECKIN_EVENTS) + .active + .each { |rule| schedule_one_rule(reservation, rule) } + end + + private + + def schedule_one_rule(reservation, rule) + return unless rule.matches_reservation?(reservation) + + fire_at = compute_fire_at(reservation, rule) + return if fire_at.nil? || fire_at <= Time.current + + delivery = Captain::Lifecycle::Delivery.create!( + account_id: reservation.account_id, + lifecycle_rule_id: rule.id, + captain_reservation_id: reservation.id, + inbox_id: reservation.unit&.concierge_inbox_id, + fire_at: fire_at, + status: 'scheduled', + origin: 'scheduled_lifecycle' + ) + Captain::Lifecycle::DispatcherJob.perform_at(fire_at, delivery.id) + end + + def compute_fire_at(reservation, rule) + event_time = Captain::Lifecycle::EventResolver.resolve(reservation, rule.event) + return nil if event_time.nil? + + event_time + rule.offset_minutes.minutes + end + end +end diff --git a/spec/enterprise/services/captain/lifecycle/scheduler_spec.rb b/spec/enterprise/services/captain/lifecycle/scheduler_spec.rb new file mode 100644 index 000000000..f3c1ee5b4 --- /dev/null +++ b/spec/enterprise/services/captain/lifecycle/scheduler_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Captain::Lifecycle::Scheduler do + let(:account) { create(:account) } + let(:brand) { create(:captain_brand, account: account) } + let(:unit) { Captain::Unit.create!(account: account, brand: brand, name: 'Test Unit') } + let(:reservation) do + create(:captain_reservation, + account: account, + unit: unit, + check_in_at: 2.hours.from_now, + check_out_at: 10.hours.from_now) + end + + describe '.schedule_for' do + let!(:matching_rule) do + create(:captain_lifecycle_rule, + account: account, + event: 'checkin.scheduled_at', + offset_minutes: -10) + end + + it 'creates a scheduled delivery for each matching enabled rule' do + expect { described_class.schedule_for(reservation) } + .to change(Captain::Lifecycle::Delivery, :count).by(1) + end + + it 'sets fire_at to event_time + offset' do + described_class.schedule_for(reservation) + delivery = Captain::Lifecycle::Delivery.last + expect(delivery.fire_at).to be_within(1.second).of(reservation.check_in_at - 10.minutes) + end + + it 'enqueues a Sidekiq job for fire_at' do + expect(Captain::Lifecycle::DispatcherJob).to receive(:perform_at) + .with(kind_of(Time), kind_of(Integer)) + described_class.schedule_for(reservation) + end + + it 'skips disabled rules' do + matching_rule.update!(enabled: false) + expect { described_class.schedule_for(reservation) } + .not_to change(Captain::Lifecycle::Delivery, :count) + end + + it 'skips rules where filter does not match' do + matching_rule.update!(filters: { 'categorias' => ['DoesNotExist'] }) + expect { described_class.schedule_for(reservation) } + .not_to change(Captain::Lifecycle::Delivery, :count) + end + end + + describe '.cancel_pending' do + let!(:delivery) do + create(:captain_lifecycle_delivery, + account: account, + captain_reservation: reservation, + status: 'scheduled') + end + let!(:sent) do + create(:captain_lifecycle_delivery, + account: account, + captain_reservation: reservation, + status: 'sent') + end + + it 'marks scheduled deliveries as cancelled' do + described_class.cancel_pending(reservation) + expect(delivery.reload.status).to eq('cancelled') + end + + it 'does not touch sent deliveries' do + described_class.cancel_pending(reservation) + expect(sent.reload.status).to eq('sent') + end + end + + describe '.reschedule_for_checkin_change' do + let!(:checkin_rule) do + create(:captain_lifecycle_rule, + account: account, + event: 'checkin.scheduled_at', + offset_minutes: -10) + end + + before do + create(:captain_lifecycle_rule, + account: account, + event: 'reservation.confirmed', + offset_minutes: 0) + described_class.schedule_for(reservation) + end + + it 'cancels checkin-based scheduled deliveries' do + expect { described_class.reschedule_for_checkin_change(reservation) } + .to(change do + Captain::Lifecycle::Delivery.where(lifecycle_rule_id: checkin_rule.id, status: 'cancelled').count + end) + end + end +end