From c022f4ce5dcf2e4a132a22db8bdff5624c362cb9 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Thu, 26 Feb 2026 21:33:23 -0300 Subject: [PATCH] feat(units): allow one Pix unit to link to multiple inboxes (N:N) --- .../v1/accounts/captain/units_controller.rb | 59 +++++++++-------- .../dashboard/settings/captain/units/Edit.vue | 64 +++++++------------ ...60226222000_create_captain_unit_inboxes.rb | 43 +++++++++++++ .../captain/gallery_items_controller.rb | 2 +- enterprise/app/models/captain/gallery_item.rb | 2 +- enterprise/app/models/captain/unit.rb | 4 +- enterprise/app/models/captain/unit_inbox.rb | 8 +++ 7 files changed, 109 insertions(+), 73 deletions(-) create mode 100644 db/migrate/20260226222000_create_captain_unit_inboxes.rb create mode 100644 enterprise/app/models/captain/unit_inbox.rb diff --git a/app/controllers/api/v1/accounts/captain/units_controller.rb b/app/controllers/api/v1/accounts/captain/units_controller.rb index 878c5bb77..f69c7eeb3 100644 --- a/app/controllers/api/v1/accounts/captain/units_controller.rb +++ b/app/controllers/api/v1/accounts/captain/units_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr before_action :set_unit, only: [:show, :update, :destroy] def index - @units = Current.account.captain_units + @units = Current.account.captain_units.includes(:inboxes) render json: @units.map { |u| format_unit(u) } end @@ -16,7 +16,7 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr @unit.captain_brand_id ||= default_brand.id ActiveRecord::Base.transaction do @unit.save! - sync_inbox_link!(@unit) + sync_inboxes!(@unit, inbox_ids_param) end render json: format_unit(@unit), status: :created rescue ActiveRecord::RecordInvalid @@ -26,7 +26,7 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr def update ActiveRecord::Base.transaction do @unit.update!(unit_params) - sync_inbox_link!(@unit) + sync_inboxes!(@unit, inbox_ids_param) if params[:captain_unit].key?(:inbox_ids) end render json: format_unit(@unit) rescue ActiveRecord::RecordInvalid @@ -53,7 +53,7 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr end def set_unit - @unit = Current.account.captain_units.find(params[:id]) + @unit = Current.account.captain_units.includes(:inboxes).find(params[:id]) end def unit_params @@ -63,47 +63,50 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr :inter_client_secret, :inter_pix_key, :inter_account_number, - :inbox_id, :inter_cert_content, :inter_key_content, :proactive_pix_polling_enabled ) end + def inbox_ids_param + return [] unless params[:captain_unit].key?(:inbox_ids) + + Array(params[:captain_unit][:inbox_ids]).map(&:to_i).select(&:positive?) + end + + # Sincroniza a lista de inboxes de uma unit: adiciona novas, remove ausentes. + # Garante que apenas inboxes da mesma conta sejam aceitas. + def sync_inboxes!(unit, ids) + valid_ids = Current.account.inboxes.where(id: ids).pluck(:id) + + # Remove vínculos não presentes na nova lista + unit.unit_inboxes.where.not(inbox_id: valid_ids).destroy_all + + # Adiciona novos vínculos (ignora duplicatas via uniqueness) + existing_ids = unit.unit_inboxes.pluck(:inbox_id) + (valid_ids - existing_ids).each do |inbox_id| + unit.unit_inboxes.create!(inbox_id: inbox_id) + end + end + def format_unit(unit) + inboxes = unit.inboxes.to_a { id: unit.id, name: unit.name, inter_client_id: unit.inter_client_id, inter_pix_key: unit.inter_pix_key, inter_account_number: unit.inter_account_number, - inbox_id: unit.inbox_id, - inbox_name: unit.inbox_id.present? ? Inbox.find_by(id: unit.inbox_id)&.name : nil, + inbox_ids: inboxes.map(&:id), + inbox_names: inboxes.map(&:name), + # Mantém inbox_id e inbox_name como atalho para compatibilidade com código legado + inbox_id: inboxes.first&.id, + inbox_name: inboxes.first&.name, has_cert: unit.inter_cert_content.present? || unit.resolved_inter_cert_path.present?, has_key: unit.inter_key_content.present? || unit.resolved_inter_key_path.present?, has_client_secret: unit.inter_client_secret.present?, proactive_pix_polling_enabled: unit.proactive_pix_polling_enabled - # Obviamente não enviando secrets ou contents aqui! } end - - def sync_inbox_link!(unit) - if unit.inbox_id.present? - Current.account.captain_units - .where(inbox_id: unit.inbox_id) - .where.not(id: unit.id) - .find_each { |existing_unit| existing_unit.update!(inbox_id: nil) } - end - - return unless defined?(CaptainInbox) - - CaptainInbox.where(captain_unit_id: unit.id).find_each do |existing_link| - existing_link.update!(captain_unit_id: nil) - end - - return if unit.inbox_id.blank? - - inbox_link = CaptainInbox.find_by(inbox_id: unit.inbox_id) - inbox_link&.update!(captain_unit_id: unit.id) - end end diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/units/Edit.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/units/Edit.vue index 03db438cc..04f2ac73a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/units/Edit.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/units/Edit.vue @@ -22,7 +22,7 @@ export default { inter_cert_content: '', inter_key_content: '', proactive_pix_polling_enabled: false, - inbox_id: null, + inbox_ids: [], hasInitialCert: false, hasInitialKey: false, hasInitialClientSecret: false, @@ -35,13 +35,7 @@ export default { inboxes: 'inboxes/getInboxes', }), inboxOptions() { - return [ - { - id: null, - name: this.$t('CAPTAIN_SETTINGS.UNITS.INBOX.NO_UNIT'), - }, - ...this.inboxes.map(inbox => ({ id: inbox.id, name: inbox.name })), - ]; + return this.inboxes.map(inbox => ({ id: inbox.id, name: inbox.name })); }, isNew() { return this.$route.params.id === 'new'; @@ -91,11 +85,9 @@ export default { this.inter_account_number = unit.inter_account_number; this.inter_pix_key = unit.inter_pix_key; this.inter_client_id = unit.inter_client_id; - this.inbox_id = unit.inbox_id || null; + this.inbox_ids = unit.inbox_ids || []; this.proactive_pix_polling_enabled = !!unit.proactive_pix_polling_enabled; - // Secret and cert contents are generally not returned by the API for security reasons, - // so we leave them blank to only update if the user types something new. this.hasInitialCert = unit.has_cert; this.hasInitialKey = unit.has_key; this.hasInitialClientSecret = unit.has_client_secret; @@ -111,7 +103,7 @@ export default { inter_pix_key: this.inter_pix_key, inter_client_id: this.inter_client_id, inter_client_secret: this.inter_client_secret, - inbox_id: this.inbox_id, + inbox_ids: this.inbox_ids, inter_cert_content: this.inter_cert_content, inter_key_content: this.inter_key_content, proactive_pix_polling_enabled: this.isInterCredentialsReady @@ -223,15 +215,16 @@ export default {
+

