feat(lifecycle): add Scheduler service and DispatcherJob stub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-15 01:35:31 -03:00
parent 4a88f7f517
commit bb4631f427
3 changed files with 176 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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