diff --git a/docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend.md b/docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend.md new file mode 100644 index 000000000..3f96cd8b3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend.md @@ -0,0 +1,3078 @@ +# 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)