# Jornada do Cliente — Backend (Fases A + C) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship the complete backend of Jornada do Cliente — lifecycle rule engine, scheduler, dispatcher pipeline with anti-ban guards, interactive-message support in WuzAPI, and the concierge AI (Sofia) wiring. Produces a headless but fully functional system that can be exercised via Rails console / API before the admin UI (Fase B) is built. **Architecture:** Event-driven scheduler enfileira `LifecycleDispatcherJob` no Sidekiq com `perform_at(fire_at, delivery_id)`. Guards checam no runtime antes de enviar via WuzAPI. Sofia é um `Captain::Assistant` com orchestrator prompt Liquid que injeta `{{ concierge.* }}` dinamicamente baseado em `conversation.custom_attributes['current_unit_id']`. **Tech Stack:** - Rails 7.1 + PostgreSQL + Sidekiq + Wisper - `Captain::PromptRenderer` (Liquid) já existente - WuzAPI via `Wuzapi::Client` + `Whatsapp::Providers::WuzapiService` - RSpec + FactoryBot + WebMock **Spec:** `docs/superpowers/specs/2026-04-15-jornada-do-cliente-design.md` **Out of scope (this plan):** - Admin UI (Fase B — nova aba "Jornada do Cliente", wizard, histórico, editor) — será outro plano - Detecção heurística de `checkin.detected`/`checkout.detected` (fase 2+) - Dashboard analítico e métricas Prometheus (fase 2+) - Multi-idioma --- ## File Structure ### Novos arquivos (models + migrations) - `db/migrate/YYYYMMDDHHMMSS_create_captain_lifecycle_tables.rb` — 3 tabelas novas - `db/migrate/YYYYMMDDHHMMSS_add_concierge_to_captain_units.rb` — 2 colunas em `captain_units` - `enterprise/app/models/captain/lifecycle/rule.rb` - `enterprise/app/models/captain/lifecycle/delivery.rb` - `enterprise/app/models/captain/lifecycle/config.rb` ### Serviços e jobs - `enterprise/app/services/captain/lifecycle/event_resolver.rb` — resolve evento → timestamp - `enterprise/app/services/captain/lifecycle/rule_matcher.rb` — encontra regras que batem com uma reserva num evento - `enterprise/app/services/captain/lifecycle/scheduler.rb` — orquestra schedule/cancel/reschedule - `enterprise/app/services/captain/lifecycle/context_builder.rb` — monta hash de variáveis (customer, reservation, hotel) pro Liquid - `enterprise/app/services/captain/lifecycle/dispatcher.rb` — pipeline com guards, render e send - `enterprise/app/services/captain/lifecycle/guards/base.rb` - `enterprise/app/services/captain/lifecycle/guards/reservation_active.rb` - `enterprise/app/services/captain/lifecycle/guards/opt_out_label.rb` - `enterprise/app/services/captain/lifecycle/guards/max_per_reservation.rb` - `enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb` - `enterprise/app/services/captain/lifecycle/guards/min_interval.rb` - `enterprise/app/services/captain/lifecycle/guards/customer_replied.rb` - `enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb` ### Extensões de código existente - `enterprise/app/models/captain/unit.rb` — accessors pra `concierge_config` jsonb - `enterprise/app/models/captain/reservation.rb` — hooks `after_commit` / `after_update` chamando scheduler - `enterprise/app/models/concerns/agentable.rb` — injeção de `concierge` no enhanced_context - `enterprise/lib/captain/prompts/concierge.liquid` — template padrão da Sofia - `app/services/wuzapi/client.rb` — `send_buttons`, `send_list`, `send_url_button` - `app/services/whatsapp/providers/wuzapi_service.rb` — dispatch de mensagens interativas ### Specs - `spec/enterprise/models/captain/lifecycle/rule_spec.rb` - `spec/enterprise/models/captain/lifecycle/delivery_spec.rb` - `spec/enterprise/models/captain/lifecycle/config_spec.rb` - `spec/enterprise/services/captain/lifecycle/event_resolver_spec.rb` - `spec/enterprise/services/captain/lifecycle/rule_matcher_spec.rb` - `spec/enterprise/services/captain/lifecycle/scheduler_spec.rb` - `spec/enterprise/services/captain/lifecycle/context_builder_spec.rb` - `spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb` - `spec/enterprise/services/captain/lifecycle/guards/*_spec.rb` - `spec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rb` - `spec/enterprise/integration/captain/lifecycle_flow_spec.rb` ### Factories - `spec/factories/captain_lifecycle_rules.rb` - `spec/factories/captain_lifecycle_deliveries.rb` - `spec/factories/captain_lifecycle_configs.rb` --- ## Task 1: Migrations for lifecycle tables **Files:** - Create: `db/migrate/_create_captain_lifecycle_tables.rb` - Create: `db/migrate/_add_concierge_to_captain_units.rb` - [ ] **Step 1: Generate migration 1 — lifecycle tables** Run: `bundle exec bin/rails generate migration CreateCaptainLifecycleTables` - [ ] **Step 2: Write migration 1** Replace the generated file with: ```ruby class CreateCaptainLifecycleTables < ActiveRecord::Migration[7.1] def change create_table :captain_lifecycle_rules do |t| t.references :account, null: false, foreign_key: true, index: true t.string :name, null: false t.text :description t.boolean :enabled, null: false, default: true t.string :event, null: false t.integer :offset_minutes, null: false, default: 0 t.jsonb :filters, null: false, default: {} t.string :message_type, null: false, default: 'text' t.text :message_body, null: false t.jsonb :message_payload t.integer :priority, null: false, default: 50 t.references :created_by_user, foreign_key: { to_table: :users }, index: true t.timestamps end add_index :captain_lifecycle_rules, %i[account_id enabled event] create_table :captain_lifecycle_deliveries do |t| t.references :account, null: false, foreign_key: true, index: true t.references :lifecycle_rule, foreign_key: { to_table: :captain_lifecycle_rules }, index: { name: 'idx_lifecycle_deliveries_rule' } t.references :captain_reservation, null: false, foreign_key: true, index: { name: 'idx_lifecycle_deliveries_reservation' } t.references :conversation, foreign_key: true t.references :message, foreign_key: true t.references :inbox, foreign_key: true t.datetime :fire_at, null: false t.datetime :sent_at t.string :status, null: false, default: 'scheduled' t.string :skip_reason t.text :failure_reason t.text :rendered_body t.string :origin, null: false, default: 'scheduled_lifecycle' t.timestamps end add_index :captain_lifecycle_deliveries, %i[captain_reservation_id origin status], name: 'idx_lifecycle_deliveries_cap_check' add_index :captain_lifecycle_deliveries, %i[account_id status fire_at], name: 'idx_lifecycle_deliveries_dashboard' add_index :captain_lifecycle_deliveries, :fire_at, where: "status = 'scheduled'", name: 'idx_lifecycle_deliveries_scheduled' create_table :captain_lifecycle_configs do |t| t.references :account, null: false, foreign_key: true, index: { unique: true } t.boolean :quiet_hours_enabled, null: false, default: false t.time :quiet_hours_from, null: false, default: '23:00' t.time :quiet_hours_to, null: false, default: '08:00' t.integer :min_interval_minutes, null: false, default: 30 t.boolean :pause_on_customer_reply, null: false, default: false t.integer :pause_on_customer_reply_within_minutes, null: false, default: 60 t.references :opt_out_label, foreign_key: { to_table: :labels } t.timestamps end end end ``` - [ ] **Step 3: Generate migration 2 — captain_units extensions** Run: `bundle exec bin/rails generate migration AddConciergeToCaptainUnits` - [ ] **Step 4: Write migration 2** ```ruby class AddConciergeToCaptainUnits < ActiveRecord::Migration[7.1] def change add_reference :captain_units, :concierge_inbox, foreign_key: { to_table: :inboxes }, null: true add_column :captain_units, :concierge_config, :jsonb, null: false, default: {} end end ``` - [ ] **Step 5: Run migrations** Run: `bundle exec bin/rails db:migrate` Expected: both migrations apply, no errors. - [ ] **Step 6: Verify schema** Run: `bundle exec bin/rails runner 'puts ActiveRecord::Base.connection.tables.grep(/lifecycle/).sort'` Expected output: ``` captain_lifecycle_configs captain_lifecycle_deliveries captain_lifecycle_rules ``` Run: `bundle exec bin/rails runner 'puts Captain::Unit.column_names.grep(/concierge/).sort'` Expected output: ``` concierge_config concierge_inbox_id ``` - [ ] **Step 7: Commit** ```bash git add db/migrate/*lifecycle* db/migrate/*concierge* db/schema.rb git commit -m "feat(lifecycle): add captain_lifecycle_* tables and concierge columns on captain_units" ``` --- ## Task 2: Captain::Lifecycle::Config model Config is the smallest of the three models (account-wide singleton). Do it first to establish the module pattern. **Files:** - Create: `enterprise/app/models/captain/lifecycle.rb` (namespace module) - Create: `enterprise/app/models/captain/lifecycle/config.rb` - Create: `spec/enterprise/models/captain/lifecycle/config_spec.rb` - Create: `spec/factories/captain_lifecycle_configs.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/models/captain/lifecycle/config_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Config, type: :model do describe 'associations' do it { is_expected.to belong_to(:account) } it { is_expected.to belong_to(:opt_out_label).class_name('Label').optional } end describe 'validations' do subject { build(:captain_lifecycle_config) } it { is_expected.to validate_uniqueness_of(:account_id) } it { is_expected.to validate_numericality_of(:min_interval_minutes).is_greater_than_or_equal_to(0) } it { is_expected.to validate_numericality_of(:pause_on_customer_reply_within_minutes).is_greater_than_or_equal_to(0) } end describe '.for_account' do let(:account) { create(:account) } it 'returns existing config when present' do config = create(:captain_lifecycle_config, account: account) expect(described_class.for_account(account)).to eq(config) end it 'creates default config when missing' do expect { described_class.for_account(account) } .to change(described_class, :count).by(1) end it 'defaults have quiet_hours disabled' do config = described_class.for_account(account) expect(config.quiet_hours_enabled).to be(false) end end end ``` - [ ] **Step 2: Write the factory** ```ruby # spec/factories/captain_lifecycle_configs.rb FactoryBot.define do factory :captain_lifecycle_config, class: 'Captain::Lifecycle::Config' do account quiet_hours_enabled { false } quiet_hours_from { '23:00' } quiet_hours_to { '08:00' } min_interval_minutes { 30 } pause_on_customer_reply { false } pause_on_customer_reply_within_minutes { 60 } end end ``` - [ ] **Step 3: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/models/captain/lifecycle/config_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle` (or `Config`). - [ ] **Step 4: Create namespace module** ```ruby # enterprise/app/models/captain/lifecycle.rb module Captain module Lifecycle end end ``` - [ ] **Step 5: Create Config model** ```ruby # enterprise/app/models/captain/lifecycle/config.rb class Captain::Lifecycle::Config < ApplicationRecord self.table_name = 'captain_lifecycle_configs' belongs_to :account belongs_to :opt_out_label, class_name: 'Label', optional: true validates :account_id, uniqueness: true validates :min_interval_minutes, numericality: { greater_than_or_equal_to: 0 } validates :pause_on_customer_reply_within_minutes, numericality: { greater_than_or_equal_to: 0 } def self.for_account(account) find_or_create_by!(account_id: account.id) end end ``` - [ ] **Step 6: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/models/captain/lifecycle/config_spec.rb` Expected: all examples pass. - [ ] **Step 7: Commit** ```bash git add enterprise/app/models/captain/lifecycle.rb enterprise/app/models/captain/lifecycle/config.rb spec/enterprise/models/captain/lifecycle/config_spec.rb spec/factories/captain_lifecycle_configs.rb git commit -m "feat(lifecycle): add Captain::Lifecycle::Config model" ``` --- ## Task 3: Captain::Lifecycle::Rule model **Files:** - Create: `enterprise/app/models/captain/lifecycle/rule.rb` - Create: `spec/enterprise/models/captain/lifecycle/rule_spec.rb` - Create: `spec/factories/captain_lifecycle_rules.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/models/captain/lifecycle/rule_spec.rb 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(:unit) { create(:captain_unit, account: account, name: 'Águas Lindas') } let(:reservation) do create(:captain_reservation, account: account, captain_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 ``` - [ ] **Step 2: Write factory** ```ruby # spec/factories/captain_lifecycle_rules.rb 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 ``` - [ ] **Step 3: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/models/captain/lifecycle/rule_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::Rule`. - [ ] **Step 4: Write the model** ```ruby # enterprise/app/models/captain/lifecycle/rule.rb 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 ``` - [ ] **Step 5: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/models/captain/lifecycle/rule_spec.rb` Expected: all examples pass. - [ ] **Step 6: Commit** ```bash git add enterprise/app/models/captain/lifecycle/rule.rb spec/enterprise/models/captain/lifecycle/rule_spec.rb spec/factories/captain_lifecycle_rules.rb git commit -m "feat(lifecycle): add Captain::Lifecycle::Rule model with filter matching" ``` --- ## Task 4: Captain::Lifecycle::Delivery model **Files:** - Create: `enterprise/app/models/captain/lifecycle/delivery.rb` - Create: `spec/enterprise/models/captain/lifecycle/delivery_spec.rb` - Create: `spec/factories/captain_lifecycle_deliveries.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/models/captain/lifecycle/delivery_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Delivery, type: :model do describe 'associations' do it { is_expected.to belong_to(:account) } it { is_expected.to belong_to(:lifecycle_rule).class_name('Captain::Lifecycle::Rule').optional } it { is_expected.to belong_to(:captain_reservation).class_name('Captain::Reservation') } it { is_expected.to belong_to(:conversation).optional } it { is_expected.to belong_to(:message).optional } it { is_expected.to belong_to(:inbox).optional } end describe 'validations' do subject { build(:captain_lifecycle_delivery) } it { is_expected.to validate_presence_of(:fire_at) } it { is_expected.to validate_inclusion_of(:status).in_array(%w[scheduled sent skipped failed cancelled]) } end describe '.scheduled / .sent / .skipped' do it 'scopes by status' do s = create(:captain_lifecycle_delivery, status: 'scheduled') create(:captain_lifecycle_delivery, status: 'sent') expect(described_class.scheduled).to contain_exactly(s) end end describe '.count_sent_for_reservation' do let(:reservation) { create(:captain_reservation) } it 'counts only sent scheduled_lifecycle deliveries' do create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'scheduled_lifecycle') create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'scheduled_lifecycle') create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'skipped', origin: 'scheduled_lifecycle') create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'concierge_reply') expect(described_class.count_sent_for_reservation(reservation.id)).to eq(2) end end describe '#mark_skipped!' do let(:delivery) { create(:captain_lifecycle_delivery, status: 'scheduled') } it 'updates status and skip_reason' do delivery.mark_skipped!('quiet_hours') expect(delivery.reload.status).to eq('skipped') expect(delivery.skip_reason).to eq('quiet_hours') end end describe '#mark_cancelled!' do let(:delivery) { create(:captain_lifecycle_delivery, status: 'scheduled') } it 'updates status' do delivery.mark_cancelled! expect(delivery.reload.status).to eq('cancelled') end end end ``` - [ ] **Step 2: Write factory** ```ruby # spec/factories/captain_lifecycle_deliveries.rb FactoryBot.define do factory :captain_lifecycle_delivery, class: 'Captain::Lifecycle::Delivery' do account lifecycle_rule { association :captain_lifecycle_rule, account: account } captain_reservation { association :captain_reservation, account: account } fire_at { 1.hour.from_now } status { 'scheduled' } origin { 'scheduled_lifecycle' } end end ``` - [ ] **Step 3: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/models/captain/lifecycle/delivery_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::Delivery`. - [ ] **Step 4: Write the model** ```ruby # enterprise/app/models/captain/lifecycle/delivery.rb class Captain::Lifecycle::Delivery < ApplicationRecord self.table_name = 'captain_lifecycle_deliveries' STATUSES = %w[scheduled sent skipped failed cancelled].freeze belongs_to :account belongs_to :lifecycle_rule, class_name: 'Captain::Lifecycle::Rule', optional: true belongs_to :captain_reservation, class_name: 'Captain::Reservation' belongs_to :conversation, optional: true belongs_to :message, optional: true belongs_to :inbox, optional: true validates :fire_at, presence: true validates :status, inclusion: { in: STATUSES } scope :scheduled, -> { where(status: 'scheduled') } scope :sent, -> { where(status: 'sent') } scope :skipped, -> { where(status: 'skipped') } scope :for_reservation, ->(id) { where(captain_reservation_id: id) } def self.count_sent_for_reservation(reservation_id) for_reservation(reservation_id) .where(status: 'sent', origin: 'scheduled_lifecycle') .count end def mark_skipped!(reason) update!(status: 'skipped', skip_reason: reason) end def mark_cancelled! update!(status: 'cancelled') end def mark_sent!(message:, conversation:, rendered_body:) update!( status: 'sent', sent_at: Time.current, message_id: message.id, conversation_id: conversation.id, rendered_body: rendered_body ) end def mark_failed!(error) update!(status: 'failed', failure_reason: error.to_s.first(2000)) end end ``` - [ ] **Step 5: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/models/captain/lifecycle/delivery_spec.rb` Expected: all examples pass. - [ ] **Step 6: Commit** ```bash git add enterprise/app/models/captain/lifecycle/delivery.rb spec/enterprise/models/captain/lifecycle/delivery_spec.rb spec/factories/captain_lifecycle_deliveries.rb git commit -m "feat(lifecycle): add Captain::Lifecycle::Delivery model with helpers" ``` --- ## Task 5: Captain::Unit concierge accessors Adds helper methods pro jsonb `concierge_config` pra não espalhar `config['knowledge']` pelo codebase. **Files:** - Modify: `enterprise/app/models/captain/unit.rb` - Create: `spec/enterprise/models/captain/unit_concierge_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/models/captain/unit_concierge_spec.rb require 'rails_helper' RSpec.describe Captain::Unit, 'concierge accessors' do subject(:unit) { build(:captain_unit, concierge_config: config) } context 'when config is empty' do let(:config) { {} } it '#concierge_persona_name defaults to Sofia' do expect(unit.concierge_persona_name).to eq('Sofia') end it '#concierge_knowledge is empty string' do expect(unit.concierge_knowledge).to eq('') end it '#concierge_variables is empty hash' do expect(unit.concierge_variables).to eq({}) end end context 'when config is populated' do let(:config) do { 'persona_name' => 'Alice', 'knowledge' => '## Sobre o hotel\n...', 'variables' => { 'wifi_password' => 'hotel1001' } } end it 'returns persona_name from config' do expect(unit.concierge_persona_name).to eq('Alice') end it 'returns knowledge from config' do expect(unit.concierge_knowledge).to eq('## Sobre o hotel\n...') end it 'returns variables from config' do expect(unit.concierge_variables).to eq('wifi_password' => 'hotel1001') end end describe '#concierge_configured?' do it 'is false when inbox missing' do unit = build(:captain_unit, concierge_inbox_id: nil) expect(unit.concierge_configured?).to be(false) end it 'is true when inbox present' do inbox = create(:inbox) unit = build(:captain_unit, concierge_inbox_id: inbox.id) expect(unit.concierge_configured?).to be(true) end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/models/captain/unit_concierge_spec.rb` Expected: FAIL — `undefined method 'concierge_persona_name'`. - [ ] **Step 3: Add accessors to Captain::Unit** Append to `enterprise/app/models/captain/unit.rb` (before the final `end`): ```ruby belongs_to :concierge_inbox, class_name: 'Inbox', optional: true def concierge_persona_name concierge_config_hash['persona_name'].presence || 'Sofia' end def concierge_knowledge concierge_config_hash['knowledge'].to_s end def concierge_variables concierge_config_hash['variables'].to_h end def concierge_configured? concierge_inbox_id.present? end private def concierge_config_hash (concierge_config || {}).with_indifferent_access end ``` (If `private` already exists in the class, place the helper above it and keep only the new public methods in the public section.) - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/models/captain/unit_concierge_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/models/captain/unit.rb spec/enterprise/models/captain/unit_concierge_spec.rb git commit -m "feat(lifecycle): add concierge_* accessors to Captain::Unit" ``` --- ## Task 6: Wuzapi::Client interactive message methods WuzAPI supports buttons/list/url via its REST endpoints. Add 3 new client methods. **Files:** - Modify: `app/services/wuzapi/client.rb` - Create: `spec/services/wuzapi/client_interactive_spec.rb` - [ ] **Step 1: Write failing spec (use WebMock)** ```ruby # spec/services/wuzapi/client_interactive_spec.rb require 'rails_helper' RSpec.describe Wuzapi::Client, 'interactive messages' do let(:base_url) { 'https://wuzapi.test' } let(:client) { described_class.new(base_url) } let(:user_token) { 'tok' } let(:phone) { '5561999999999' } describe '#send_buttons' do it 'POSTs to /chat/sendbuttons with buttons payload' do stub = stub_request(:post, "#{base_url}/chat/sendbuttons") .with( headers: { 'Token' => user_token }, body: hash_including( Phone: phone, Text: 'Curtiu?', Buttons: [ { DisplayText: 'Sim' }, { DisplayText: 'Não' } ] ) ) .to_return(status: 200, body: { Id: 'msg-1' }.to_json, headers: { 'Content-Type' => 'application/json' }) response = client.send_buttons( user_token, phone, 'Curtiu?', [{ text: 'Sim' }, { text: 'Não' }] ) expect(response).to be_success expect(stub).to have_been_requested end end describe '#send_list' do it 'POSTs to /chat/sendlist with sections' do stub = stub_request(:post, "#{base_url}/chat/sendlist") .with(headers: { 'Token' => user_token }) .to_return(status: 200, body: { Id: 'msg-2' }.to_json, headers: { 'Content-Type' => 'application/json' }) response = client.send_list( user_token, phone, text: 'Cardápio', button_text: 'Ver opções', sections: [{ title: 'Bebidas', rows: [{ title: 'Água', row_id: 'agua' }] }] ) expect(response).to be_success expect(stub).to have_been_requested end end describe '#send_url_button' do it 'POSTs to /chat/sendbuttons with URL button' do stub = stub_request(:post, "#{base_url}/chat/sendbuttons") .with(body: hash_including(Buttons: [hash_including(Type: 'url')])) .to_return(status: 200, body: { Id: 'msg-3' }.to_json, headers: { 'Content-Type' => 'application/json' }) response = client.send_url_button(user_token, phone, text: 'Avalie no Google', button_text: 'Avaliar', url: 'https://g.page/r/XYZ') expect(response).to be_success expect(stub).to have_been_requested end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/services/wuzapi/client_interactive_spec.rb` Expected: FAIL — `undefined method 'send_buttons' for #`. - [ ] **Step 3: Add methods to Wuzapi::Client** Insert before the `def send_reaction` method (line ~70): ```ruby # Sends quick-reply buttons (up to 3 by WhatsApp limit). # buttons: array of { text: String } def send_buttons(user_token, phone_number, text, buttons) payload = { Phone: phone_number, Text: text, Buttons: buttons.map { |b| { DisplayText: b[:text] || b['text'] } } } request(:post, '/chat/sendbuttons', payload, user_auth_headers(user_token)) end # Sends a list menu (sections with rows, up to 10 rows total). # sections: array of { title:, rows: [{ title:, description:, row_id: }] } def send_list(user_token, phone_number, text:, button_text:, sections:, footer: nil) payload = { Phone: phone_number, Text: text, ButtonText: button_text, FooterText: footer, Sections: sections.map do |s| { Title: s[:title] || s['title'], Rows: Array(s[:rows] || s['rows']).map do |r| { Title: r[:title] || r['title'], Description: r[:description] || r['description'], RowId: r[:row_id] || r['row_id'] } end } end }.compact request(:post, '/chat/sendlist', payload, user_auth_headers(user_token)) end # Sends a single button that opens a URL when clicked. def send_url_button(user_token, phone_number, text:, button_text:, url:) payload = { Phone: phone_number, Text: text, Buttons: [{ Type: 'url', DisplayText: button_text, Url: url }] } request(:post, '/chat/sendbuttons', payload, user_auth_headers(user_token)) end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/services/wuzapi/client_interactive_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add app/services/wuzapi/client.rb spec/services/wuzapi/client_interactive_spec.rb git commit -m "feat(wuzapi): add send_buttons, send_list, send_url_button methods" ``` --- ## Task 7: WuzapiService dispatch for interactive messages Wires the new client methods through the provider so that outgoing messages with structured payload go out as buttons/list instead of plain text. **Files:** - Modify: `app/services/whatsapp/providers/wuzapi_service.rb` - Create: `spec/services/whatsapp/providers/wuzapi_interactive_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/services/whatsapp/providers/wuzapi_interactive_spec.rb require 'rails_helper' RSpec.describe Whatsapp::Providers::WuzapiService, 'interactive send' do let(:channel) { create(:channel_whatsapp, provider: 'wuzapi') } let(:service) { described_class.new(whatsapp_channel: channel) } let(:phone) { '+5561999999999' } let(:wuzapi_client) { instance_double(Wuzapi::Client) } before do allow(Wuzapi::Client).to receive(:new).and_return(wuzapi_client) allow(channel).to receive(:wuzapi_user_token).and_return('tok') end describe '#send_interactive_message' do it 'dispatches quick_reply buttons' do payload = { 'type' => 'quick_reply', 'body' => 'Curtiu?', 'buttons' => [{ 'id' => 'yes', 'text' => 'Sim' }] } expect(wuzapi_client).to receive(:send_buttons) .with('tok', '5561999999999', 'Curtiu?', [{ text: 'Sim' }]) .and_return(instance_double(Faraday::Response, success?: true, body: '{"Id":"m-1"}')) service.send_interactive_message(phone, payload) end it 'dispatches url_button' do payload = { 'type' => 'url_button', 'body' => 'Avalie', 'button' => { 'text' => 'Abrir', 'url' => 'https://g.page/r/1' } } expect(wuzapi_client).to receive(:send_url_button) .with('tok', '5561999999999', text: 'Avalie', button_text: 'Abrir', url: 'https://g.page/r/1') .and_return(instance_double(Faraday::Response, success?: true, body: '{"Id":"m-2"}')) service.send_interactive_message(phone, payload) end it 'dispatches list' do payload = { 'type' => 'list', 'body' => 'Cardápio', 'button_text' => 'Ver', 'sections' => [{ 'title' => 'Bebidas', 'rows' => [{ 'title' => 'Água', 'row_id' => 'a1' }] }] } expect(wuzapi_client).to receive(:send_list).and_return( instance_double(Faraday::Response, success?: true, body: '{"Id":"m-3"}') ) service.send_interactive_message(phone, payload) end it 'raises for unknown type' do expect { service.send_interactive_message(phone, 'type' => 'xyz') } .to raise_error(ArgumentError, /unsupported interactive type/) end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/services/whatsapp/providers/wuzapi_interactive_spec.rb` Expected: FAIL — `undefined method 'send_interactive_message'`. - [ ] **Step 3: Add method to WuzapiService** Insert after `def send_template` (around line 55): ```ruby # Dispatches an interactive message (buttons / list / url_button). # Called by the lifecycle dispatcher when a rule has message_type != 'text'. # payload: already-rendered hash from Captain::Lifecycle::Rule#message_payload def send_interactive_message(phone_number, payload) normalized_phone = normalize_phone(phone_number) user_token = @whatsapp_channel.wuzapi_user_token case payload['type'].to_s when 'quick_reply', 'buttons' buttons = Array(payload['buttons']).map { |b| { text: b['text'] || b[:text] } } client.send_buttons(user_token, normalized_phone, payload['body'].to_s, buttons) when 'url_button' button = payload['button'] || {} client.send_url_button( user_token, normalized_phone, text: payload['body'].to_s, button_text: button['text'].to_s, url: button['url'].to_s ) when 'list' client.send_list( user_token, normalized_phone, text: payload['body'].to_s, button_text: payload['button_text'].to_s, sections: payload['sections'] || [] ) else raise ArgumentError, "unsupported interactive type: #{payload['type'].inspect}" end end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/services/whatsapp/providers/wuzapi_interactive_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add app/services/whatsapp/providers/wuzapi_service.rb spec/services/whatsapp/providers/wuzapi_interactive_spec.rb git commit -m "feat(wuzapi): dispatch interactive messages (buttons/list/url_button)" ``` --- ## Task 8: Captain::Lifecycle::EventResolver Pure function: given a reservation and an event name, return the timestamp that represents "when that event happens" (or raise if not applicable). **Files:** - Create: `enterprise/app/services/captain/lifecycle/event_resolver.rb` - Create: `spec/enterprise/services/captain/lifecycle/event_resolver_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/services/captain/lifecycle/event_resolver_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::EventResolver do let(:reservation) do build(:captain_reservation, check_in_at: Time.zone.parse('2026-04-20 22:00:00'), check_out_at: Time.zone.parse('2026-04-21 12:00:00'), updated_at: Time.zone.parse('2026-04-19 10:00:00')) end describe '.resolve' do it 'returns check_in_at for checkin.scheduled_at' do expect(described_class.resolve(reservation, 'checkin.scheduled_at')) .to eq(reservation.check_in_at) end it 'returns check_out_at for checkout.scheduled_at' do expect(described_class.resolve(reservation, 'checkout.scheduled_at')) .to eq(reservation.check_out_at) end it 'returns updated_at for reservation.confirmed' do expect(described_class.resolve(reservation, 'reservation.confirmed')) .to eq(reservation.updated_at) end it 'returns nil for checkin.detected (not yet supported)' do expect(described_class.resolve(reservation, 'checkin.detected')).to be_nil end it 'raises for unknown event' do expect { described_class.resolve(reservation, 'unknown.event') } .to raise_error(ArgumentError, /unknown event/) end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/event_resolver_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::EventResolver`. - [ ] **Step 3: Implement the resolver** ```ruby # enterprise/app/services/captain/lifecycle/event_resolver.rb class Captain::Lifecycle::EventResolver UNSUPPORTED_IN_MVP = %w[checkin.detected checkout.detected].freeze def self.resolve(reservation, event_name) return nil if UNSUPPORTED_IN_MVP.include?(event_name) case event_name when 'reservation.confirmed' reservation.updated_at when 'checkin.scheduled_at' reservation.check_in_at when 'checkout.scheduled_at' reservation.check_out_at when 'reservation.cancelled' reservation.updated_at if reservation.status.to_s == 'cancelled' when 'reservation.no_show' reservation.check_in_at&.+ 1.hour else raise ArgumentError, "unknown event: #{event_name.inspect}" end end end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/event_resolver_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/services/captain/lifecycle/event_resolver.rb spec/enterprise/services/captain/lifecycle/event_resolver_spec.rb git commit -m "feat(lifecycle): add EventResolver service" ``` --- ## Task 9: Captain::Lifecycle::Scheduler service Orchestrates scheduling deliveries when a reservation is created/updated. **Files:** - Create: `enterprise/app/services/captain/lifecycle/scheduler.rb` - Create: `spec/enterprise/services/captain/lifecycle/scheduler_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/services/captain/lifecycle/scheduler_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Scheduler do let(:account) { create(:account) } let(:unit) { create(:captain_unit, account: account) } let(:reservation) do create(:captain_reservation, account: account, captain_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 rules whose fire_at is in the past' do create(:captain_lifecycle_rule, account: account, event: 'checkin.scheduled_at', offset_minutes: -3.days.to_i / 60) # offset of -3 days means fire_at ~= 3 days ago matching_rule.destroy! expect { described_class.schedule_for(reservation) } .not_to change { Captain::Lifecycle::Delivery.count } 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 let!(:other_rule) do create(:captain_lifecycle_rule, account: account, event: 'reservation.confirmed', offset_minutes: 0) end before do described_class.schedule_for(reservation) end it 'cancels checkin-based scheduled deliveries and reschedules' do reservation.update!(check_in_at: reservation.check_in_at + 1.hour) expect { described_class.reschedule_for_checkin_change(reservation) } .to change { Captain::Lifecycle::Delivery.where(status: 'cancelled').count }.by(1) end it 'does not touch non-checkin deliveries' do original = Captain::Lifecycle::Delivery .where(lifecycle_rule_id: other_rule.id) .pluck(:status) described_class.reschedule_for_checkin_change(reservation) still_same = Captain::Lifecycle::Delivery .where(lifecycle_rule_id: other_rule.id) .pluck(:status) expect(still_same).to eq(original) end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/scheduler_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::Scheduler`. - [ ] **Step 3: Implement scheduler** ```ruby # enterprise/app/services/captain/lifecycle/scheduler.rb class Captain::Lifecycle::Scheduler CHECKIN_EVENTS = %w[checkin.scheduled_at checkin.detected].freeze CHECKOUT_EVENTS = %w[checkout.scheduled_at checkout.detected].freeze class << self def schedule_for(reservation) rules = Captain::Lifecycle::Rule .where(account_id: reservation.account_id) .active .to_a rules.each do |rule| next unless rule.matches_reservation?(reservation) fire_at = compute_fire_at(reservation, rule) next 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 end def cancel_pending(reservation) Captain::Lifecycle::Delivery .where(captain_reservation_id: reservation.id, status: 'scheduled') .update_all(status: 'cancelled', updated_at: Time.current) end def reschedule_for_checkin_change(reservation) pending = 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 IN (?)', CHECKIN_EVENTS) pending.update_all(status: 'cancelled', updated_at: Time.current) checkin_rules = Captain::Lifecycle::Rule .where(account_id: reservation.account_id, event: CHECKIN_EVENTS) .active checkin_rules.each { |r| schedule_one_rule(reservation, r) } end private 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 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 end end ``` Note: this references `Captain::Lifecycle::DispatcherJob`. The full implementation (Dispatcher-calling body) is Task 16 — here we only need a stub so that `expect(...).to receive(:perform_at)` in the scheduler spec resolves the constant. Create the stub now: ```ruby # enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb class Captain::Lifecycle::DispatcherJob < ApplicationJob queue_as :default def perform(delivery_id) # Stub — full implementation lands in Task 16. end end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/scheduler_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/services/captain/lifecycle/scheduler.rb enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb spec/enterprise/services/captain/lifecycle/scheduler_spec.rb git commit -m "feat(lifecycle): add Scheduler service and DispatcherJob stub" ``` --- ## Task 10: Captain::Reservation lifecycle hooks Wires `Captain::Reservation` to the Scheduler via `after_commit` callbacks. **Files:** - Modify: `enterprise/app/models/captain/reservation.rb` - Create: `spec/enterprise/models/captain/reservation_lifecycle_hooks_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/models/captain/reservation_lifecycle_hooks_spec.rb require 'rails_helper' RSpec.describe Captain::Reservation, 'lifecycle hooks' do let(:account) { create(:account) } let(:unit) { create(:captain_unit, account: account) } describe 'after create' do it 'calls Scheduler.schedule_for' do expect(Captain::Lifecycle::Scheduler).to receive(:schedule_for).with(kind_of(Captain::Reservation)) create(:captain_reservation, account: account, captain_unit: unit, check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now) end end describe 'after update (status → cancelled)' do let(:reservation) do create(:captain_reservation, account: account, captain_unit: unit, check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now) end it 'cancels pending deliveries' do expect(Captain::Lifecycle::Scheduler).to receive(:cancel_pending).with(reservation) reservation.update!(status: 'cancelled') end end describe 'after update (check_in_at changed)' do let(:reservation) do create(:captain_reservation, account: account, captain_unit: unit, check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now) end it 'reschedules checkin-based deliveries' do expect(Captain::Lifecycle::Scheduler).to receive(:reschedule_for_checkin_change).with(reservation) reservation.update!(check_in_at: 3.hours.from_now) end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/models/captain/reservation_lifecycle_hooks_spec.rb` Expected: FAIL — hooks do not exist yet. - [ ] **Step 3: Add hooks to Captain::Reservation** In `enterprise/app/models/captain/reservation.rb`, add alongside existing callbacks: ```ruby after_create_commit :schedule_lifecycle_rules after_update_commit :handle_lifecycle_status_change, if: :saved_change_to_status? after_update_commit :handle_lifecycle_checkin_change, if: :saved_change_to_check_in_at? private def schedule_lifecycle_rules Captain::Lifecycle::Scheduler.schedule_for(self) rescue StandardError => e Rails.logger.error("[Lifecycle] schedule_for failed for reservation #{id}: #{e.class} #{e.message}") end def handle_lifecycle_status_change return unless %w[cancelled no_show].include?(status.to_s) Captain::Lifecycle::Scheduler.cancel_pending(self) rescue StandardError => e Rails.logger.error("[Lifecycle] cancel_pending failed for reservation #{id}: #{e.class} #{e.message}") end def handle_lifecycle_checkin_change Captain::Lifecycle::Scheduler.reschedule_for_checkin_change(self) rescue StandardError => e Rails.logger.error("[Lifecycle] reschedule failed for reservation #{id}: #{e.class} #{e.message}") end ``` If `private` already exists further down, put these methods below the existing private section or use a separate `private` block. - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/models/captain/reservation_lifecycle_hooks_spec.rb` Expected: all examples pass. - [ ] **Step 5: Run full reservation spec to check for regressions** Run: `bundle exec rspec spec/enterprise/models/captain/reservation_spec.rb` Expected: all examples still pass (no regressions in existing hooks). - [ ] **Step 6: Commit** ```bash git add enterprise/app/models/captain/reservation.rb spec/enterprise/models/captain/reservation_lifecycle_hooks_spec.rb git commit -m "feat(lifecycle): wire Captain::Reservation lifecycle hooks" ``` --- ## Task 11: Captain::Lifecycle::ContextBuilder Builds the variable hash that gets fed into the Liquid template during render. **Files:** - Create: `enterprise/app/services/captain/lifecycle/context_builder.rb` - Create: `spec/enterprise/services/captain/lifecycle/context_builder_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/services/captain/lifecycle/context_builder_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::ContextBuilder do let(:account) { create(:account) } let(:unit) do create(:captain_unit, account: account, name: 'Águas Lindas', concierge_config: { 'persona_name' => 'Sofia', 'variables' => { 'wifi_password' => 'hotel1001', 'menu_link' => 'https://menu.x' } }) end let(:contact) { create(:contact, account: account, name: 'João Silva', phone_number: '+5561999999999') } let(:reservation) do create(:captain_reservation, account: account, captain_unit: unit, contact: contact, suite_identifier: 'Alexa', total_amount: 160.0, check_in_at: Time.zone.parse('2026-04-20 22:00:00'), check_out_at: Time.zone.parse('2026-04-21 12:00:00'), metadata: { 'permanencia' => 'Pernoite' }) end describe '.build' do subject(:ctx) { described_class.build(reservation) } it 'includes customer.name and first_name' do expect(ctx['customer']['name']).to eq('João Silva') expect(ctx['customer']['first_name']).to eq('João') end it 'includes customer.phone' do expect(ctx['customer']['phone']).to eq('+5561999999999') end it 'includes reservation.suite' do expect(ctx['reservation']['suite']).to eq('Alexa') end it 'includes reservation.unit_name' do expect(ctx['reservation']['unit_name']).to eq('Águas Lindas') end it 'formats reservation.amount as BRL' do expect(ctx['reservation']['amount']).to include('R$') expect(ctx['reservation']['amount']).to include('160') end it 'includes reservation.permanencia' do expect(ctx['reservation']['permanencia']).to eq('Pernoite') end it 'includes hotel.wifi_password from unit variables' do expect(ctx['hotel']['wifi_password']).to eq('hotel1001') end it 'includes hotel.menu_link from unit variables' do expect(ctx['hotel']['menu_link']).to eq('https://menu.x') end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/context_builder_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::ContextBuilder`. - [ ] **Step 3: Implement ContextBuilder** ```ruby # enterprise/app/services/captain/lifecycle/context_builder.rb class Captain::Lifecycle::ContextBuilder def self.build(reservation) new(reservation).build end def initialize(reservation) @reservation = reservation @contact = reservation.contact @unit = reservation.unit end def build { 'customer' => customer_context, 'reservation' => reservation_context, 'hotel' => hotel_context } end private def customer_context name = @contact&.name.to_s { 'name' => name, 'first_name' => name.split.first.to_s, 'phone' => @contact&.phone_number.to_s, 'cpf' => @contact&.custom_attributes.to_h['cpf'].to_s } end def reservation_context { 'suite' => @reservation.suite_identifier.to_s, 'unit_name' => @unit&.name.to_s, 'check_in_at' => format_datetime(@reservation.check_in_at), 'check_out_at' => format_datetime(@reservation.check_out_at), 'amount' => format_money(@reservation.total_amount), 'permanencia' => @reservation.metadata.to_h['permanencia'].to_s } end def hotel_context (@unit&.concierge_variables || {}).stringify_keys end def format_datetime(value) return '' unless value I18n.l(value.in_time_zone('America/Sao_Paulo'), format: :short) end def format_money(value) ActiveSupport::NumberHelper.number_to_currency( value.to_f, unit: 'R$ ', separator: ',', delimiter: '.' ) end end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/context_builder_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/services/captain/lifecycle/context_builder.rb spec/enterprise/services/captain/lifecycle/context_builder_spec.rb git commit -m "feat(lifecycle): add ContextBuilder for Liquid render variables" ``` --- ## Task 12: Guards — simple ones (reservation_active, opt_out_label, max_per_reservation) Three guards that don't need reschedule logic: they either pass or skip. Bundled because all 3 share the same `Guards::Base` interface. **Files:** - Create: `enterprise/app/services/captain/lifecycle/guards/base.rb` - Create: `enterprise/app/services/captain/lifecycle/guards/reservation_active.rb` - Create: `enterprise/app/services/captain/lifecycle/guards/opt_out_label.rb` - Create: `enterprise/app/services/captain/lifecycle/guards/max_per_reservation.rb` - Create: `spec/enterprise/services/captain/lifecycle/guards/reservation_active_spec.rb` - Create: `spec/enterprise/services/captain/lifecycle/guards/opt_out_label_spec.rb` - Create: `spec/enterprise/services/captain/lifecycle/guards/max_per_reservation_spec.rb` - [ ] **Step 1: Write failing spec for reservation_active guard** ```ruby # spec/enterprise/services/captain/lifecycle/guards/reservation_active_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Guards::ReservationActive do let(:reservation) { create(:captain_reservation, status: 'scheduled') } let(:delivery) { create(:captain_lifecycle_delivery, captain_reservation: reservation) } subject(:guard) { described_class.new(delivery) } it 'returns pass for active reservation' do expect(guard.check).to eq(action: :pass) end it 'returns skip for cancelled reservation' do reservation.update!(status: 'cancelled') expect(guard.check).to eq(action: :skip, reason: 'reservation_cancelled') end it 'returns skip for no_show reservation' do reservation.update!(status: 'no_show') if Captain::Reservation.statuses.key?('no_show') result = guard.check expect(result[:action]).to eq(:skip) if Captain::Reservation.statuses.key?('no_show') end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/reservation_active_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::Guards`. - [ ] **Step 3: Create Guards::Base and ReservationActive** ```ruby # enterprise/app/services/captain/lifecycle/guards/base.rb class Captain::Lifecycle::Guards::Base def initialize(delivery) @delivery = delivery @reservation = delivery.captain_reservation @account = delivery.account end def check raise NotImplementedError end protected def pass { action: :pass } end def skip(reason) { action: :skip, reason: reason } end def reschedule(new_fire_at) { action: :reschedule, fire_at: new_fire_at } end end ``` Also create the namespace module file so Rails autoloading works: ```ruby # enterprise/app/services/captain/lifecycle/guards.rb module Captain module Lifecycle module Guards end end end ``` ```ruby # enterprise/app/services/captain/lifecycle/guards/reservation_active.rb class Captain::Lifecycle::Guards::ReservationActive < Captain::Lifecycle::Guards::Base BLOCKING_STATUSES = %w[cancelled no_show].freeze def check return skip('reservation_cancelled') if BLOCKING_STATUSES.include?(@reservation.status.to_s) pass end end ``` - [ ] **Step 4: Run first guard spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/reservation_active_spec.rb` Expected: pass. - [ ] **Step 5: Write failing spec for opt_out_label guard** ```ruby # spec/enterprise/services/captain/lifecycle/guards/opt_out_label_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Guards::OptOutLabel do let(:account) { create(:account) } let(:label) { create(:label, account: account, title: 'nao_mandar_auto') } let(:config) { create(:captain_lifecycle_config, account: account, opt_out_label: label) } let(:contact) { create(:contact, account: account) } let(:reservation) { create(:captain_reservation, account: account, contact: contact) } let(:delivery) { create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation) } subject(:guard) { described_class.new(delivery) } before { config } # ensure created it 'passes when contact has no opt_out label' do expect(guard.check).to eq(action: :pass) end it 'skips when contact has the configured opt_out label' do contact.update!(label_list: [label.title]) expect(guard.check).to eq(action: :skip, reason: 'opt_out_label') end it 'passes when account has no opt_out_label configured' do config.update!(opt_out_label: nil) contact.update!(label_list: [label.title]) expect(guard.check).to eq(action: :pass) end end ``` - [ ] **Step 6: Implement OptOutLabel guard** ```ruby # enterprise/app/services/captain/lifecycle/guards/opt_out_label.rb class Captain::Lifecycle::Guards::OptOutLabel < Captain::Lifecycle::Guards::Base def check config = Captain::Lifecycle::Config.for_account(@account) label = config.opt_out_label return pass if label.blank? contact = @reservation.contact return pass if contact.blank? return skip('opt_out_label') if contact.label_list.include?(label.title) pass end end ``` - [ ] **Step 7: Run opt_out spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/opt_out_label_spec.rb` Expected: pass. - [ ] **Step 8: Write failing spec for max_per_reservation** ```ruby # spec/enterprise/services/captain/lifecycle/guards/max_per_reservation_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Guards::MaxPerReservation do let(:reservation) { create(:captain_reservation) } let(:delivery) { create(:captain_lifecycle_delivery, captain_reservation: reservation) } subject(:guard) { described_class.new(delivery) } context 'under cap' do it 'passes with 0 sent deliveries' do expect(guard.check).to eq(action: :pass) end it 'passes with 4 sent deliveries' do 4.times do create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'scheduled_lifecycle') end expect(guard.check).to eq(action: :pass) end end context 'at or over cap' do before do 5.times do create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'scheduled_lifecycle') end end it 'skips with max_reached' do expect(guard.check).to eq(action: :skip, reason: 'max_reached') end end context 'with concierge replies (non-lifecycle origin)' do it 'ignores concierge_reply origin toward the cap' do 10.times do create(:captain_lifecycle_delivery, captain_reservation: reservation, status: 'sent', origin: 'concierge_reply') end expect(guard.check).to eq(action: :pass) end end end ``` - [ ] **Step 9: Implement MaxPerReservation guard** ```ruby # enterprise/app/services/captain/lifecycle/guards/max_per_reservation.rb class Captain::Lifecycle::Guards::MaxPerReservation < Captain::Lifecycle::Guards::Base CAP = 5 def check count = Captain::Lifecycle::Delivery.count_sent_for_reservation(@reservation.id) return skip('max_reached') if count >= CAP pass end end ``` - [ ] **Step 10: Run max_per_reservation spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/max_per_reservation_spec.rb` Expected: pass. - [ ] **Step 11: Commit** ```bash git add enterprise/app/services/captain/lifecycle/guards/ spec/enterprise/services/captain/lifecycle/guards/ git commit -m "feat(lifecycle): add base, reservation_active, opt_out_label, max_per_reservation guards" ``` --- ## Task 13: Quiet hours guard (Opção C) Reschedules to the end of the quiet window, but skips with `too_stale` if the delay exceeds 2 hours. **Files:** - Create: `enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb` - Create: `spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Guards::QuietHours do let(:account) { create(:account) } let(:reservation) { create(:captain_reservation, account: account) } let(:delivery) do create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation, fire_at: fire_at) end subject(:guard) { described_class.new(delivery) } context 'when quiet hours disabled' do let(:fire_at) { Time.zone.parse('2026-04-15 03:00:00') } before { create(:captain_lifecycle_config, account: account, quiet_hours_enabled: false) } it 'passes regardless of fire_at' do expect(guard.check).to eq(action: :pass) end end context 'when quiet hours enabled (23:00-08:00)' do before do create(:captain_lifecycle_config, account: account, quiet_hours_enabled: true, quiet_hours_from: '23:00', quiet_hours_to: '08:00') end context 'fire_at outside window (14:00)' do let(:fire_at) { Time.zone.parse('2026-04-15 14:00:00') } it('passes') { expect(guard.check).to eq(action: :pass) } end context 'fire_at inside window but close to end (07:45, delay 15min)' do let(:fire_at) { Time.zone.parse('2026-04-15 07:45:00') } it 'reschedules to 08:00' do result = guard.check expect(result[:action]).to eq(:reschedule) expect(result[:fire_at]).to eq(Time.zone.parse('2026-04-15 08:00:00')) end end context 'fire_at deep inside window (03:00, delay 5h)' do let(:fire_at) { Time.zone.parse('2026-04-15 03:00:00') } it 'skips with too_stale' do expect(guard.check).to eq(action: :skip, reason: 'too_stale') end end context 'fire_at at 23:30 (delay until next 08:00 = 8.5h)' do let(:fire_at) { Time.zone.parse('2026-04-15 23:30:00') } it 'skips with too_stale' do expect(guard.check).to eq(action: :skip, reason: 'too_stale') end end end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::Guards::QuietHours`. - [ ] **Step 3: Implement QuietHours guard** ```ruby # enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb class Captain::Lifecycle::Guards::QuietHours < Captain::Lifecycle::Guards::Base MAX_DELAY = 2.hours def check config = Captain::Lifecycle::Config.for_account(@account) return pass unless config.quiet_hours_enabled fire_at = @delivery.fire_at return pass unless in_quiet_window?(fire_at, config) delayed_to = next_valid_time(fire_at, config) delay = delayed_to - fire_at return skip('too_stale') if delay > MAX_DELAY reschedule(delayed_to) end private def in_quiet_window?(time, config) from = time_of_day(config.quiet_hours_from) to = time_of_day(config.quiet_hours_to) minute = time.hour * 60 + time.min if from < to (from...to).cover?(minute) else minute >= from || minute < to end end def next_valid_time(fire_at, config) to_minute = time_of_day(config.quiet_hours_to) fire_minute = fire_at.hour * 60 + fire_at.min target = fire_at.beginning_of_day + to_minute.minutes target += 1.day if fire_minute >= time_of_day(config.quiet_hours_from) && fire_minute >= to_minute target end def time_of_day(t) t.hour * 60 + t.min end end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/services/captain/lifecycle/guards/quiet_hours.rb spec/enterprise/services/captain/lifecycle/guards/quiet_hours_spec.rb git commit -m "feat(lifecycle): add QuietHours guard with 2h staleness limit" ``` --- ## Task 14: MinInterval and CustomerReplied guards Both guards reschedule instead of skip (except when too stale — same rule as QuietHours: 2h max). **Files:** - Create: `enterprise/app/services/captain/lifecycle/guards/min_interval.rb` - Create: `enterprise/app/services/captain/lifecycle/guards/customer_replied.rb` - Create: `spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb` - Create: `spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb` - [ ] **Step 1: Write failing spec for min_interval** ```ruby # spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Guards::MinInterval do let(:account) { create(:account) } let(:reservation) { create(:captain_reservation, account: account) } subject(:guard) { described_class.new(delivery) } let(:delivery) do create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation, fire_at: Time.current + 1.minute) end context 'when min_interval_minutes = 0' do before { create(:captain_lifecycle_config, account: account, min_interval_minutes: 0) } it 'passes' do expect(guard.check).to eq(action: :pass) end end context 'when min_interval_minutes = 30' do before { create(:captain_lifecycle_config, account: account, min_interval_minutes: 30) } it 'passes when no previous message recently' do expect(guard.check).to eq(action: :pass) end it 'reschedules when a previous delivery was sent < 30min ago' do create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation, status: 'sent', sent_at: 10.minutes.ago, origin: 'scheduled_lifecycle') result = guard.check expect(result[:action]).to eq(:reschedule) # should fire approximately 30min after the previous sent_at expect(result[:fire_at]).to be_within(1.minute).of(10.minutes.ago + 30.minutes) end end end ``` - [ ] **Step 2: Implement min_interval guard** ```ruby # enterprise/app/services/captain/lifecycle/guards/min_interval.rb class Captain::Lifecycle::Guards::MinInterval < Captain::Lifecycle::Guards::Base MAX_DELAY = 2.hours def check config = Captain::Lifecycle::Config.for_account(@account) interval = config.min_interval_minutes.to_i return pass if interval <= 0 last_sent_at = Captain::Lifecycle::Delivery .where(captain_reservation_id: @reservation.id, status: 'sent', origin: 'scheduled_lifecycle') .maximum(:sent_at) return pass if last_sent_at.blank? earliest_allowed = last_sent_at + interval.minutes return pass if @delivery.fire_at >= earliest_allowed delay = earliest_allowed - @delivery.fire_at return skip('too_stale') if delay > MAX_DELAY reschedule(earliest_allowed) end end ``` - [ ] **Step 3: Run min_interval spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb` Expected: pass. - [ ] **Step 4: Write failing spec for customer_replied** ```ruby # spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Guards::CustomerReplied do let(:account) { create(:account) } let(:contact) { create(:contact, account: account) } let(:reservation) { create(:captain_reservation, account: account, contact: contact) } let(:inbox) { create(:inbox, account: account) } let(:conversation) { create(:conversation, account: account, contact: contact, inbox: inbox) } let(:delivery) do create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation, conversation: conversation, inbox: inbox, fire_at: Time.current + 1.minute) end subject(:guard) { described_class.new(delivery) } context 'when guard disabled' do before { create(:captain_lifecycle_config, account: account, pause_on_customer_reply: false) } it('passes') { expect(guard.check).to eq(action: :pass) } end context 'when guard enabled (60 min window)' do before do create(:captain_lifecycle_config, account: account, pause_on_customer_reply: true, pause_on_customer_reply_within_minutes: 60) end it 'passes when no recent incoming messages' do expect(guard.check).to eq(action: :pass) end it 'reschedules when customer sent a message 10min ago' do create(:message, account: account, conversation: conversation, message_type: 'incoming', created_at: 10.minutes.ago) result = guard.check expect(result[:action]).to eq(:reschedule) end end end ``` - [ ] **Step 5: Implement customer_replied guard** ```ruby # enterprise/app/services/captain/lifecycle/guards/customer_replied.rb class Captain::Lifecycle::Guards::CustomerReplied < Captain::Lifecycle::Guards::Base MAX_DELAY = 2.hours def check config = Captain::Lifecycle::Config.for_account(@account) return pass unless config.pause_on_customer_reply window = config.pause_on_customer_reply_within_minutes.to_i.minutes conversation = resolve_conversation return pass if conversation.blank? last_incoming_at = conversation.messages .where(message_type: Message.message_types[:incoming]) .maximum(:created_at) return pass if last_incoming_at.blank? wait_until = last_incoming_at + window return pass if @delivery.fire_at >= wait_until delay = wait_until - @delivery.fire_at return skip('too_stale') if delay > MAX_DELAY reschedule(wait_until) end private def resolve_conversation return @delivery.conversation if @delivery.conversation.present? inbox_id = @delivery.inbox_id || @reservation.unit&.concierge_inbox_id return nil if inbox_id.blank? @account.conversations .where(contact_id: @reservation.contact_id, inbox_id: inbox_id) .order(last_activity_at: :desc) .first end end ``` - [ ] **Step 6: Run customer_replied spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb` Expected: pass. - [ ] **Step 7: Commit** ```bash git add enterprise/app/services/captain/lifecycle/guards/min_interval.rb enterprise/app/services/captain/lifecycle/guards/customer_replied.rb spec/enterprise/services/captain/lifecycle/guards/min_interval_spec.rb spec/enterprise/services/captain/lifecycle/guards/customer_replied_spec.rb git commit -m "feat(lifecycle): add MinInterval and CustomerReplied guards" ``` --- ## Task 15: Dispatcher service Orchestrates guards → render → send. Called by the Sidekiq job. **Files:** - Create: `enterprise/app/services/captain/lifecycle/dispatcher.rb` - Create: `spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::Dispatcher do let(:account) { create(:account) } let(:unit) { create(:captain_unit, account: account, concierge_inbox: inbox) } let(:inbox) { create(:inbox, account: account) } let(:contact) { create(:contact, account: account, name: 'João Silva', phone_number: '+5561999999999') } let(:reservation) do create(:captain_reservation, account: account, captain_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 subject(:dispatcher) { described_class.new(delivery) } describe '#call' do context 'happy path' do before do allow_any_instance_of(Captain::Lifecycle::Dispatcher) .to receive(:send_message) .and_return(build_stubbed(:message, id: 42)) 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 it 'sets current_unit_id on conversation' do dispatcher.call expect(delivery.reload.conversation.custom_attributes['current_unit_id']).to eq(unit.id) end end context '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 'guard blocks with reschedule' do it 'reschedules the delivery and does not send' do allow_any_instance_of(Captain::Lifecycle::Guards::QuietHours) .to receive(:check) .and_return(action: :reschedule, fire_at: 2.hours.from_now) 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(2.hours.from_now) end end context '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 ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb` Expected: FAIL — `uninitialized constant Captain::Lifecycle::Dispatcher`. - [ ] **Step 3: Implement Dispatcher** ```ruby # enterprise/app/services/captain/lifecycle/dispatcher.rb 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' guard_result = run_guards case guard_result[:action] when :skip @delivery.mark_skipped!(guard_result[:reason]) return when :reschedule apply_reschedule(guard_result[:fire_at]) return end 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 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) conversation.update!(custom_attributes: (conversation.custom_attributes || {}).merge( 'current_unit_id' => reservation.captain_unit_id )) 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, rendered_body) msg 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 ) { |ci| ci.source_id = contact.phone_number.to_s.gsub(/\D/, '') } 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, _rendered_body) return if rule.message_type == 'text' || rule.message_payload.blank? inbox = reservation.unit.concierge_inbox provider_service = inbox.channel.try(:create_messaging_service) || inbox.channel return unless provider_service.respond_to?(:send_interactive_message) payload = render_payload(rule.message_payload, reservation) provider_service.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 ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb` Expected: all examples pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/services/captain/lifecycle/dispatcher.rb spec/enterprise/services/captain/lifecycle/dispatcher_spec.rb git commit -m "feat(lifecycle): add Dispatcher service with guards→render→send pipeline" ``` --- ## Task 16: DispatcherJob (Sidekiq) — full implementation Replaces the stub from Task 9 with a real job that calls the Dispatcher. **Files:** - Modify: `enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb` - Create: `spec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rb` - [ ] **Step 1: Write failing spec** ```ruby # spec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rb require 'rails_helper' RSpec.describe Captain::Lifecycle::DispatcherJob do let(:delivery) { create(:captain_lifecycle_delivery) } it 'calls Dispatcher#call' do dispatcher = instance_double(Captain::Lifecycle::Dispatcher) expect(Captain::Lifecycle::Dispatcher).to receive(:new).with(instance_of(Captain::Lifecycle::Delivery)).and_return(dispatcher) expect(dispatcher).to receive(:call) described_class.perform_now(delivery.id) end it 'silently skips missing delivery records' do expect { described_class.perform_now(-1) }.not_to raise_error end end ``` - [ ] **Step 2: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rb` Expected: FAIL — job is still a stub that does nothing. - [ ] **Step 3: Replace the stub implementation** ```ruby # enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb class Captain::Lifecycle::DispatcherJob < ApplicationJob queue_as :default retry_on StandardError, wait: :exponentially_longer, attempts: 3 def perform(delivery_id) delivery = Captain::Lifecycle::Delivery.find_by(id: delivery_id) return if delivery.blank? Captain::Lifecycle::Dispatcher.new(delivery).call end end ``` - [ ] **Step 4: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rb` Expected: pass. - [ ] **Step 5: Commit** ```bash git add enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb spec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rb git commit -m "feat(lifecycle): implement DispatcherJob" ``` --- ## Task 17: Concierge Liquid context injection Adds `{% render 'concierge' %}` / `{{ concierge.* }}` support to the Captain prompt rendering pipeline. Reads `conversation.custom_attributes['current_unit_id']` to resolve the unit and injects its config. **Files:** - Create: `enterprise/lib/captain/prompts/snippets/concierge.liquid` - Create: `enterprise/lib/captain/prompts/concierge.liquid` - Modify: `enterprise/app/models/concerns/agentable.rb` - Create: `spec/enterprise/models/concerns/agentable_concierge_spec.rb` - [ ] **Step 1: Write the concierge Liquid snippet** ```liquid {%- comment -%}enterprise/lib/captain/prompts/snippets/concierge.liquid{%- endcomment -%} # Concierge Context (unit: {{ concierge.unit_name }}) Persona: {{ concierge.persona_name }} ## Base de Conhecimento {{ concierge.knowledge }} ## Variáveis Disponíveis {% for pair in concierge.variables %}- {{ pair[0] }}: {{ pair[1] }} {% endfor %} ``` - [ ] **Step 2: Write the default concierge orchestrator template** ```liquid {%- comment -%}enterprise/lib/captain/prompts/concierge.liquid{%- endcomment -%} Você é {{ concierge.persona_name }}, assistente virtual do {{ concierge.unit_name }}. {% render 'concierge' %} ## Dados da Estadia Atual - Suíte: {{ reservation.suite }} - Check-in: {{ reservation.check_in_at }} - Check-out: {{ reservation.check_out_at }} ## Como se comportar (Escreva aqui tom, transparência de IA, regras de handoff, política de reclamações, etc.) ``` - [ ] **Step 3: Write failing spec for agentable extension** ```ruby # spec/enterprise/models/concerns/agentable_concierge_spec.rb require 'rails_helper' RSpec.describe 'Agentable concierge context injection' do let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account) } let(:unit) do create(:captain_unit, account: account, name: 'Prime Águas Lindas', concierge_config: { 'persona_name' => 'Sofia', 'knowledge' => 'Hotel com café às 7h', 'variables' => { 'wifi_password' => 'abc123' } }) end let(:contact) { create(:contact, account: account, name: 'João Silva') } let(:conversation) do create(:conversation, account: account, inbox: inbox, contact: contact, custom_attributes: { 'current_unit_id' => unit.id }) end let(:assistant) do create(:captain_assistant, account: account, orchestrator_prompt: '{{ concierge.persona_name }} in {{ concierge.unit_name }} knows: {{ concierge.knowledge }}') end it 'resolves concierge context from current_unit_id' do ctx = { conversation: { id: conversation.id } }.with_indifferent_access rendered = assistant.render_orchestrator_prompt(ctx) expect(rendered).to include('Sofia in Prime Águas Lindas') expect(rendered).to include('Hotel com café às 7h') end it 'falls back to empty strings when current_unit_id missing' do conversation.update!(custom_attributes: {}) ctx = { conversation: { id: conversation.id } }.with_indifferent_access rendered = assistant.render_orchestrator_prompt(ctx) # should not raise; renders with empty persona_name defaulting expect(rendered).to be_a(String) end end ``` - [ ] **Step 4: Run spec to verify failure** Run: `bundle exec rspec spec/enterprise/models/concerns/agentable_concierge_spec.rb` Expected: FAIL — `concierge.*` variables not in context. - [ ] **Step 5: Update agentable concern to inject concierge context** In `enterprise/app/models/concerns/agentable.rb`, find the `render_orchestrator_prompt` method (around line 15-35) and modify the enhancement block. The existing code builds `enhanced_context` from `state[:conversation]` and `state[:contact]`. Add a concierge branch before `ctx = enhanced_context.with_indifferent_access`: ```ruby enhanced_context = enhanced_context.merge( conversation: conversation_data, contact: contact_data, concierge: resolve_concierge_context(conversation_data), reservation: resolve_reservation_context(conversation_data) ) ``` And add two private methods at the bottom of the concern (above any existing `private` methods or in a new private block): ```ruby def resolve_concierge_context(conversation_data) unit = resolve_current_unit(conversation_data) return default_concierge_context if unit.blank? { 'persona_name' => unit.concierge_persona_name, 'unit_name' => unit.name.to_s, 'knowledge' => unit.concierge_knowledge, 'variables' => unit.concierge_variables } end def resolve_reservation_context(conversation_data) conv = lookup_conversation(conversation_data) return {} if conv.blank? reservation = conv.account.captain_reservations .where(contact_id: conv.contact_id) .order(created_at: :desc) .first return {} if reservation.blank? Captain::Lifecycle::ContextBuilder.build(reservation).fetch('reservation', {}) end def resolve_current_unit(conversation_data) conv = lookup_conversation(conversation_data) return nil if conv.blank? unit_id = conv.custom_attributes.to_h['current_unit_id'] return nil if unit_id.blank? Captain::Unit.find_by(id: unit_id) end def lookup_conversation(conversation_data) id = conversation_data.is_a?(Hash) ? (conversation_data[:id] || conversation_data['id']) : nil Conversation.find_by(id: id) end def default_concierge_context { 'persona_name' => 'Sofia', 'unit_name' => '', 'knowledge' => '', 'variables' => {} } end ``` - [ ] **Step 6: Run spec to verify pass** Run: `bundle exec rspec spec/enterprise/models/concerns/agentable_concierge_spec.rb` Expected: pass. - [ ] **Step 7: Run the broader agentable/assistant spec to catch regressions** Run: `bundle exec rspec spec/enterprise/models/captain/assistant_spec.rb 2>&1 | tail -20` Expected: all still pass (existing tests do not depend on `concierge.*`). - [ ] **Step 8: Commit** ```bash git add enterprise/lib/captain/prompts/snippets/concierge.liquid enterprise/lib/captain/prompts/concierge.liquid enterprise/app/models/concerns/agentable.rb spec/enterprise/models/concerns/agentable_concierge_spec.rb git commit -m "feat(lifecycle): inject concierge context into Captain orchestrator prompt" ``` --- ## Task 18: End-to-end integration spec Validates the full flow: create rule → create reservation → job gets enqueued → time-travel → job runs → guards pass → message sent → delivery logged. **Files:** - Create: `spec/enterprise/integration/captain/lifecycle_flow_spec.rb` - [ ] **Step 1: Write the integration spec** ```ruby # spec/enterprise/integration/captain/lifecycle_flow_spec.rb require 'rails_helper' RSpec.describe 'Captain::Lifecycle end-to-end flow', :freeze_time do let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account) } let(:unit) do create(:captain_unit, account: account, name: 'Águas Lindas', concierge_inbox: inbox, concierge_config: { 'persona_name' => 'Sofia', 'variables' => { 'wifi_password' => 'hotel1001' } }) end let(:captain_inbox) do create(:captain_inbox, account: account, inbox: inbox, assistant: create(:captain_assistant, account: account)) end let(:contact) { create(:contact, account: account, name: 'Maria Teste', phone_number: '+5561988887777') } before do captain_inbox Sidekiq::Testing.inline! if defined?(Sidekiq::Testing) end it 'schedules, dispatches, and logs a delivery through the full pipeline' do # 1. Create a rule rule = create(:captain_lifecycle_rule, account: account, event: 'checkin.scheduled_at', offset_minutes: -10, message_body: 'Oi {{ customer.first_name }}! Wi-fi: {{ hotel.wifi_password }}') # 2. Create a reservation with check_in 2 hours out reservation = create(:captain_reservation, account: account, captain_unit: unit, contact: contact, suite_identifier: 'Alexa', check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now) # 3. Delivery was scheduled delivery = Captain::Lifecycle::Delivery.last expect(delivery).not_to be_nil expect(delivery.status).to eq('scheduled') expect(delivery.lifecycle_rule_id).to eq(rule.id) expect(delivery.fire_at).to be_within(1.second).of(reservation.check_in_at - 10.minutes) # 4. Stub WuzAPI at the provider level to avoid real HTTP allow_any_instance_of(Whatsapp::Providers::WuzapiService) .to receive(:send_message).and_return({ 'Id' => 'stub-msg' }) # 5. Time-travel to fire_at and run the job travel_to(delivery.fire_at) do Captain::Lifecycle::DispatcherJob.perform_now(delivery.id) end # 6. Delivery is now sent delivery.reload expect(delivery.status).to eq('sent') expect(delivery.rendered_body).to include('Maria') expect(delivery.rendered_body).to include('hotel1001') expect(delivery.sent_at).to be_present expect(delivery.conversation_id).to be_present expect(delivery.message_id).to be_present end it 'cancels pending deliveries when reservation is cancelled' do create(:captain_lifecycle_rule, account: account, event: 'checkin.scheduled_at', offset_minutes: -10, message_body: 'Oi') reservation = create(:captain_reservation, account: account, captain_unit: unit, contact: contact, check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now) delivery = Captain::Lifecycle::Delivery.last expect(delivery.status).to eq('scheduled') reservation.update!(status: 'cancelled') expect(delivery.reload.status).to eq('cancelled') end it 'respects the max-5 cap' do 5.times { |i| create(:captain_lifecycle_rule, account: account, name: "rule-#{i}", event: 'reservation.confirmed', offset_minutes: i, message_body: "msg #{i}") } create(:captain_lifecycle_rule, account: account, name: 'rule-6', event: 'reservation.confirmed', offset_minutes: 10, message_body: 'msg 6') # Stub send so first 5 succeed allow_any_instance_of(Captain::Lifecycle::Dispatcher) .to receive(:send_message).and_return(build_stubbed(:message, id: 1)) reservation = create(:captain_reservation, account: account, captain_unit: unit, contact: contact, check_in_at: 2.hours.from_now, check_out_at: 10.hours.from_now) # Fire all 6 scheduled jobs Captain::Lifecycle::Delivery.scheduled.each do |d| Captain::Lifecycle::DispatcherJob.perform_now(d.id) end statuses = Captain::Lifecycle::Delivery.where(captain_reservation: reservation).pluck(:status) expect(statuses.count('sent')).to eq(5) expect(statuses.count('skipped')).to eq(1) skipped = Captain::Lifecycle::Delivery.where(captain_reservation: reservation, status: 'skipped').first expect(skipped.skip_reason).to eq('max_reached') end end ``` - [ ] **Step 2: Run the integration spec** Run: `bundle exec rspec spec/enterprise/integration/captain/lifecycle_flow_spec.rb` Expected: all 3 examples pass. If any fail, fix the underlying unit that owns the failure — do not patch the integration spec to hide bugs. - [ ] **Step 3: Run full lifecycle test suite** Run: `bundle exec rspec spec/enterprise/services/captain/lifecycle/ spec/enterprise/models/captain/lifecycle/ spec/enterprise/jobs/captain/lifecycle/ spec/enterprise/integration/captain/` Expected: every example passes. This is the regression gate before commit. - [ ] **Step 4: Commit** ```bash git add spec/enterprise/integration/captain/lifecycle_flow_spec.rb git commit -m "test(lifecycle): add end-to-end integration spec for scheduler→dispatch→send flow" ``` --- ## Task 19: Seed for development / smoke script A runnable rails script that sets up a test account + unit + rule + reservation so the developer can exercise the pipeline from `rails console` or `rails runner`. **Files:** - Create: `db/seeds/captain_lifecycle_demo.rb` - [ ] **Step 1: Write the seed script** ```ruby # db/seeds/captain_lifecycle_demo.rb # Run with: bundle exec rails runner db/seeds/captain_lifecycle_demo.rb # # Creates a demo rule + reservation and leaves the delivery scheduled # so the engineer can inspect it, fast-forward, or trigger manually. account = Account.first || raise('No Account exists; create one first') unit = account.captain_units.first || raise('No Captain::Unit exists; create one first') inbox = unit.inboxes.first || raise('Unit has no inboxes') # Ensure unit has concierge config unit.update!( concierge_inbox_id: inbox.id, concierge_config: { 'persona_name' => 'Sofia', 'knowledge' => "## Sobre\nHotel exemplo para teste de lifecycle.\n## Wifi\nSenha: demo123\n", 'variables' => { 'wifi_password' => 'demo123' } } ) rule = Captain::Lifecycle::Rule.create!( account: account, name: 'DEMO lembrete 10min antes check-in', enabled: true, event: 'checkin.scheduled_at', offset_minutes: -10, filters: {}, message_type: 'text', message_body: 'Oi {{ customer.first_name }}! Sua {{ reservation.suite }} tá pronta. Wifi: {{ hotel.wifi_password }}' ) contact = account.contacts.first_or_create!( name: 'Cliente Demo', phone_number: '+5561900000000', email: 'demo@example.com' ) reservation = Captain::Reservation.create!( account: account, captain_unit: unit, contact: contact, suite_identifier: 'Alexa', status: :scheduled, total_amount: 160.0, check_in_at: 30.minutes.from_now, check_out_at: 8.hours.from_now, inbox: inbox, metadata: { 'permanencia' => 'Pernoite' } ) delivery = Captain::Lifecycle::Delivery.where(captain_reservation_id: reservation.id).last puts '--- DEMO CREATED ---' puts "Rule: #{rule.id} — #{rule.name}" puts "Reservation: #{reservation.id} (check_in_at = #{reservation.check_in_at})" puts "Delivery: #{delivery&.id} status=#{delivery&.status} fire_at=#{delivery&.fire_at}" puts puts 'To fire now:' puts " Captain::Lifecycle::DispatcherJob.perform_now(#{delivery&.id})" ``` - [ ] **Step 2: Run the seed to verify it works** Run: `bundle exec rails runner db/seeds/captain_lifecycle_demo.rb` Expected output (values vary): ``` --- DEMO CREATED --- Rule: 1 — DEMO lembrete 10min antes check-in Reservation: 42 (check_in_at = 2026-04-15 XX:XX:XX ...) Delivery: 1 status=scheduled fire_at=2026-04-15 XX:XX:XX ... To fire now: Captain::Lifecycle::DispatcherJob.perform_now(1) ``` - [ ] **Step 3: Commit** ```bash git add db/seeds/captain_lifecycle_demo.rb git commit -m "chore(lifecycle): add demo seed script for manual pipeline testing" ``` --- ## Task 20: Rubocop + ensure no style regressions Run rubocop on everything new. Fix any offenses inline (no `rubocop:disable` unless justified). - [ ] **Step 1: Run rubocop on all new/modified files** ```bash bundle exec rubocop \ enterprise/app/models/captain/lifecycle.rb \ enterprise/app/models/captain/lifecycle/ \ enterprise/app/services/captain/lifecycle/ \ enterprise/app/jobs/captain/lifecycle/ \ enterprise/app/models/concerns/agentable.rb \ enterprise/app/models/captain/unit.rb \ enterprise/app/models/captain/reservation.rb \ app/services/wuzapi/client.rb \ app/services/whatsapp/providers/wuzapi_service.rb ``` Expected: no offenses. - [ ] **Step 2: If offenses found, auto-fix where safe** ```bash bundle exec rubocop -a ``` Then re-run without `-a` to confirm clean. - [ ] **Step 3: Run full backend spec suite one final time** ```bash bundle exec rspec \ spec/enterprise/models/captain/lifecycle \ spec/enterprise/services/captain/lifecycle \ spec/enterprise/jobs/captain/lifecycle \ spec/enterprise/integration/captain \ spec/enterprise/models/captain/reservation_lifecycle_hooks_spec.rb \ spec/enterprise/models/captain/unit_concierge_spec.rb \ spec/enterprise/models/concerns/agentable_concierge_spec.rb \ spec/services/wuzapi/client_interactive_spec.rb \ spec/services/whatsapp/providers/wuzapi_interactive_spec.rb ``` Expected: 100% green. - [ ] **Step 4: Commit any fixes** ```bash git add -u git commit -m "style(lifecycle): rubocop cleanup on new lifecycle files" || true ``` --- ## Validação final Ao final do plano, tudo deve passar: ```bash bundle exec rubocop bundle exec rspec spec/enterprise/models/captain/lifecycle spec/enterprise/services/captain/lifecycle spec/enterprise/jobs/captain/lifecycle spec/enterprise/integration/captain ``` Critérios de conclusão: - [ ] Migrations aplicam limpo em banco zerado - [ ] Todos os specs novos passam - [ ] Seed de demo cria um cenário funcional ponta-a-ponta - [ ] `rails runner` consegue disparar manualmente um delivery e ver a mensagem chegando no WuzAPI (stub ou real) - [ ] Rubocop 100% clean nos arquivos novos - [ ] Nenhum spec existente (Jasmine, reservas, outros) foi quebrado pelo hooks novos ## Fora de escopo deste plano (ficam pra follow-ups) - **Plano Fase B — Admin UI "Jornada do Cliente"**: wizard de 4 passos, editor de mensagem com autocomplete, tabs de configurações e histórico, templates prontos. Depende deste plano estar concluído e validado. - Detecção heurística de `checkin.detected` / `checkout.detected` (backlog) - Dashboard analítico de funil (backlog) - Sincronização de templates prontos via seed (backlog) - Observabilidade Prometheus (backlog)