docs(lifecycle): add backend handoff with gotchas and next-session prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
325f05c3eb
commit
7da16f7722
@ -0,0 +1,201 @@
|
||||
# Jornada do Cliente — Backend Handoff (pra sessão nova)
|
||||
|
||||
**Data:** 2026-04-15
|
||||
**Status:** Backend (Fases A + C do spec) 100% entregue. UI (Fase B) ainda não — próximo plano.
|
||||
|
||||
## Pra retomar numa sessão nova
|
||||
|
||||
Colar este prompt no início da sessão:
|
||||
|
||||
> Quero escrever o plano de implementação da Fase B — a UI "Jornada do Cliente" — do spec em `docs/superpowers/specs/2026-04-15-jornada-do-cliente-design.md` seção 9. O backend já está pronto (ver `docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend.md` e o handoff em `docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend-handoff.md`). Chama a skill writing-plans.
|
||||
|
||||
## O que está pronto (commits no main)
|
||||
|
||||
21 commits atômicos entre `1c89ef73f` e `325f05c3e`. Todos verdes, ~120 specs, 0 regressões, Rubocop limpo.
|
||||
|
||||
Mapa lógico:
|
||||
|
||||
| Camada | Arquivo principal | Papel |
|
||||
|---|---|---|
|
||||
| **Schema** | `db/migrate/20260415040927_*.rb` + `20260415040957_*.rb` | 3 tabelas `captain_lifecycle_*` + 2 colunas em `captain_units` |
|
||||
| **Model: Config** | `enterprise/app/models/captain/lifecycle/config.rb` | Singleton por conta. Guards configuráveis. `.for_account(account)` helper |
|
||||
| **Model: Rule** | `enterprise/app/models/captain/lifecycle/rule.rb` | `EVENTS` constant, `MESSAGE_TYPES`, `#matches_reservation?` com 3 filtros |
|
||||
| **Model: Delivery** | `enterprise/app/models/captain/lifecycle/delivery.rb` | Audit log. `mark_sent!/skipped!/cancelled!/failed!`, `.count_sent_for_reservation(id)` |
|
||||
| **Model ext** | `enterprise/app/models/captain/unit.rb` | `concierge_persona_name` (default Sofia), `concierge_knowledge`, `concierge_variables`, `concierge_configured?`, `belongs_to :concierge_inbox` |
|
||||
| **Model ext** | `enterprise/app/models/captain/reservation.rb` | 3 hooks: `after_create_commit :schedule_lifecycle_rules`, `after_update_commit :handle_lifecycle_status_change` (on `saved_change_to_status?` + status in cancelled/no_show), `after_update_commit :handle_lifecycle_checkin_change` (on `saved_change_to_check_in_at?`). Todos com `rescue StandardError` pra nunca derrubar a reserva |
|
||||
| **Service: EventResolver** | `enterprise/app/services/captain/lifecycle/event_resolver.rb` | `.resolve(reservation, event)` → timestamp. `checkin.detected`/`checkout.detected` retornam nil (unsupported no MVP) |
|
||||
| **Service: Scheduler** | `enterprise/app/services/captain/lifecycle/scheduler.rb` | `.schedule_for(reservation)`, `.cancel_pending(reservation)`, `.reschedule_for_checkin_change(reservation)` — cria deliveries + enqueue Sidekiq |
|
||||
| **Service: ContextBuilder** | `enterprise/app/services/captain/lifecycle/context_builder.rb` | `.build(reservation)` → `{ 'customer' => {...}, 'reservation' => {...}, 'hotel' => {...} }` |
|
||||
| **Guards** | `enterprise/app/services/captain/lifecycle/guards/*.rb` | Base + 6 guards: ReservationActive, OptOutLabel, MaxPerReservation (CAP=5), QuietHours (Opção C 2h), MinInterval, CustomerReplied |
|
||||
| **Service: Dispatcher** | `enterprise/app/services/captain/lifecycle/dispatcher.rb` | Pipeline: `call` → guards → render → `send_message` → `mark_sent!`. Order matters, stop on first non-pass |
|
||||
| **Job** | `enterprise/app/jobs/captain/lifecycle/dispatcher_job.rb` | `perform(delivery_id)` chama Dispatcher. `self.perform_at(time, id)` delega pra `set(wait_until:).perform_later` |
|
||||
| **Liquid** | `enterprise/app/models/concerns/agentable.rb` | `render_orchestrator_prompt` agora injeta `concierge:` e `reservation:` no enhanced_context, resolvidos a partir de `conversation.custom_attributes['current_unit_id']` |
|
||||
| **Liquid templates** | `enterprise/lib/captain/prompts/concierge.liquid` + `snippets/concierge.liquid` | Template padrão da Sofia (o dono reescreve) |
|
||||
| **WuzAPI** | `app/services/wuzapi/client.rb` | `send_buttons`, `send_list`, `send_url_button` |
|
||||
| **WuzAPI provider** | `app/services/whatsapp/providers/wuzapi_service.rb` | `send_interactive_message(phone, payload)` roteia por `type` |
|
||||
| **Seed** | `db/seeds/captain_lifecycle_demo.rb` | Smoke test via `bundle exec rails runner` |
|
||||
|
||||
## Gotchas descobertos durante a implementação (IMPORTANTE — não repetir)
|
||||
|
||||
Estas armadilhas custaram tempo nos subagentes — documento aqui pra próxima sessão não tropeçar:
|
||||
|
||||
### 1. `Captain::Reservation` associa `unit`, não `captain_unit`
|
||||
|
||||
A coluna é `captain_unit_id` mas a associação é `belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id'`. Então no factory e nos testes é **`unit: unit`**, não `captain_unit: unit`. Cuidado com copy-paste de código do spec.
|
||||
|
||||
### 2. Factory `:captain_unit` foi consertada em `325f05c3e`
|
||||
|
||||
Antes, `association :brand, account: account` era quebrado (FactoryBot não avalia `account` lazily nessa forma). Fix aplicado: bloco `brand do ... end` que busca/cria brand na mesma conta. **Agora funciona:** `create(:captain_unit)` e `create(:captain_unit, account: some_account)` são idiomáticos de novo. Todos os specs do lifecycle usaram workaround `Captain::Unit.create!(account:, brand:, name:)` porque o bug existia quando foram escritos — não são wrong, só não-idiomáticos. Não precisa refatorar.
|
||||
|
||||
### 3. Factory `:captain_reservation` foi criada na T3
|
||||
|
||||
Estava ausente do repo antes desta feature. Criada em `spec/factories/captain/reservation.rb` com `unit { nil }` default. Qualquer spec que precise de reserva com unit específica deve passar `unit: unit`.
|
||||
|
||||
### 4. `Wuzapi::Client#request` retorna `Hash`, não `Faraday::Response`
|
||||
|
||||
O cliente tem `handle_response` interno que parseia o body. Então `client.send_buttons(...)` retorna um hash, não uma response. Tests não podem usar `be_success` — usam `be_a(Hash)` ou testam chaves do hash retornado.
|
||||
|
||||
### 5. `perform_at` não é nativo do ActiveJob (só Sidekiq)
|
||||
|
||||
A T9 reimplementou como class method em `DispatcherJob`:
|
||||
|
||||
```ruby
|
||||
def self.perform_at(wait_until, delivery_id)
|
||||
set(wait_until: wait_until).perform_later(delivery_id)
|
||||
end
|
||||
```
|
||||
|
||||
Isso é a forma idiomática do ActiveJob pra agendar. Scheduler e Dispatcher chamam via `Captain::Lifecycle::DispatcherJob.perform_at(fire_at, delivery_id)`.
|
||||
|
||||
### 6. `belongs_to :conversation` / `belongs_to :message` precisam de `class_name: '::Conversation'`
|
||||
|
||||
Rails autoload resolve `Conversation` dentro de `Captain::Lifecycle::Delivery` como `Captain::Lifecycle::Conversation` (inexistente), causando `ArgumentError` em runtime. A T14 consertou em `delivery.rb`. Mesma gotcha vale pra qualquer model novo dentro do namespace `Captain::Lifecycle::*` que referencie modelos top-level.
|
||||
|
||||
### 7. `build_stubbed` falha com FK constraints
|
||||
|
||||
No Dispatcher spec (T15), `mark_sent!` persiste `conversation_id` e `message_id`. Stubs feitos com `build_stubbed` quebram na constraint. Solução: usa `create(:conversation)` + `create(:message)` reais no before block, mesmo quando a intenção é só stub.
|
||||
|
||||
### 8. Lifecycle hook pode criar deliveries "fantasmas" em specs de outros models
|
||||
|
||||
Todo `create(:captain_reservation)` agora dispara `after_create_commit :schedule_lifecycle_rules` → Scheduler roda → se houver rules na DB, cria deliveries. Specs que contam deliveries precisam ou (a) criar reservation ANTES das rules, ou (b) stubar `Captain::Lifecycle::Scheduler.schedule_for`. A T9 e T14 já ajustaram os specs afetados, mas **qualquer spec novo que instancia reserva + conta deliveries precisa lembrar disso.**
|
||||
|
||||
### 9. `captain_unit` tem campo `brand_id` obrigatório — não `captain_brand_id`
|
||||
|
||||
A coluna é nomeada assim, diferente da tabela (`captain_brands`). Testes que criam unit precisam passar `brand:` (nome da associação), não `captain_brand:`.
|
||||
|
||||
## Specs — onde estão e como rodar
|
||||
|
||||
Suite completa do lifecycle:
|
||||
|
||||
```bash
|
||||
eval "$(rbenv init - zsh)" && rbenv shell 3.4.4 && 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_accessors_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 \
|
||||
spec/factories/captain_unit_factory_spec.rb
|
||||
```
|
||||
|
||||
Esperado no check final: **~120 examples, 0 failures, 0 pending.** Tempo total ~8s.
|
||||
|
||||
Regression gate (specs de modelos pré-existentes que foram tocados):
|
||||
|
||||
```bash
|
||||
bundle exec rspec spec/enterprise/models/captain/reservation_spec.rb
|
||||
```
|
||||
|
||||
## O que NÃO foi implementado (e por quê)
|
||||
|
||||
### Fora de escopo deste plano (estão no spec, fase 2+)
|
||||
|
||||
- **Detecção heurística de `checkin.detected` / `checkout.detected`** — os eventos existem na enum, mas `EventResolver` retorna `nil`, ou seja, rules com esses eventos nunca disparam. Deferido pro backlog (`Ideias/Lifecycle Automation/backlog.md`)
|
||||
- **Dashboard analítico / métricas Prometheus** — logs estruturados sim, Prometheus não. Deferido
|
||||
- **Retention job (purge de deliveries >180 dias)** — design no spec, não implementado. Pode virar uma task de 10min depois
|
||||
- **Cron de 1-em-1-min pra destravar mensagens adiadas por quiet hours** — não necessário no MVP porque quiet hours é default false. Se algum cliente ligar, re-avaliar
|
||||
|
||||
### Próximo plano (Fase B — UI)
|
||||
|
||||
Seção 9 do spec. Três tabs dentro de uma aba nova "Jornada do Cliente" no menu Captain:
|
||||
|
||||
- **Regras** — lista + wizard de 4 passos + templates prontos + editor de mensagem com autocomplete
|
||||
- **Configurações** — guards + Sofia por unidade (inbox, persona_name, knowledge textarea, variables key/value)
|
||||
- **Histórico** — lista da `captain_lifecycle_deliveries` com preview modal do `rendered_body`
|
||||
|
||||
Estimativa: 15-20 tasks Vue 3 + Pinia + rotas Rails/jbuilder. Multilíngue pt_BR + en obrigatório. Vai precisar de endpoints REST que ainda não existem (CRUD de rules, update de config, GET de deliveries, GET/PATCH de concierge_config em captain_units).
|
||||
|
||||
## Como testar manualmente agora (sem UI)
|
||||
|
||||
Via console Rails (precisa ter `Account.first` + `Captain::Unit` + `Inbox`):
|
||||
|
||||
```ruby
|
||||
# Console
|
||||
account = Account.first
|
||||
unit = account.captain_units.first
|
||||
inbox = unit.inboxes.first || Inbox.first
|
||||
|
||||
unit.update!(
|
||||
concierge_inbox_id: inbox.id,
|
||||
concierge_config: {
|
||||
'persona_name' => 'Sofia',
|
||||
'knowledge' => '# Sobre\nHotel teste.\n',
|
||||
'variables' => { 'wifi_password' => 'teste123', 'menu_link' => 'https://menu.x' }
|
||||
}
|
||||
)
|
||||
|
||||
rule = Captain::Lifecycle::Rule.create!(
|
||||
account: account,
|
||||
name: 'Demo pré check-in',
|
||||
event: 'checkin.scheduled_at',
|
||||
offset_minutes: -10,
|
||||
message_body: 'Oi {{ customer.first_name }}! Wifi: {{ hotel.wifi_password }}'
|
||||
)
|
||||
|
||||
# Agora cria uma reserva — delivery é agendada automaticamente via hook
|
||||
contact = account.contacts.first || Contact.create!(account: account, name: 'Teste', phone_number: '+5561900000000')
|
||||
reservation = Captain::Reservation.create!(
|
||||
account: account,
|
||||
unit: unit,
|
||||
contact: contact,
|
||||
suite_identifier: 'Alexa',
|
||||
status: :scheduled,
|
||||
total_amount: 160,
|
||||
check_in_at: 20.minutes.from_now,
|
||||
check_out_at: 8.hours.from_now,
|
||||
inbox: inbox
|
||||
)
|
||||
|
||||
# Ver a delivery agendada
|
||||
delivery = Captain::Lifecycle::Delivery.where(captain_reservation_id: reservation.id).last
|
||||
puts "Delivery ##{delivery.id} status=#{delivery.status} fire_at=#{delivery.fire_at}"
|
||||
|
||||
# Disparar manual (sem esperar fire_at)
|
||||
Captain::Lifecycle::DispatcherJob.perform_now(delivery.id)
|
||||
|
||||
# Checar resultado
|
||||
delivery.reload
|
||||
puts "Status: #{delivery.status}"
|
||||
puts "Rendered: #{delivery.rendered_body}"
|
||||
puts "Skip reason: #{delivery.skip_reason}" if delivery.status == 'skipped'
|
||||
```
|
||||
|
||||
Ou via seed:
|
||||
|
||||
```bash
|
||||
eval "$(rbenv init - zsh)" && rbenv shell 3.4.4 && bundle exec rails runner db/seeds/captain_lifecycle_demo.rb
|
||||
```
|
||||
|
||||
## Arquivos e pastas relevantes (resumo de ponteiros)
|
||||
|
||||
- **Spec de design:** `docs/superpowers/specs/2026-04-15-jornada-do-cliente-design.md`
|
||||
- **Plano backend:** `docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend.md`
|
||||
- **Backlog de ideias:** `/Users/user/Documents/Obsidian Vault/Ideias/Lifecycle Automation/backlog.md`
|
||||
- **Docs Captain prompts (Jasmine):** `/Users/user/Documents/Obsidian Vault/Dev/Prompts/Captain Chatwoot/`
|
||||
- **Models:** `enterprise/app/models/captain/lifecycle/`
|
||||
- **Services:** `enterprise/app/services/captain/lifecycle/`
|
||||
- **Jobs:** `enterprise/app/jobs/captain/lifecycle/`
|
||||
- **Specs:** `spec/enterprise/*/captain/lifecycle/` + `spec/enterprise/integration/captain/lifecycle_flow_spec.rb`
|
||||
Loading…
Reference in New Issue
Block a user