feat(units): allow one Pix unit to link to multiple inboxes (N:N)

This commit is contained in:
Rodrigo Borba 2026-02-26 21:33:23 -03:00
parent 8c456e7e98
commit c022f4ce5d
7 changed files with 109 additions and 73 deletions

View File

@ -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

View File

@ -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 {
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_LABEL') }}
<select v-model="inbox_id">
<option
<div class="inbox-multiselect">
<label
v-for="option in inboxOptions"
:key="option.id === null ? 'no-inbox' : option.id"
:value="option.id"
:key="option.id"
class="inbox-option"
>
<input v-model="inbox_ids" type="checkbox" :value="option.id" />
{{ option.name }}
</option>
</select>
</label>
</div>
<p class="help-text">
{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_HELP') }}
</p>
@ -401,36 +394,23 @@ export default {
</template>
<style scoped>
.content-box {
padding: var(--space-large);
}
.button-wrapper {
.inbox-multiselect {
display: flex;
justify-content: flex-end;
gap: var(--space-small);
margin-top: var(--space-normal);
}
.help-text {
font-size: var(--font-size-mini);
color: var(--s-500);
flex-direction: column;
gap: var(--space-smaller);
margin-top: var(--space-micro);
max-height: 180px;
overflow-y: auto;
border: 1px solid var(--s-200);
border-radius: var(--border-radius-normal);
padding: var(--space-small);
}
.text-success {
color: var(--color-success);
}
.file-upload-wrapper {
margin-bottom: var(--space-small);
margin-top: var(--space-micro);
}
.hidden-file-input {
display: none;
}
.checkbox-wrapper {
.inbox-option {
display: flex;
align-items: center;
gap: var(--space-smaller);
}
.checkbox-label {
font-weight: normal;
margin: 0;
cursor: pointer;
}
</style>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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