{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_HELP') }}

@@ -401,36 +394,23 @@ export default { diff --git a/db/migrate/20260226222000_create_captain_unit_inboxes.rb b/db/migrate/20260226222000_create_captain_unit_inboxes.rb new file mode 100644 index 000000000..8828c4b10 --- /dev/null +++ b/db/migrate/20260226222000_create_captain_unit_inboxes.rb @@ -0,0 +1,43 @@ +class CreateCaptainUnitInboxes < ActiveRecord::Migration[7.0] + def up + create_table :captain_unit_inboxes do |t| + t.bigint :captain_unit_id, null: false + t.bigint :inbox_id, null: false + t.timestamps + end + + add_index :captain_unit_inboxes, [:captain_unit_id, :inbox_id], unique: true, name: 'index_captain_unit_inboxes_on_unit_and_inbox' + add_index :captain_unit_inboxes, :inbox_id, name: 'index_captain_unit_inboxes_on_inbox_id' + + add_foreign_key :captain_unit_inboxes, :captain_units, column: :captain_unit_id, on_delete: :cascade + add_foreign_key :captain_unit_inboxes, :inboxes, column: :inbox_id, on_delete: :cascade + + # Migra dados existentes: cada unit que já tinha inbox_id ganha um registro na pivot + execute <<~SQL.squish + INSERT INTO captain_unit_inboxes (captain_unit_id, inbox_id, created_at, updated_at) + SELECT id, inbox_id, NOW(), NOW() + FROM captain_units + WHERE inbox_id IS NOT NULL + ON CONFLICT DO NOTHING + SQL + + # Zera a coluna antiga (mas não a remove — remoção fica para migration futura) + execute 'UPDATE captain_units SET inbox_id = NULL WHERE inbox_id IS NOT NULL' + end + + def down + # Restaura o inbox_id da primeira linha da pivot (rollback best-effort) + execute <<~SQL.squish + UPDATE captain_units cu + SET inbox_id = ( + SELECT cui.inbox_id + FROM captain_unit_inboxes cui + WHERE cui.captain_unit_id = cu.id + ORDER BY cui.created_at + LIMIT 1 + ) + SQL + + drop_table :captain_unit_inboxes + end +end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/gallery_items_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/gallery_items_controller.rb index ac8ac0ef3..ac00e916a 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/gallery_items_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/gallery_items_controller.rb @@ -116,7 +116,7 @@ class Api::V1::Accounts::Captain::GalleryItemsController < Api::V1::Accounts::Ba def normalize_gallery_scope!(attrs) if attrs[:inbox_id].blank? && attrs[:captain_unit_id].present? unit = Current.account.captain_units.find_by(id: attrs[:captain_unit_id]) - attrs[:inbox_id] = unit&.inbox_id + attrs[:inbox_id] = unit&.inboxes&.first&.id end scope = attrs[:scope].presence diff --git a/enterprise/app/models/captain/gallery_item.rb b/enterprise/app/models/captain/gallery_item.rb index ce7b7e536..5d33e47fd 100644 --- a/enterprise/app/models/captain/gallery_item.rb +++ b/enterprise/app/models/captain/gallery_item.rb @@ -68,7 +68,7 @@ class Captain::GalleryItem < ApplicationRecord def infer_inbox_from_unit return if inbox_id.present? || captain_unit.blank? - self.inbox_id = captain_unit.inbox_id + self.inbox_id = captain_unit.inboxes.first&.id end def normalize_scope diff --git a/enterprise/app/models/captain/unit.rb b/enterprise/app/models/captain/unit.rb index c10b887fd..c1494eafe 100644 --- a/enterprise/app/models/captain/unit.rb +++ b/enterprise/app/models/captain/unit.rb @@ -48,7 +48,9 @@ class Captain::Unit < ApplicationRecord belongs_to :account belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', inverse_of: :units - belongs_to :inbox, optional: true + has_many :unit_inboxes, class_name: 'Captain::UnitInbox', foreign_key: :captain_unit_id, + inverse_of: :captain_unit, dependent: :destroy + has_many :inboxes, through: :unit_inboxes has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit, dependent: :destroy diff --git a/enterprise/app/models/captain/unit_inbox.rb b/enterprise/app/models/captain/unit_inbox.rb new file mode 100644 index 000000000..fc030477d --- /dev/null +++ b/enterprise/app/models/captain/unit_inbox.rb @@ -0,0 +1,8 @@ +class Captain::UnitInbox < ApplicationRecord + self.table_name = 'captain_unit_inboxes' + + belongs_to :captain_unit, class_name: 'Captain::Unit' + belongs_to :inbox + + validates :captain_unit_id, uniqueness: { scope: :inbox_id } +end