Plano de 20 tasks em TDD cobrindo: migrations, models (Rule/Delivery/Config), extensões em Captain::Unit, 3 métodos interativos em Wuzapi::Client, EventResolver, Scheduler event-driven, hooks em Captain::Reservation, ContextBuilder, 6 guards (Opção C quiet hours, max-5, opt-out, etc), Dispatcher pipeline, DispatcherJob, injeção Liquid de concierge.* no orchestrator prompt e spec de integração end-to-end. Out of scope: UI (Fase B) será plano separado após backend validado. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
102 KiB
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 novasdb/migrate/YYYYMMDDHHMMSS_add_concierge_to_captain_units.rb— 2 colunas emcaptain_unitsenterprise/app/models/captain/lifecycle/rule.rbenterprise/app/models/captain/lifecycle/delivery.rbenterprise/app/models/captain/lifecycle/config.rb
Serviços e jobs
enterprise/app/services/captain/lifecycle/event_resolver.rb— resolve evento → timestampenterprise/app/services/captain/lifecycle/rule_matcher.rb— encontra regras que batem com uma reserva num eventoenterprise/app/services/captain/lifecycle/scheduler.rb— orquestra schedule/cancel/rescheduleenterprise/app/services/captain/lifecycle/context_builder.rb— monta hash de variáveis (customer, reservation, hotel) pro Liquidenterprise/app/services/captain/lifecycle/dispatcher.rb— pipeline com guards, render e sendenterprise/app/services/captain/lifecycle/guards/base.rbenterprise/app/services/captain/lifecycle/guards/reservation_active.rbenterprise/app/services/captain/lifecycle/guards/opt_out_label.rbenterprise/app/services/captain/lifecycle/guards/max_per_reservation.rbenterprise/app/services/captain/lifecycle/guards/quiet_hours.rbenterprise/app/services/captain/lifecycle/guards/min_interval.rbenterprise/app/services/captain/lifecycle/guards/customer_replied.rbenterprise/app/jobs/captain/lifecycle/dispatcher_job.rb
Extensões de código existente
enterprise/app/models/captain/unit.rb— accessors praconcierge_configjsonbenterprise/app/models/captain/reservation.rb— hooksafter_commit/after_updatechamando schedulerenterprise/app/models/concerns/agentable.rb— injeção deconciergeno enhanced_contextenterprise/lib/captain/prompts/concierge.liquid— template padrão da Sofiaapp/services/wuzapi/client.rb—send_buttons,send_list,send_url_buttonapp/services/whatsapp/providers/wuzapi_service.rb— dispatch de mensagens interativas
Specs
spec/enterprise/models/captain/lifecycle/rule_spec.rbspec/enterprise/models/captain/lifecycle/delivery_spec.rbspec/enterprise/models/captain/lifecycle/config_spec.rbspec/enterprise/services/captain/lifecycle/event_resolver_spec.rbspec/enterprise/services/captain/lifecycle/rule_matcher_spec.rbspec/enterprise/services/captain/lifecycle/scheduler_spec.rbspec/enterprise/services/captain/lifecycle/context_builder_spec.rbspec/enterprise/services/captain/lifecycle/dispatcher_spec.rbspec/enterprise/services/captain/lifecycle/guards/*_spec.rbspec/enterprise/jobs/captain/lifecycle/dispatcher_job_spec.rbspec/enterprise/integration/captain/lifecycle_flow_spec.rb
Factories
spec/factories/captain_lifecycle_rules.rbspec/factories/captain_lifecycle_deliveries.rbspec/factories/captain_lifecycle_configs.rb
Task 1: Migrations for lifecycle tables
Files:
-
Create:
db/migrate/<TIMESTAMP>_create_captain_lifecycle_tables.rb -
Create:
db/migrate/<TIMESTAMP2>_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:
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
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
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
# 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
# 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
# enterprise/app/models/captain/lifecycle.rb
module Captain
module Lifecycle
end
end
- Step 5: Create Config model
# 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
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
# 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
# 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
# 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
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
# 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
# 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
# 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
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
# 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):
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
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)
# 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 #<Wuzapi::Client>.
- Step 3: Add methods to Wuzapi::Client
Insert before the def send_reaction method (line ~70):
# 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
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
# 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):
# 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
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
# 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
# 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
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
# 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
# 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:
# 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
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
# 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:
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
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
# 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
# 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
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
# 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
# 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:
# enterprise/app/services/captain/lifecycle/guards.rb
module Captain
module Lifecycle
module Guards
end
end
end
# 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
# 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
# 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
# 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
# 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
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
# 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
# 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
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
# 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
# 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
# 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
# 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
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
# 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
# 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
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
# 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
# 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
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
{%- 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
{%- 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
# 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:
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):
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
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
# 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
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
# 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
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
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
bundle exec rubocop -a <files>
Then re-run without -a to confirm clean.
- Step 3: Run full backend spec suite one final time
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
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:
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 runnerconsegue 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)