fix(captain): provision unit via RPC SECURITY DEFINER (RLS bypass)
Anon key não tinha permissão de INSERT em reserva_hotel.unidades — RLS exige authenticated + tenant_member, não atendido. POST direto falhava sem feedback útil. Solução: RPC reserva_hotel.provision_unidade(...) com SECURITY DEFINER que faz upsert idempotente bypassando RLS, com validações de tenant + marca dentro da função. EXECUTE granted to anon. Service agora chama /rpc/provision_unidade em vez de POST /unidades. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5cd15665e
commit
4e798944cf
@ -13,9 +13,6 @@
|
||||
class Captain::Reserva::ProvisionUnitInSupabaseService
|
||||
DEFAULT_SCHEMA = 'reserva_hotel'
|
||||
DEFAULT_TENANT_ID = 1
|
||||
# Conta padrão usada quando a unidade ainda não tem id_conta_pagamento próprio.
|
||||
# Cada marca pode editar isso direto no Supabase posteriormente.
|
||||
DEFAULT_CONTA_PAGAMENTO_ID = 'e227a8b3-480a-46a8-987f-2cca6395760b'
|
||||
|
||||
def initialize(unit:)
|
||||
@unit = unit
|
||||
@ -28,12 +25,12 @@ class Captain::Reserva::ProvisionUnitInSupabaseService
|
||||
marca_id = resolve_marca_id
|
||||
return error("marca '#{unit.brand.name}' não encontrada em reserva_hotel.marcas") if marca_id.blank?
|
||||
|
||||
row = upsert_unidade(build_payload(marca_id))
|
||||
row = upsert_via_rpc(marca_id)
|
||||
return error('falha ao gravar unidade no Supabase') if row.blank?
|
||||
|
||||
persist_supabase_ids(row)
|
||||
Rails.logger.info("[Captain::Reserva::ProvisionUnit] unit=#{unit.id} (#{unit.name}) -> supabase_unit=#{row['id']}")
|
||||
{ success: true, supabase_unit_id: row['id'] }
|
||||
Rails.logger.info("[Captain::Reserva::ProvisionUnit] unit=#{unit.id} (#{unit.name}) -> supabase_unit=#{row['out_id']}")
|
||||
{ success: true, supabase_unit_id: row['out_id'] }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Captain::Reserva::ProvisionUnit] unit=#{unit&.id} #{e.class}: #{e.message}")
|
||||
error(e.message)
|
||||
@ -68,45 +65,46 @@ class Captain::Reserva::ProvisionUnitInSupabaseService
|
||||
# disparar after_commit em loop (chamado pelo próprio after_commit).
|
||||
def persist_supabase_ids(row)
|
||||
unit.update_columns( # rubocop:disable Rails/SkipsModelValidations
|
||||
supabase_unit_id: row['id'],
|
||||
supabase_tenant_id: row['tenant_id'],
|
||||
supabase_marca_id: row['id_marca']
|
||||
supabase_unit_id: row['out_id'],
|
||||
supabase_tenant_id: row['out_tenant_id'],
|
||||
supabase_marca_id: row['out_id_marca']
|
||||
)
|
||||
end
|
||||
|
||||
def build_payload(marca_id)
|
||||
{
|
||||
nome: unit.name,
|
||||
id_marca: marca_id,
|
||||
id_conta_pagamento: unit.supabase_unit_id.present? ? nil : DEFAULT_CONTA_PAGAMENTO_ID,
|
||||
tenant_id: tenant_id,
|
||||
chatwoot_unit_id: unit.id,
|
||||
categorias_visiveis: Array(unit.visible_suite_categories),
|
||||
ativa: true
|
||||
}.compact
|
||||
end
|
||||
|
||||
def upsert_unidade(payload)
|
||||
url = "#{supabase_url}/rest/v1/unidades?on_conflict=tenant_id,chatwoot_unit_id"
|
||||
response = http.post(url) do |req|
|
||||
apply_upsert_headers(req)
|
||||
req.body = payload.to_json
|
||||
# Chama a RPC reserva_hotel.provision_unidade (SECURITY DEFINER) que faz
|
||||
# upsert idempotente em unidades bypassando RLS. Anon key tem GRANT EXECUTE.
|
||||
def upsert_via_rpc(marca_id)
|
||||
response = http.post("#{supabase_url}/rest/v1/rpc/provision_unidade") do |req|
|
||||
apply_rpc_headers(req)
|
||||
req.body = build_rpc_body(marca_id).to_json
|
||||
end
|
||||
unless response.success?
|
||||
Rails.logger.warn("[ProvisionUnit] RPC status=#{response.status} body=#{response.body}")
|
||||
return nil
|
||||
end
|
||||
return nil unless response.success?
|
||||
|
||||
Array(JSON.parse(response.body)).first
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def apply_upsert_headers(req)
|
||||
def build_rpc_body(marca_id)
|
||||
{
|
||||
p_nome: unit.name,
|
||||
p_id_marca: marca_id,
|
||||
p_tenant_id: tenant_id,
|
||||
p_chatwoot_unit_id: unit.id,
|
||||
p_categorias_visiveis: Array(unit.visible_suite_categories)
|
||||
}
|
||||
end
|
||||
|
||||
def apply_rpc_headers(req)
|
||||
req.headers['apikey'] = supabase_key
|
||||
req.headers['Authorization'] = "Bearer #{supabase_key}"
|
||||
req.headers['Content-Profile'] = supabase_schema
|
||||
req.headers['Content-Type'] = 'application/json'
|
||||
req.headers['Accept'] = 'application/json'
|
||||
req.headers['Accept-Encoding'] = 'identity'
|
||||
req.headers['Prefer'] = 'resolution=merge-duplicates,return=representation'
|
||||
end
|
||||
|
||||
def supabase_get(table, query)
|
||||
|
||||
@ -30,11 +30,10 @@ RSpec.describe Captain::Reserva::ProvisionUnitInSupabaseService do
|
||||
.with(query: hash_including('nome' => "eq.#{brand.name}", 'tenant_id' => 'eq.1'))
|
||||
.to_return(status: 200, body: [{ id: marca_uuid }].to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
stub_request(:post, "#{supabase_url}/rest/v1/unidades")
|
||||
.with(query: { 'on_conflict' => 'tenant_id,chatwoot_unit_id' })
|
||||
stub_request(:post, "#{supabase_url}/rest/v1/rpc/provision_unidade")
|
||||
.to_return(
|
||||
status: 201,
|
||||
body: [{ id: unidade_uuid, tenant_id: 1, id_marca: marca_uuid }].to_json,
|
||||
status: 200,
|
||||
body: [{ out_id: unidade_uuid, out_tenant_id: 1, out_id_marca: marca_uuid }].to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
@ -51,18 +50,16 @@ RSpec.describe Captain::Reserva::ProvisionUnitInSupabaseService do
|
||||
expect(unit.supabase_marca_id).to eq(marca_uuid)
|
||||
end
|
||||
|
||||
it 'envia categorias_visiveis e chatwoot_unit_id no payload' do
|
||||
it 'envia parâmetros corretos pra RPC' do
|
||||
described_class.new(unit: unit).perform
|
||||
|
||||
expect(WebMock).to have_requested(:post, "#{supabase_url}/rest/v1/unidades").with(
|
||||
query: { 'on_conflict' => 'tenant_id,chatwoot_unit_id' },
|
||||
expect(WebMock).to have_requested(:post, "#{supabase_url}/rest/v1/rpc/provision_unidade").with(
|
||||
body: hash_including(
|
||||
'nome' => unit.name,
|
||||
'id_marca' => marca_uuid,
|
||||
'tenant_id' => 1,
|
||||
'chatwoot_unit_id' => unit.id,
|
||||
'categorias_visiveis' => %w[Alexa Stilo Hidromassagem],
|
||||
'ativa' => true
|
||||
'p_nome' => unit.name,
|
||||
'p_id_marca' => marca_uuid,
|
||||
'p_tenant_id' => 1,
|
||||
'p_chatwoot_unit_id' => unit.id,
|
||||
'p_categorias_visiveis' => %w[Alexa Stilo Hidromassagem]
|
||||
)
|
||||
)
|
||||
end
|
||||
@ -86,10 +83,10 @@ RSpec.describe Captain::Reserva::ProvisionUnitInSupabaseService do
|
||||
expect(result[:error]).to match(/marca .* não encontrada/)
|
||||
end
|
||||
|
||||
it 'retorna erro e não grava IDs quando upsert falha' do
|
||||
it 'retorna erro e não grava IDs quando RPC falha' do
|
||||
stub_request(:get, "#{supabase_url}/rest/v1/marcas")
|
||||
.to_return(status: 200, body: [{ id: marca_uuid }].to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
stub_request(:post, "#{supabase_url}/rest/v1/unidades")
|
||||
stub_request(:post, "#{supabase_url}/rest/v1/rpc/provision_unidade")
|
||||
.to_return(status: 500, body: '{"message":"err"}')
|
||||
|
||||
result = described_class.new(unit: unit).perform
|
||||
|
||||
Loading…
Reference in New Issue
Block a user