From ffc5ac7fb8a327e68df2dbbd6abbaa82a0090938 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 15 Apr 2026 01:18:17 -0300 Subject: [PATCH] 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 --- .../app/models/captain/lifecycle/rule.rb | 61 +++++++++++++ .../models/captain/lifecycle/rule_spec.rb | 85 +++++++++++++++++++ spec/factories/captain/reservation.rb | 19 +++++ spec/factories/captain_lifecycle_rules.rb | 15 ++++ 4 files changed, 180 insertions(+) create mode 100644 enterprise/app/models/captain/lifecycle/rule.rb create mode 100644 spec/enterprise/models/captain/lifecycle/rule_spec.rb create mode 100644 spec/factories/captain/reservation.rb create mode 100644 spec/factories/captain_lifecycle_rules.rb diff --git a/enterprise/app/models/captain/lifecycle/rule.rb b/enterprise/app/models/captain/lifecycle/rule.rb new file mode 100644 index 000000000..2a6c453df --- /dev/null +++ b/enterprise/app/models/captain/lifecycle/rule.rb @@ -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 diff --git a/spec/enterprise/models/captain/lifecycle/rule_spec.rb b/spec/enterprise/models/captain/lifecycle/rule_spec.rb new file mode 100644 index 000000000..e950fea25 --- /dev/null +++ b/spec/enterprise/models/captain/lifecycle/rule_spec.rb @@ -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 diff --git a/spec/factories/captain/reservation.rb b/spec/factories/captain/reservation.rb new file mode 100644 index 000000000..4d7ff20e7 --- /dev/null +++ b/spec/factories/captain/reservation.rb @@ -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 diff --git a/spec/factories/captain_lifecycle_rules.rb b/spec/factories/captain_lifecycle_rules.rb new file mode 100644 index 000000000..718e3c041 --- /dev/null +++ b/spec/factories/captain_lifecycle_rules.rb @@ -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