feat(lifecycle): add Captain::Lifecycle::Rule model with filter matching

TDD: 16 examples passing. Adds EVENTS constant, active/for_event scopes,
and matches_reservation? with unit_ids/categorias/permanencias filters.
Also adds captain_reservation factory used by the spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-15 01:18:17 -03:00
parent 6ee3fcd4ef
commit ffc5ac7fb8
4 changed files with 180 additions and 0 deletions

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
class Captain::Lifecycle::Rule < ApplicationRecord
self.table_name = 'captain_lifecycle_rules'
EVENTS = %w[
reservation.confirmed
checkin.scheduled_at
checkin.detected
checkout.scheduled_at
checkout.detected
reservation.cancelled
reservation.no_show
].freeze
MESSAGE_TYPES = %w[text buttons list url_button].freeze
belongs_to :account
belongs_to :created_by_user, class_name: 'User', optional: true
validates :name, presence: true
validates :event, presence: true, inclusion: { in: EVENTS }
validates :message_body, presence: true
validates :message_type, inclusion: { in: MESSAGE_TYPES }
scope :active, -> { where(enabled: true) }
scope :for_event, ->(event) { where(event: event) }
def matches_reservation?(reservation)
return false unless reservation
filters_hash = filters.presence || {}
matches_unit?(filters_hash, reservation) &&
matches_categoria?(filters_hash, reservation) &&
matches_permanencia?(filters_hash, reservation)
end
private
def matches_unit?(filters_hash, reservation)
unit_ids = Array(filters_hash['unit_ids'])
return true if unit_ids.empty?
unit_ids.include?(reservation.captain_unit_id)
end
def matches_categoria?(filters_hash, reservation)
categorias = Array(filters_hash['categorias'])
return true if categorias.empty?
categorias.include?(reservation.suite_identifier)
end
def matches_permanencia?(filters_hash, reservation)
permanencias = Array(filters_hash['permanencias'])
return true if permanencias.empty?
actual = reservation.metadata.to_h['permanencia']
permanencias.include?(actual)
end
end

View File

@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Captain::Lifecycle::Rule, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
end
describe 'validations' do
subject { build(:captain_lifecycle_rule) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:event) }
it { is_expected.to validate_presence_of(:message_body) }
it { is_expected.to validate_inclusion_of(:event).in_array(Captain::Lifecycle::Rule::EVENTS) }
it { is_expected.to validate_inclusion_of(:message_type).in_array(%w[text buttons list url_button]) }
end
describe '.active' do
it 'returns only enabled rules' do
enabled = create(:captain_lifecycle_rule, enabled: true)
create(:captain_lifecycle_rule, enabled: false)
expect(described_class.active).to contain_exactly(enabled)
end
end
describe '.for_event' do
it 'filters by event' do
rule = create(:captain_lifecycle_rule, event: 'checkin.scheduled_at')
create(:captain_lifecycle_rule, event: 'reservation.confirmed')
expect(described_class.for_event('checkin.scheduled_at')).to contain_exactly(rule)
end
end
describe '#matches_reservation?' do
let(:account) { create(:account) }
let(:brand) { Captain::Brand.create!(account: account, name: 'Brand Test') }
let(:unit) { Captain::Unit.create!(account: account, captain_brand_id: brand.id, name: 'Águas Lindas') }
let(:reservation) do
create(:captain_reservation,
account: account,
unit: unit,
suite_identifier: 'Alexa',
metadata: { 'permanencia' => 'Pernoite' })
end
it 'matches when all filters empty' do
rule = build(:captain_lifecycle_rule, account: account, filters: {})
expect(rule.matches_reservation?(reservation)).to be(true)
end
it 'matches when unit_id in filter' do
rule = build(:captain_lifecycle_rule, account: account, filters: { 'unit_ids' => [unit.id] })
expect(rule.matches_reservation?(reservation)).to be(true)
end
it 'does not match when unit_id not in filter' do
rule = build(:captain_lifecycle_rule, account: account, filters: { 'unit_ids' => [999] })
expect(rule.matches_reservation?(reservation)).to be(false)
end
it 'matches when categoria in filter' do
rule = build(:captain_lifecycle_rule, account: account, filters: { 'categorias' => ['Alexa'] })
expect(rule.matches_reservation?(reservation)).to be(true)
end
it 'does not match when categoria not in filter' do
rule = build(:captain_lifecycle_rule, account: account, filters: { 'categorias' => ['Stilo'] })
expect(rule.matches_reservation?(reservation)).to be(false)
end
it 'matches when permanencia in filter' do
rule = build(:captain_lifecycle_rule, account: account, filters: { 'permanencias' => ['Pernoite'] })
expect(rule.matches_reservation?(reservation)).to be(true)
end
it 'requires all specified filters to match' do
rule = build(:captain_lifecycle_rule, account: account,
filters: { 'unit_ids' => [unit.id], 'categorias' => ['Stilo'] })
expect(rule.matches_reservation?(reservation)).to be(false)
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
FactoryBot.define do
factory :captain_reservation, class: 'Captain::Reservation' do
association :account
association :inbox
association :contact
association :contact_inbox
suite_identifier { 'Suite 101' }
check_in_at { 1.day.from_now.beginning_of_hour }
check_out_at { 2.days.from_now.beginning_of_hour }
status { :confirmed }
total_amount { 300.00 }
metadata { {} }
unit { nil }
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
FactoryBot.define do
factory :captain_lifecycle_rule, class: 'Captain::Lifecycle::Rule' do
account
sequence(:name) { |n| "Rule ##{n}" }
enabled { true }
event { 'checkin.scheduled_at' }
offset_minutes { -10 }
filters { {} }
message_type { 'text' }
message_body { 'Olá {{ customer.first_name }}, sua suíte {{ reservation.suite }} está pronta!' }
priority { 50 }
end
end