From 2216e082c1b541c4fcf2d4c703fa5ace38c51950 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Mon, 13 Apr 2026 23:37:43 -0300 Subject: [PATCH] docs: plano Fase 2+3 (backend Chatwoot + fluxo publico completo) Plano combinado com 19 tasks bite-sized: - Parte A: seed de dados de teste em reserva_hotel - Parte B (Fase 2): controller publico Chatwoot com 2 endpoints, auth por token, 8 specs RSpec, smoke test via curl - Parte C (Fase 3): client HTTP, formatadores, catalogoService, useReservationForm, StayDetailsStep, ImageGallery, PriceSummary, CustomerForm, PixCheckout com polling, SuccessScreen, ReservationFlow Usa Captain::Unit id=4 (Hotel 1001 Aguas Lindas, inbox_id=2) como unidade de teste (ja configurada com credenciais Inter). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...13-reserva-1001-fase-2-3-fluxo-completo.md | 1984 +++++++++++++++++ 1 file changed, 1984 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-reserva-1001-fase-2-3-fluxo-completo.md diff --git a/docs/superpowers/plans/2026-04-13-reserva-1001-fase-2-3-fluxo-completo.md b/docs/superpowers/plans/2026-04-13-reserva-1001-fase-2-3-fluxo-completo.md new file mode 100644 index 000000000..9c14e0db2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-reserva-1001-fase-2-3-fluxo-completo.md @@ -0,0 +1,1984 @@ +# Reserva Rede 1001 — Fase 2+3: Backend Chatwoot + Fluxo Público Completo + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fluxo completo onde o cliente abre a página `reserva-1001`, escolhe marca → unidade → permanência → categoria → data → vê fotos + preço, preenche seus dados, clica "Confirmar e Pagar" e recebe um **QR Code PIX real gerado pela API do Chatwoot** (que chama a Inter), paga, e a página detecta o pagamento via polling. No Chatwoot a atendente recebe uma mensagem automática "✅ Pagamento confirmado!" na conversa. + +**Architecture (2 partes):** +1. **Backend (Chatwoot)**: controller público novo `Public::Api::V1::Captain::PublicReservationsController` com 2 endpoints (POST cria reserva + PIX, GET retorna status). Reusa 100% do `Captain::Inter::CobService`, `ConfirmationService` e webhook Inter existentes. +2. **Frontend (reserva-1001)**: refatora `App.tsx` em fluxo multi-step, adiciona hook `useReservationForm`, services do Supabase (unidades/categorias/preços/fotos), componentes de form em cascata, tela de checkout com polling, tela de sucesso. + +**Tech Stack:** Rails 7 (backend) · React 19 + TypeScript + Supabase (frontend) · Inter API (PIX, via Chatwoot) · RSpec · Vitest + +**Spec:** `docs/superpowers/specs/2026-04-13-reserva-1001-design.md` +**Fase 1 (pré-requisito):** `docs/superpowers/plans/2026-04-13-reserva-1001-fase-1-fundacao.md` — COMPLETO + +--- + +## Dados conhecidos (já investigados) + +**Chatwoot dev DB:** +- `Captain::Unit` id=4, name="Hotel 1001 Águas Lindas", account_id=1, inbox_id=2, **Inter credentials completas (client_id/secret/cert/key/pix_key)** → usar como unidade de teste. +- Unit id=3 "Hotel Recanto" tem inbox_id=1 como segunda opção. + +**Modelos Chatwoot relevantes:** +- `Captain::Reservation` (`enterprise/app/models/captain/reservation.rb`) — campos obrigatórios: `account`, `inbox`, `contact`, `contact_inbox`, `suite_identifier`, `check_in_at`, `check_out_at`. Enum status: `{scheduled:0, active:1, completed:2, cancelled:3, pending_payment:4, draft:5, confirmed:6}`. `payment_status` é string ('pending'/'paid'). +- `Captain::PixCharge` (`enterprise/app/models/captain/pix_charge.rb`) — `txid`, `pix_copia_e_cola`, `status` enum `{active, paid, expired, failed}`, pertence a `Reservation` e `Unit`. +- `Captain::Inter::CobService#call` — assinatura: `initialize(reservation, amount: nil)`. Retorna `Captain::PixCharge`. Atualiza `reservation.current_pix_charge_id`. +- `Captain::Payments::ConfirmationService#perform` — marca reservation paga, adiciona labels, cria mensagem automática na conversa. + +**Builders:** +- `ContactInboxWithContactBuilder.new(source_id:, inbox:, contact_attributes:).perform` — cria/dedupe contact + contact_inbox. +- `ConversationBuilder.new(params:, contact_inbox:).perform` — cria conversation. +- `Messages::MessageBuilder.new(user, conversation, params).perform` — cria mensagem. + +**Rotas existentes:** +- Webhook Inter: `POST /api/v1/captain/webhooks/inter_pix` (público, sem auth) +- Short payment link: `GET /r/:token` + +**Supabase `reserva_hotel` (hoje):** +- `marcas`: 1 row ("Hotel 1001 Noites", categorias=["Standard","Superior","Luxo"], permanencias=["3hrs","6hrs","Pernoite"]) +- `unidades`, `precos`, `fotos_categoria`: 0 rows cada +- Task A0 (seed) popula isso com dados de teste. + +--- + +## File Structure + +**Backend (dentro de `chatwoot/`):** +``` +enterprise/app/controllers/public/api/v1/captain/ +└── public_reservations_controller.rb # novo + +config/routes.rb # adicionar 2 rotas + +spec/enterprise/controllers/public/api/v1/captain/ +└── public_reservations_controller_spec.rb # novo +``` + +**Frontend (dentro de `reserva-1001/src/`):** +``` +lib/ +├── chatwootApi.ts # novo — client HTTP do endpoint novo +├── formatters.ts # novo — utilidades BRL, CPF, tel +└── supabase.ts # existente + +services/ # NOVO +├── unidadesService.ts # lista unidades por marca +├── categoriasService.ts # resolve categorias visíveis por unidade +├── precosService.ts # resolve preço por categoria+permanência+day_range +└── fotosService.ts # fotos por unidade+categoria + +hooks/ # NOVO +└── useReservationForm.ts # estado consolidado do form + +components/ +├── FormField.tsx # existente +├── SelectField.tsx # existente +├── reservation/ # NOVO +│ ├── ReservationFlow.tsx # orquestra as etapas +│ ├── StayDetailsStep.tsx # marca→unidade→permanência→categoria→data +│ ├── ImageGallery.tsx # grid de fotos +│ ├── PriceSummary.tsx # preço total + 50% PIX +│ ├── CustomerForm.tsx # nome/tel/CPF/email/obs +│ └── SubmitButton.tsx +├── checkout/ # NOVO +│ ├── PixCheckout.tsx # QR + copia-cola + timer + polling +│ └── SuccessScreen.tsx # confirmação +└── ui/ + └── button.tsx # existente + +App.tsx # reescrito pra montar ReservationFlow / PixCheckout / SuccessScreen +``` + +--- + +## Pré-requisitos (informações que eu, controller, tenho antes de dispatchar qualquer subagente) + +1. **Credenciais Supabase** já em `reserva-1001/.env.local` (Fase 1 feita) +2. **Chatwoot dev rodando** em `http://localhost:3000` (para Fase 2 testar o endpoint via curl e para Fase 3 bater contra) +3. **Captain::Unit id=4 (Hotel 1001 Águas Lindas)** é a unidade de teste conhecida, inbox_id=2, account_id=1 +4. **Token de autenticação do endpoint público**: vamos definir `RESERVA_1001_API_TOKEN=dev-token-change-in-prod` em `chatwoot/.env` (dev) e `reserva-1001/.env.local` (VITE_CHATWOOT_API_TOKEN) + +--- + +# PARTE A — Dados de teste (Supabase seed) + +## Task A0: Seed de dados de teste em `reserva_hotel` + +**Contexto:** Sem unidade, preços e fotos no Supabase, o frontend não tem o que renderizar. Esta task popula o mínimo necessário pra testar todo o fluxo com a unidade Hotel 1001 Águas Lindas (Captain::Unit id=4 no Chatwoot). + +**Files:** +- Create: `reserva-1001/supabase/migrations/20260413000002_seed_dados_teste.sql` + +- [ ] **Step 1: Aplicar seed via MCP (apply_migration)** + +Migration name: `reserva_1001_seed_dados_teste` + +Query SQL: +```sql +-- Usa a marca já existente ("Hotel 1001 Noites") e cria: +-- • 1 unidade (Águas Lindas) amarrada ao Captain::Unit id=4 +-- • 6 preços (2 categorias × 3 permanências, weekday) +-- • 4 fotos por categoria (URLs placeholders do Unsplash) +-- • 2 extras (adicional tira-gosto + decoração romântica) + +do $$ +declare + v_marca_id uuid; + v_unidade_id uuid; +begin + -- 1. Garante que a marca tem categorias e permanências coerentes com os preços + select id into v_marca_id from reserva_hotel.marcas where nome = 'Hotel 1001 Noites' limit 1; + + if v_marca_id is null then + insert into reserva_hotel.marcas (nome, categorias, permanencias, ativa) + values ('Hotel 1001 Noites', + array['Standard','Hidromassagem'], + array['3hrs','4hrs','Pernoite'], + true) + returning id into v_marca_id; + else + update reserva_hotel.marcas + set categorias = array['Standard','Hidromassagem'], + permanencias = array['3hrs','4hrs','Pernoite'] + where id = v_marca_id; + end if; + + -- 2. Unidade amarrada ao Captain::Unit id=4 + insert into reserva_hotel.unidades + (nome, id_marca, id_conta_pagamento, categorias_visiveis, + endereco, telefone, ativa, chatwoot_unit_id) + select + 'Hotel 1001 Águas Lindas', + v_marca_id, + (select id from reserva_hotel.contas_pagamento limit 1), + array['Standard','Hidromassagem'], + 'Águas Lindas, GO', + '(61) 99999-0000', + true, + 4 + on conflict do nothing + returning id into v_unidade_id; + + if v_unidade_id is null then + select id into v_unidade_id + from reserva_hotel.unidades + where chatwoot_unit_id = 4 + limit 1; + end if; + + -- 3. Preços (6 rows: 2 categorias × 3 permanências) — weekday default + insert into reserva_hotel.precos + (id_marca, categoria, permanencia, periodo_semana, valor, ativo) + values + (v_marca_id, 'Standard', '3hrs', 'default', 120.00, true), + (v_marca_id, 'Standard', '4hrs', 'default', 150.00, true), + (v_marca_id, 'Standard', 'Pernoite', 'default', 220.00, true), + (v_marca_id, 'Hidromassagem', '3hrs', 'default', 170.00, true), + (v_marca_id, 'Hidromassagem', '4hrs', 'default', 210.00, true), + (v_marca_id, 'Hidromassagem', 'Pernoite', 'default', 290.00, true) + on conflict do nothing; + + -- 4. Fotos (URLs placeholder Unsplash, 2 por categoria) + insert into reserva_hotel.fotos_categoria + (id_unidade, categoria, url_foto, alt, ordem) + values + (v_unidade_id, 'Standard', + 'https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=800', 'Suíte Standard vista 1', 0), + (v_unidade_id, 'Standard', + 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800', 'Suíte Standard vista 2', 1), + (v_unidade_id, 'Hidromassagem', + 'https://images.unsplash.com/photo-1582719508461-905c673771fd?w=800', 'Suíte Hidromassagem vista 1', 0), + (v_unidade_id, 'Hidromassagem', + 'https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=800', 'Suíte Hidromassagem vista 2', 1) + on conflict do nothing; + + -- 5. Extras simples + insert into reserva_hotel.extras + (id_marca, titulo, descricao, preco, ativo, ordem) + values + (v_marca_id, 'Tira-gosto', 'Porção de petiscos servida na suíte', 35.00, true, 0), + (v_marca_id, 'Decoração romântica', 'Pétalas, velas e champanhe', 90.00, true, 1) + on conflict do nothing; +end $$; +``` + +*(Controller nota: aplicar via `mcp__claude_ai_supabase__apply_migration` com project_id `acdvblhzzaneddlxqyst`, name `reserva_1001_seed_dados_teste`.)* + +- [ ] **Step 2: Validar que os dados estão lá** + +Query via MCP `execute_sql`: +```sql +select + (select count(*) from reserva_hotel.unidades where chatwoot_unit_id=4) as unidades, + (select count(*) from reserva_hotel.precos) as precos, + (select count(*) from reserva_hotel.fotos_categoria) as fotos, + (select count(*) from reserva_hotel.extras) as extras; +``` + +Expected: `unidades=1, precos=6, fotos=4, extras=2`. + +- [ ] **Step 3: Commit do arquivo de migration** + +```bash +cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001 +git add supabase/migrations/20260413000002_seed_dados_teste.sql +git commit -m "feat: seed de dados de teste (unidade aguas lindas, precos, fotos, extras)" +``` + +--- + +# PARTE B — Backend Chatwoot (Fase 2) + +## Task B1: Variável de ambiente para o token + +**Files:** +- Modify: `chatwoot/.env` (não commitar — só local) +- Modify: `chatwoot/.env.example` (commitar) + +- [ ] **Step 1: Adicionar ao `.env` local** + +Append a `chatwoot/.env`: +``` +# Reserva Rede 1001 — Public API token +RESERVA_1001_API_TOKEN=dev-token-change-in-prod +``` + +- [ ] **Step 2: Adicionar ao `.env.example`** + +Append a `chatwoot/.env.example`: +``` +# Reserva Rede 1001 — public reservations API (Fase 2) +# Token usado para autenticar chamadas do app reserva-1001 ao endpoint publico +# de geracao de reservas/PIX. Gerar via `openssl rand -hex 32` em producao. +RESERVA_1001_API_TOKEN= +``` + +- [ ] **Step 3: Commit apenas o `.env.example`** + +```bash +cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/chatwoot +git add .env.example +git commit -m "chore: documenta RESERVA_1001_API_TOKEN no .env.example" +``` + +--- + +## Task B2: Teste RSpec — autenticação por token + +**Files:** +- Create: `chatwoot/spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb` + +- [ ] **Step 1: Criar o spec com o primeiro teste (TDD — falha porque controller ainda não existe)** + +Create `spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb`: +```ruby +require 'rails_helper' + +RSpec.describe 'Public Captain Reservations API', type: :request do + let(:unit) { create(:captain_unit, account: create(:account)) } + let(:valid_token) { 'test-token-abc' } + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('RESERVA_1001_API_TOKEN', any_args).and_return(valid_token) + end + + describe 'POST /public/api/v1/captain/public_reservations' do + context 'without token' do + it 'returns 401' do + post '/public/api/v1/captain/public_reservations', + params: { chatwoot_unit_id: unit.id }.to_json, + headers: { 'Content-Type' => 'application/json' } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with invalid token' do + it 'returns 401' do + post '/public/api/v1/captain/public_reservations', + params: { chatwoot_unit_id: unit.id }.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-Reserva-Token' => 'wrong' + } + + expect(response).to have_http_status(:unauthorized) + end + end + end +end +``` + +- [ ] **Step 2: Rodar o spec (deve falhar — rota/controller não existem)** + +```bash +cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/chatwoot +bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -20 +``` + +Expected: FAILURE — rota não existe. + +- [ ] **Step 3: Commit do spec falhando** + +```bash +git add spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb +git commit -m "test: spec para autenticacao do PublicReservationsController (red)" +``` + +--- + +## Task B3: Rota + controller esqueleto com autenticação + +**Files:** +- Modify: `chatwoot/config/routes.rb` +- Create: `chatwoot/enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb` + +- [ ] **Step 1: Ler o `config/routes.rb` atual e encontrar o namespace público** + +```bash +cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/chatwoot +grep -n "public/api/v1/captain" config/routes.rb +``` + +Expected: mostra a linha do `inter_webhooks`. A nova rota vai logo abaixo. + +- [ ] **Step 2: Adicionar rota** + +Edit `config/routes.rb` — encontrar o bloco que registra `inter_pix` webhook (algo como `post '/api/v1/captain/webhooks/inter_pix', to: 'public/api/v1/captain/inter_webhooks#create'`) e adicionar na sequência: + +```ruby +# Reserva Rede 1001 — public reservation API +namespace :public, defaults: { format: 'json' } do + namespace :api do + namespace :v1 do + namespace :captain do + resources :public_reservations, only: [:create, :show] do + member do + get :status + end + end + end + end + end +end +``` + +Se já existir um namespace `public/api/v1/captain` aberto por outro motivo, colocar o `resources :public_reservations` dentro dele. + +- [ ] **Step 3: Criar o controller com auth-only** + +Create `enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb`: +```ruby +class Public::Api::V1::Captain::PublicReservationsController < ActionController::API + include ActionController::Cookies + + before_action :authenticate_reserva_token! + + # POST /public/api/v1/captain/public_reservations + def create + render json: { error: 'not_implemented' }, status: :not_implemented + end + + # GET /public/api/v1/captain/public_reservations/:id/status + def status + render json: { error: 'not_implemented' }, status: :not_implemented + end + + private + + def authenticate_reserva_token! + expected = ENV.fetch('RESERVA_1001_API_TOKEN', nil) + provided = request.headers['X-Reserva-Token'] + + if expected.blank? + Rails.logger.error('[PublicReservations] RESERVA_1001_API_TOKEN not configured') + render json: { error: 'service_unavailable' }, status: :service_unavailable and return + end + + if provided.blank? || !ActiveSupport::SecurityUtils.secure_compare(provided, expected) + render json: { error: 'unauthorized' }, status: :unauthorized and return + end + end +end +``` + +- [ ] **Step 4: Rodar o spec novamente (deve passar nos testes de auth)** + +```bash +bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -20 +``` + +Expected: 2 passed (`without token` → 401, `with invalid token` → 401). + +Se falhar por problema de carregamento do arquivo, garantir que `enterprise/app/controllers/public/api/v1/captain/` é autoloaded (seguir o mesmo padrão do `inter_webhooks_controller.rb`). + +- [ ] **Step 5: Commit** + +```bash +git add config/routes.rb enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb +git commit -m "feat: rota + controller esqueleto PublicReservations com token auth" +``` + +--- + +## Task B4: Teste RSpec — criar reserva + PIX (happy path) + +**Files:** +- Modify: `chatwoot/spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb` + +- [ ] **Step 1: Adicionar contexto e teste de criação bem-sucedida** + +Adicionar dentro do `describe 'POST ...'` bloco: + +```ruby + context 'with valid token and valid payload' do + let(:unit) do + create( + :captain_unit, + account: account, + inbox: inbox, + inter_client_id: 'fake-id', + inter_client_secret: 'fake-secret', + inter_pix_key: 'fake-key@test.com', + inter_account_number: '1234567', + inter_cert_content: 'FAKE-CERT', + inter_key_content: 'FAKE-KEY' + ) + end + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + + let(:valid_payload) do + { + chatwoot_unit_id: unit.id, + category: 'Standard', + stay_type: '3hrs', + checkin_at: 2.hours.from_now.iso8601, + customer: { + name: 'Maria Teste', + phone: '+5561999990000', + cpf: '12345678909', + email: 'maria@teste.com' + }, + total_cents: 12_000, + deposit_cents: 6_000, + notes: 'Chegaremos às 15h' + } + end + + let(:fake_pix_charge) do + double('PixCharge', + id: 99, + txid: 'test-txid-123', + pix_copia_e_cola: '00020126...', + status: 'active') + end + + before do + allow_any_instance_of(Captain::Inter::CobService).to receive(:call).and_return(fake_pix_charge) + end + + it 'returns 201 with reservation and PIX payload' do + post '/public/api/v1/captain/public_reservations', + params: valid_payload.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-Reserva-Token' => valid_token + } + + expect(response).to have_http_status(:created) + body = JSON.parse(response.body) + expect(body['reservation_id']).to be_present + expect(body['conversation_id']).to be_present + expect(body['pix']['txid']).to eq('test-txid-123') + expect(body['pix']['copia_e_cola']).to start_with('00020126') + end + + it 'creates Contact, ContactInbox, Conversation and Reservation' do + expect { + post '/public/api/v1/captain/public_reservations', + params: valid_payload.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-Reserva-Token' => valid_token + } + }.to change(Contact, :count).by(1) + .and change(Conversation, :count).by(1) + .and change(Captain::Reservation, :count).by(1) + end + end +``` + +- [ ] **Step 2: Rodar (deve falhar — lógica ainda não existe)** + +```bash +bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -25 +``` + +Expected: 2 novos FAIL (501 not_implemented em vez de 201). Os 2 de auth continuam passando. + +- [ ] **Step 3: Commit** + +```bash +git add spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb +git commit -m "test: specs do happy path do PublicReservationsController (red)" +``` + +--- + +## Task B5: Implementar `create` — cria Contact, Conversation, Reservation, chama CobService + +**Files:** +- Modify: `chatwoot/enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb` + +- [ ] **Step 1: Implementar o action `create`** + +Substituir o método `create` do controller por: + +```ruby + def create + unit = Captain::Unit.find_by(id: params[:chatwoot_unit_id]) + return render json: { error: 'unit_not_found' }, status: :not_found if unit.nil? + return render json: { error: 'unit_has_no_inbox' }, status: :unprocessable_entity if unit.inbox_id.blank? + return render json: { error: 'unit_missing_inter_credentials' }, status: :unprocessable_entity unless unit.inter_credentials_present? + + customer = params[:customer] || {} + return render json: { error: 'customer_required' }, status: :unprocessable_entity if customer[:name].blank? + + account = unit.account + inbox = unit.inbox + + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: "reserva1001-#{SecureRandom.uuid}", + inbox: inbox, + contact_attributes: { + name: customer[:name], + phone_number: customer[:phone].presence, + email: customer[:email].presence, + additional_attributes: { + cpf: customer[:cpf].presence, + origem: 'reserva-1001' + }.compact + } + ).perform + + conversation = ConversationBuilder.new( + params: { + additional_attributes: { + source: 'reserva-1001', + reserva_category: params[:category], + reserva_stay_type: params[:stay_type], + reserva_checkin_at: params[:checkin_at] + } + }, + contact_inbox: contact_inbox + ).perform + + initial_note = <<~NOTE.strip + 🛎️ *Nova reserva via reserva.1001* + Categoria: #{params[:category]} + Permanência: #{params[:stay_type]} + Check-in: #{params[:checkin_at]} + Total: R$ #{format('%.2f', params[:total_cents].to_i / 100.0)} + Entrada (PIX 50%): R$ #{format('%.2f', params[:deposit_cents].to_i / 100.0)} + Observação: #{params[:notes].presence || '—'} + NOTE + + Messages::MessageBuilder.new( + nil, + conversation, + { content: initial_note, message_type: 'outgoing', private: true } + ).perform + + reservation = Captain::Reservation.create!( + account: account, + inbox: inbox, + contact: contact_inbox.contact, + contact_inbox: contact_inbox, + conversation: conversation, + unit: unit, + suite_identifier: "#{params[:category]} · #{params[:stay_type]}", + check_in_at: params[:checkin_at], + check_out_at: checkout_from(params[:checkin_at], params[:stay_type]), + status: :draft, + payment_status: 'pending', + total_amount: (params[:total_cents].to_i / 100.0), + metadata: { + origem: 'reserva-1001', + category: params[:category], + stay_type: params[:stay_type], + deposit_cents: params[:deposit_cents].to_i, + notes: params[:notes] + } + ) + + deposit_amount = (params[:deposit_cents].to_i / 100.0) + charge = Captain::Inter::CobService.new(reservation, amount: deposit_amount).call + reservation.update!(status: :pending_payment) + + render json: { + reservation_id: reservation.id, + conversation_id: conversation.id, + pix: { + txid: charge.txid, + copia_e_cola: charge.pix_copia_e_cola, + qrcode_base64: nil, # gerado no front via biblioteca qrcode (menos round-trip) + expires_at: (Time.current + Captain::PixCharge::EXPIRATION_SECONDS.seconds).iso8601 + } + }, status: :created + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("[PublicReservations] validation error: #{e.message}") + render json: { error: 'validation_failed', details: e.record.errors.full_messages }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error("[PublicReservations] unexpected error: #{e.class} - #{e.message}") + render json: { error: 'internal_error', message: e.message }, status: :internal_server_error + end + + private + + def checkout_from(checkin_iso, stay_type) + checkin = Time.parse(checkin_iso.to_s) + hours = case stay_type + when '2hrs' then 2 + when '3hrs' then 3 + when '4hrs' then 4 + when 'Pernoite', 'pernoite' then 12 + when 'Diaria', 'diaria', 'Diária' then 24 + else 3 + end + checkin + hours.hours + end +``` + +(manter `authenticate_reserva_token!` privado abaixo) + +- [ ] **Step 2: Rodar os specs** + +```bash +bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -30 +``` + +Expected: 4 passing. + +Se falhar por algum motivo (ex: factory não aceita inbox, `Captain::Unit` factory diferente), adaptar os testes/código e iterar até verde. + +- [ ] **Step 3: Commit** + +```bash +git add enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb +git commit -m "feat: implementa POST /public_reservations (contact + conversa + reserva + pix)" +``` + +--- + +## Task B6: Teste + implementação do `status` (GET) + +**Files:** +- Modify: `chatwoot/spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb` +- Modify: `chatwoot/enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb` + +- [ ] **Step 1: Adicionar describe do GET /status** + +Adicionar ao spec: + +```ruby + describe 'GET /public/api/v1/captain/public_reservations/:id/status' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact) } + let(:unit) { create(:captain_unit, account: account, inbox: inbox) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) } + let(:reservation) do + Captain::Reservation.create!( + account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, + conversation: conversation, unit: unit, suite_identifier: 'Standard · 3hrs', + check_in_at: 1.hour.from_now, check_out_at: 4.hours.from_now, + status: :pending_payment, payment_status: 'pending', total_amount: 120.0 + ) + end + + it 'requires token' do + get "/public/api/v1/captain/public_reservations/#{reservation.id}/status" + expect(response).to have_http_status(:unauthorized) + end + + it 'returns pending for unpaid reservation' do + get "/public/api/v1/captain/public_reservations/#{reservation.id}/status", + headers: { 'X-Reserva-Token' => valid_token } + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['status']).to eq('pending') + end + + it 'returns paid after payment_status flips' do + reservation.update!(payment_status: 'paid') + get "/public/api/v1/captain/public_reservations/#{reservation.id}/status", + headers: { 'X-Reserva-Token' => valid_token } + expect(JSON.parse(response.body)['status']).to eq('paid') + end + + it 'returns 404 for unknown reservation' do + get '/public/api/v1/captain/public_reservations/999999/status', + headers: { 'X-Reserva-Token' => valid_token } + expect(response).to have_http_status(:not_found) + end + end +``` + +- [ ] **Step 2: Implementar `status` no controller** + +Substituir o método `status` do controller por: + +```ruby + def status + reservation = Captain::Reservation.find_by(id: params[:id]) + return render json: { error: 'not_found' }, status: :not_found if reservation.nil? + + render json: { + reservation_id: reservation.id, + status: reservation.payment_status + } + end +``` + +- [ ] **Step 3: Rodar os specs** + +```bash +bundle exec rspec spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb 2>&1 | tail -30 +``` + +Expected: 8 passing (4 do POST + 4 do GET). + +- [ ] **Step 4: Commit** + +```bash +git add spec/enterprise/controllers/public/api/v1/captain/public_reservations_controller_spec.rb enterprise/app/controllers/public/api/v1/captain/public_reservations_controller.rb +git commit -m "feat: implementa GET /public_reservations/:id/status" +``` + +--- + +## Task B7: Smoke test via curl contra o Chatwoot local + +**Files:** nenhum arquivo novo. + +- [ ] **Step 1: Garantir que o Chatwoot dev está rodando** + +Se não estiver: abra outro terminal e rode `pnpm run dev` dentro de `chatwoot/`. Espere até ver "Listening on 0.0.0.0:3000". + +- [ ] **Step 2: Curl — tentativa sem token (espera 401)** + +```bash +curl -i -X POST http://localhost:3000/public/api/v1/captain/public_reservations \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +Expected: `HTTP/1.1 401 Unauthorized`. + +- [ ] **Step 3: Curl — tentativa com token válido e payload válido** + +```bash +curl -i -X POST http://localhost:3000/public/api/v1/captain/public_reservations \ + -H "Content-Type: application/json" \ + -H "X-Reserva-Token: dev-token-change-in-prod" \ + -d '{ + "chatwoot_unit_id": 4, + "category": "Standard", + "stay_type": "3hrs", + "checkin_at": "2026-04-14T23:00:00-03:00", + "customer": { + "name": "Teste Curl", + "phone": "+5561999998877", + "cpf": "12345678909", + "email": "teste@curl.com" + }, + "total_cents": 12000, + "deposit_cents": 6000, + "notes": "smoke test" + }' +``` + +Expected: `HTTP/1.1 201 Created` com body JSON contendo `reservation_id`, `conversation_id`, `pix.txid`, `pix.copia_e_cola`. + +**Se a Inter real responder com erro** (cert inválido no dev, endpoint bloqueado, etc): aceitar como "falhou por motivo externo à nossa lógica" e documentar. O RSpec já validou nossa lógica com mock. + +- [ ] **Step 4: Curl — GET /status** + +```bash +# Substituir pelo reservation_id retornado acima +curl -i http://localhost:3000/public/api/v1/captain/public_reservations//status \ + -H "X-Reserva-Token: dev-token-change-in-prod" +``` + +Expected: `200 OK` com `{"reservation_id":...,"status":"pending"}`. + +- [ ] **Step 5: Commit (se houve ajustes)** + +Se os smoke tests exigiram correções no código, commit como `fix:`. + +--- + +# PARTE C — Frontend (Fase 3) + +## Task C1: Client HTTP do Chatwoot + +**Files:** +- Modify: `reserva-1001/.env.local` e `.env.local.example` +- Create: `reserva-1001/src/lib/chatwootApi.ts` + +- [ ] **Step 1: Garantir variáveis de ambiente** + +Edit `reserva-1001/.env.local`, adicionar/completar: +``` +VITE_CHATWOOT_API_URL=http://localhost:3000 +VITE_CHATWOOT_API_TOKEN=dev-token-change-in-prod +``` + +Edit `.env.local.example` do mesmo jeito (com token em branco). + +- [ ] **Step 2: Criar o client** + +Create `reserva-1001/src/lib/chatwootApi.ts`: +```ts +const API_URL = import.meta.env.VITE_CHATWOOT_API_URL +const API_TOKEN = import.meta.env.VITE_CHATWOOT_API_TOKEN + +if (!API_URL || !API_TOKEN) { + console.warn('VITE_CHATWOOT_API_URL / VITE_CHATWOOT_API_TOKEN nao definidos') +} + +export interface CreateReservationInput { + chatwoot_unit_id: number + category: string + stay_type: string + checkin_at: string // ISO + customer: { + name: string + phone: string + cpf: string + email?: string + } + total_cents: number + deposit_cents: number + notes?: string +} + +export interface CreateReservationResponse { + reservation_id: number + conversation_id: number + pix: { + txid: string + copia_e_cola: string + qrcode_base64: string | null + expires_at: string + } +} + +export interface StatusResponse { + reservation_id: number + status: 'pending' | 'paid' | 'expired' | 'canceled' +} + +async function request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${API_URL}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + 'X-Reserva-Token': API_TOKEN, + ...(init.headers ?? {}), + }, + }) + + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Chatwoot API ${res.status}: ${body || res.statusText}`) + } + + return (await res.json()) as T +} + +export const chatwootApi = { + createReservation(input: CreateReservationInput): Promise { + return request('/public/api/v1/captain/public_reservations', { + method: 'POST', + body: JSON.stringify(input), + }) + }, + + getStatus(id: number): Promise { + return request(`/public/api/v1/captain/public_reservations/${id}/status`) + }, +} +``` + +- [ ] **Step 3: Typecheck + commit** + +```bash +cd /Users/user/Dev/Produtos/Chatwoot-fazer-ai/fazer-ai-kanban/reserva-1001 +pnpm typecheck +git add .env.local.example src/lib/chatwootApi.ts +git commit -m "feat: client http do endpoint publico do chatwoot" +``` + +--- + +## Task C2: Formatadores (BRL, CPF, telefone) + +**Files:** +- Create: `reserva-1001/src/lib/formatters.ts` + +- [ ] **Step 1: Criar** + +```ts +export function formatBRL(cents: number): string { + return (cents / 100).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL', + }) +} + +export function maskCPF(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, 11) + return digits + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d{1,2})$/, '$1-$2') +} + +export function maskPhone(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, 11) + if (digits.length <= 10) { + return digits + .replace(/(\d{2})(\d)/, '($1) $2') + .replace(/(\d{4})(\d)/, '$1-$2') + } + return digits + .replace(/(\d{2})(\d)/, '($1) $2') + .replace(/(\d{5})(\d)/, '$1-$2') +} + +export function onlyDigits(value: string): string { + return value.replace(/\D/g, '') +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/lib/formatters.ts +git commit -m "feat: formatadores BRL, CPF e telefone" +``` + +--- + +## Task C3: Services do Supabase — unidades, preços, fotos, extras + +**Files:** +- Create: `reserva-1001/src/services/catalogoService.ts` + +- [ ] **Step 1: Criar** + +```ts +import { supabase } from '@/lib/supabase' +import type { Database } from '@/types/database' + +type Marca = Database['reserva_hotel']['Tables']['marcas']['Row'] +type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row'] +type Preco = Database['reserva_hotel']['Tables']['precos']['Row'] +type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row'] +type Extra = Database['reserva_hotel']['Tables']['extras']['Row'] + +export const catalogoService = { + async listMarcas(): Promise { + const { data, error } = await supabase + .from('marcas') + .select('*') + .eq('ativa', true) + .order('nome') + if (error) throw new Error(error.message) + return data ?? [] + }, + + async listUnidades(marcaId: string): Promise { + const { data, error } = await supabase + .from('unidades') + .select('*') + .eq('id_marca', marcaId) + .eq('ativa', true) + .order('nome') + if (error) throw new Error(error.message) + return data ?? [] + }, + + async findPreco( + marcaId: string, + categoria: string, + permanencia: string, + periodo = 'default' + ): Promise { + const { data, error } = await supabase + .from('precos') + .select('*') + .eq('id_marca', marcaId) + .eq('categoria', categoria) + .eq('permanencia', permanencia) + .eq('periodo_semana', periodo) + .eq('ativo', true) + .maybeSingle() + if (error) throw new Error(error.message) + return data + }, + + async listFotos(unidadeId: string, categoria: string): Promise { + const { data, error } = await supabase + .from('fotos_categoria') + .select('*') + .eq('id_unidade', unidadeId) + .eq('categoria', categoria) + .eq('ativa', true) + .order('ordem') + if (error) throw new Error(error.message) + return data ?? [] + }, + + async listExtras(marcaId: string): Promise { + const { data, error } = await supabase + .from('extras') + .select('*') + .eq('id_marca', marcaId) + .eq('ativo', true) + .order('ordem') + if (error) throw new Error(error.message) + return data ?? [] + }, +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/services/catalogoService.ts +git commit -m "feat: catalogoService com queries de marcas/unidades/precos/fotos/extras" +``` + +--- + +## Task C4: Hook `useReservationForm` (estado consolidado) + +**Files:** +- Create: `reserva-1001/src/hooks/useReservationForm.ts` + +- [ ] **Step 1: Criar** + +```ts +import { useEffect, useState, useCallback } from 'react' +import { catalogoService } from '@/services/catalogoService' +import type { Database } from '@/types/database' + +type Marca = Database['reserva_hotel']['Tables']['marcas']['Row'] +type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row'] +type Preco = Database['reserva_hotel']['Tables']['precos']['Row'] +type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row'] + +export interface ReservationFormState { + marcaId: string + unidadeId: string + permanencia: string + categoria: string + checkinAt: string // datetime-local format + nome: string + telefone: string + cpf: string + email: string + observacao: string +} + +const empty: ReservationFormState = { + marcaId: '', + unidadeId: '', + permanencia: '', + categoria: '', + checkinAt: '', + nome: '', + telefone: '', + cpf: '', + email: '', + observacao: '', +} + +export function useReservationForm() { + const [form, setForm] = useState(empty) + const [marcas, setMarcas] = useState([]) + const [unidades, setUnidades] = useState([]) + const [preco, setPreco] = useState(null) + const [fotos, setFotos] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Carregar marcas uma vez + useEffect(() => { + catalogoService + .listMarcas() + .then(setMarcas) + .catch((err: Error) => setError(err.message)) + }, []) + + // Quando muda marca, buscar unidades + useEffect(() => { + if (!form.marcaId) { + setUnidades([]) + return + } + catalogoService + .listUnidades(form.marcaId) + .then(setUnidades) + .catch((err: Error) => setError(err.message)) + }, [form.marcaId]) + + // Quando tem marca + categoria + permanência, buscar preço + useEffect(() => { + if (!form.marcaId || !form.categoria || !form.permanencia) { + setPreco(null) + return + } + catalogoService + .findPreco(form.marcaId, form.categoria, form.permanencia) + .then(setPreco) + .catch((err: Error) => setError(err.message)) + }, [form.marcaId, form.categoria, form.permanencia]) + + // Quando tem unidade + categoria, buscar fotos + useEffect(() => { + if (!form.unidadeId || !form.categoria) { + setFotos([]) + return + } + catalogoService + .listFotos(form.unidadeId, form.categoria) + .then(setFotos) + .catch((err: Error) => setError(err.message)) + }, [form.unidadeId, form.categoria]) + + const update = useCallback(( + key: K, + value: ReservationFormState[K] + ) => { + setForm((prev) => { + const next = { ...prev, [key]: value } + // Reset em cascata + if (key === 'marcaId') { + next.unidadeId = '' + next.categoria = '' + next.permanencia = '' + } + if (key === 'unidadeId') { + next.categoria = '' + } + return next + }) + }, []) + + const marcaSelecionada = marcas.find((m) => m.id === form.marcaId) ?? null + const unidadeSelecionada = unidades.find((u) => u.id === form.unidadeId) ?? null + + const precoTotalCents = preco ? Math.round(Number(preco.valor) * 100) : 0 + const depositCents = Math.round(precoTotalCents / 2) + + const canSubmit = + form.marcaId !== '' && + form.unidadeId !== '' && + form.categoria !== '' && + form.permanencia !== '' && + form.checkinAt !== '' && + form.nome.trim() !== '' && + form.telefone.replace(/\D/g, '').length >= 10 && + form.cpf.replace(/\D/g, '').length === 11 && + preco !== null + + return { + form, + update, + marcas, + unidades, + marcaSelecionada, + unidadeSelecionada, + preco, + precoTotalCents, + depositCents, + fotos, + loading, + setLoading, + error, + setError, + canSubmit, + reset: () => setForm(empty), + } +} +``` + +- [ ] **Step 2: Typecheck e commit** + +```bash +pnpm typecheck +git add src/hooks/useReservationForm.ts +git commit -m "feat: hook useReservationForm com estado em cascata" +``` + +--- + +## Task C5: Componente `StayDetailsStep` (form em cascata) + +**Files:** +- Create: `reserva-1001/src/components/reservation/StayDetailsStep.tsx` + +- [ ] **Step 1: Criar** + +```tsx +import { SelectField } from '@/components/SelectField' +import { FormField } from '@/components/FormField' +import type { ReservationFormState } from '@/hooks/useReservationForm' +import type { Database } from '@/types/database' + +type Marca = Database['reserva_hotel']['Tables']['marcas']['Row'] +type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row'] + +interface Props { + form: ReservationFormState + marcas: Marca[] + unidades: Unidade[] + onChange: (k: K, v: ReservationFormState[K]) => void +} + +export function StayDetailsStep({ form, marcas, unidades, onChange }: Props) { + const marca = marcas.find((m) => m.id === form.marcaId) ?? null + const unidade = unidades.find((u) => u.id === form.unidadeId) ?? null + + const categoriaOptions = + unidade?.categorias_visiveis?.map((c) => ({ value: c, label: c })) ?? + marca?.categorias?.map((c) => ({ value: c, label: c })) ?? + [] + + const permanenciaOptions = + marca?.permanencias?.map((p) => ({ value: p, label: p })) ?? [] + + return ( +
+

