From 4e798944cf8a35323cb423d107574429b1d71125 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 09:11:57 -0300 Subject: [PATCH] fix(captain): provision unit via RPC SECURITY DEFINER (RLS bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../provision_unit_in_supabase_service.rb | 56 +++++++++---------- ...provision_unit_in_supabase_service_spec.rb | 27 ++++----- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb b/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb index 5fee29703..3662c6595 100644 --- a/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb +++ b/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb @@ -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) diff --git a/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb b/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb index 20860297a..8173750ad 100644 --- a/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb +++ b/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb @@ -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