feat(units): allow one Pix unit to link to multiple inboxes (N:N)
This commit is contained in:
parent
8c456e7e98
commit
c022f4ce5d
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
43
db/migrate/20260226222000_create_captain_unit_inboxes.rb
Normal file
43
db/migrate/20260226222000_create_captain_unit_inboxes.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
8
enterprise/app/models/captain/unit_inbox.rb
Normal file
8
enterprise/app/models/captain/unit_inbox.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user