Detalhes da estadia

+ + onChange('marcaId', e.target.value)} + options={marcas.map((m) => ({ value: m.id, label: m.nome }))} + /> + + onChange('unidadeId', e.target.value)} + options={unidades.map((u) => ({ value: u.id, label: u.nome }))} + /> + +
+ onChange('permanencia', e.target.value)} + options={permanenciaOptions} + /> + + onChange('categoria', e.target.value)} + options={categoriaOptions} + /> +
+ + onChange('checkinAt', e.target.value)} + /> +
+ ) +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm typecheck +git add src/components/reservation/StayDetailsStep.tsx +git commit -m "feat: StayDetailsStep com selects em cascata" +``` + +--- + +## Task C6: `ImageGallery` e `PriceSummary` + +**Files:** +- Create: `reserva-1001/src/components/reservation/ImageGallery.tsx` +- Create: `reserva-1001/src/components/reservation/PriceSummary.tsx` + +- [ ] **Step 1: ImageGallery** + +Create `src/components/reservation/ImageGallery.tsx`: +```tsx +import type { Database } from '@/types/database' + +type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row'] + +interface Props { + fotos: Foto[] +} + +export function ImageGallery({ fotos }: Props) { + if (fotos.length === 0) return null + + return ( +
+ {fotos.map((foto) => ( +
+ {foto.alt +
+ ))} +
+ ) +} +``` + +- [ ] **Step 2: PriceSummary** + +Create `src/components/reservation/PriceSummary.tsx`: +```tsx +import { formatBRL } from '@/lib/formatters' + +interface Props { + totalCents: number + depositCents: number +} + +export function PriceSummary({ totalCents, depositCents }: Props) { + if (totalCents === 0) return null + const restante = totalCents - depositCents + + return ( +
+
+ Preço estimado + {formatBRL(totalCents)} +
+
+ Pagar no check-in + {formatBRL(restante)} +
+
+
+
Entrada via PIX (50%)
+
Necessário para confirmar
+
+
{formatBRL(depositCents)}
+
+
+ ) +} +``` + +- [ ] **Step 3: Typecheck + commit** + +```bash +pnpm typecheck +git add src/components/reservation/ImageGallery.tsx src/components/reservation/PriceSummary.tsx +git commit -m "feat: ImageGallery + PriceSummary" +``` + +--- + +## Task C7: `CustomerForm` + +**Files:** +- Create: `reserva-1001/src/components/reservation/CustomerForm.tsx` + +- [ ] **Step 1: Criar** + +```tsx +import { FormField } from '@/components/FormField' +import { maskCPF, maskPhone } from '@/lib/formatters' +import type { ReservationFormState } from '@/hooks/useReservationForm' + +interface Props { + form: ReservationFormState + onChange: (k: K, v: ReservationFormState[K]) => void +} + +export function CustomerForm({ form, onChange }: Props) { + return ( +
+

Seus dados

+ + onChange('nome', e.target.value)} + /> + +
+ onChange('telefone', maskPhone(e.target.value))} + /> + onChange('cpf', maskCPF(e.target.value))} + /> +
+ + onChange('email', e.target.value)} + /> + +
+ +