From f6a96f9d4e1673305deb578f127b1472e65e1826 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Tue, 5 May 2026 16:41:09 -0300 Subject: [PATCH] fix(inbox): remove captain dependencies before delete --- app/jobs/delete_object_job.rb | 91 +++++++++++++++++++++++++++++ spec/jobs/delete_object_job_spec.rb | 70 ++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/app/jobs/delete_object_job.rb b/app/jobs/delete_object_job.rb index 4e9030012..f0fc21751 100644 --- a/app/jobs/delete_object_job.rb +++ b/app/jobs/delete_object_job.rb @@ -2,6 +2,30 @@ class DeleteObjectJob < ApplicationJob queue_as :low BATCH_SIZE = 5_000 + INBOX_DEPENDENT_TABLES = %i[ + captain_feedback_logs + captain_lifecycle_deliveries + captain_reminders + captain_reservations + captain_gallery_items + captain_inbox_automations + captain_inbox_reminder_settings + captain_inboxes + captain_notification_templates + captain_pricing_inboxes + captain_tool_configs + captain_unit_inboxes + jasmine_inbox_collections + jasmine_inbox_settings + jasmine_tool_configs + ].freeze + INBOX_NULLIFY_TARGETS = [ + [:captain_conversation_insights, :inbox_id], + [:captain_pricings, :inbox_id], + [:jasmine_collections, :owner_inbox_id], + [:captain_units, :inbox_id], + [:captain_units, :concierge_inbox_id] + ].freeze def perform(object, user = nil, ip = nil) # Pre-purge heavy associations for large objects to avoid @@ -23,6 +47,8 @@ class DeleteObjectJob < ApplicationJob end def purge_heavy_associations(object) + purge_inbox_blocking_associations(object) if object.is_a?(Inbox) + klass = heavy_associations.keys.find { |k| object.is_a?(k) } return unless klass @@ -38,6 +64,71 @@ class DeleteObjectJob < ApplicationJob batch.each(&:destroy!) end end + + def purge_inbox_blocking_associations(inbox) + inbox_id = inbox.id + reservation_ids = select_ids(:captain_reservations, :inbox_id, inbox_id) + + purge_reservation_children(reservation_ids) + + # fazer.ai/Captain tables hold hard FKs to inboxes. If these survive, the + # async delete job fails and the UI shows the inbox again on refresh. + INBOX_DEPENDENT_TABLES.each { |table| delete_by_column(table, :inbox_id, inbox_id) } + nullify_inbox_references(inbox_id) + end + + def purge_reservation_children(reservation_ids) + delete_where_in(:captain_lifecycle_deliveries, :captain_reservation_id, reservation_ids) + delete_where_in(:captain_pix_charges, :reservation_id, reservation_ids) + end + + def nullify_inbox_references(inbox_id) + INBOX_NULLIFY_TARGETS.each do |table, column| + nullify_by_column(table, column, inbox_id) + end + end + + def select_ids(table, column, value) + return [] unless column_available?(table, column) + + sql = "SELECT id FROM #{quote_table(table)} WHERE #{quote_column(column)} = #{Integer(value)}" + db.select_values(sql).map(&:to_i) + end + + def delete_where_in(table, column, values) + return if values.blank? + return unless column_available?(table, column) + + db.execute("DELETE FROM #{quote_table(table)} WHERE #{quote_column(column)} IN (#{values.map { |v| Integer(v) }.join(',')})") + end + + def delete_by_column(table, column, value) + return unless column_available?(table, column) + + db.execute("DELETE FROM #{quote_table(table)} WHERE #{quote_column(column)} = #{Integer(value)}") + end + + def nullify_by_column(table, column, value) + return unless column_available?(table, column) + + db.execute("UPDATE #{quote_table(table)} SET #{quote_column(column)} = NULL WHERE #{quote_column(column)} = #{Integer(value)}") + end + + def column_available?(table, column) + db.data_source_exists?(table.to_s) && db.column_exists?(table.to_s, column.to_s) + end + + def quote_table(table) + db.quote_table_name(table) + end + + def quote_column(column) + db.quote_column_name(column) + end + + def db + ActiveRecord::Base.connection + end end DeleteObjectJob.prepend_mod_with('DeleteObjectJob') diff --git a/spec/jobs/delete_object_job_spec.rb b/spec/jobs/delete_object_job_spec.rb index 8267ba0f0..e291b46ca 100644 --- a/spec/jobs/delete_object_job_spec.rb +++ b/spec/jobs/delete_object_job_spec.rb @@ -32,6 +32,51 @@ RSpec.describe DeleteObjectJob, type: :job do expect(Contact.where(id: contact_ids).reload).not_to be_empty expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound) end + + it 'removes fazer.ai Captain and Jasmine inbox dependencies before destroying the inbox', :aggregate_failures do + contact = create(:contact, account: account) + contact_inbox = create(:contact_inbox, contact: contact, inbox: inbox) + unit = create(:captain_unit, account: account) + assistant = create(:captain_assistant, account: account) + collection_id = insert_row(:jasmine_collections, account_id: account.id, name: 'Samanbaia', owner_inbox_id: inbox.id) + reservation_id = insert_row( + :captain_reservations, + account_id: account.id, + inbox_id: inbox.id, + contact_id: contact.id, + contact_inbox_id: contact_inbox.id, + suite_identifier: 'Suite 101', + check_in_at: 1.day.from_now, + check_out_at: 2.days.from_now, + status: 0 + ) + + unit.update!(inbox_id: inbox.id, concierge_inbox_id: inbox.id) + create(:captain_inbox, captain_assistant: assistant, inbox: inbox, captain_unit: unit) + create(:captain_conversation_insight, account: account, inbox: inbox) + create(:captain_gallery_item, :inbox_scoped, account: account, inbox: inbox, captain_unit: unit) + insert_row(:captain_lifecycle_deliveries, account_id: account.id, captain_reservation_id: reservation_id, inbox_id: inbox.id, + fire_at: 1.hour.from_now, status: 'scheduled', origin: 'scheduled_lifecycle') + insert_row(:captain_pix_charges, reservation_id: reservation_id, unit_id: unit.id, txid: SecureRandom.hex(8), status: 'active') + insert_row(:captain_inbox_automations, account_id: account.id, inbox_id: inbox.id, title: 'Menu', message: 'Oi') + insert_row(:captain_inbox_reminder_settings, account_id: account.id, inbox_id: inbox.id) + insert_row(:captain_notification_templates, inbox_id: inbox.id, label: 'Template', content: 'Oi') + insert_row(:captain_tool_configs, account_id: account.id, inbox_id: inbox.id, tool_key: 'availability') + insert_row(:jasmine_inbox_collections, account_id: account.id, inbox_id: inbox.id, collection_id: collection_id) + insert_row(:jasmine_inbox_settings, account_id: account.id, inbox_id: inbox.id) + insert_row(:jasmine_tool_configs, account_id: account.id, inbox_id: inbox.id, tool_key: 'availability') + + expect { described_class.perform_now(inbox) }.not_to raise_error + + expect(Inbox.exists?(inbox.id)).to be false + expect(CaptainInbox.where(inbox_id: inbox.id)).to be_empty + expect(Captain::Reservation.where(id: reservation_id)).to be_empty + expect(Captain::GalleryItem.where(inbox_id: inbox.id)).to be_empty + expect(Captain::ConversationInsight.where(inbox_id: inbox.id)).to be_empty + expect(unit.reload.inbox_id).to be_nil + expect(unit.concierge_inbox_id).to be_nil + expect(select_value(:jasmine_collections, collection_id, :owner_inbox_id)).to be_nil + end end context 'when object is heavy (Account)' do @@ -73,4 +118,29 @@ RSpec.describe DeleteObjectJob, type: :job do end end end + + def insert_row(table, attributes) + attributes = attributes.merge(created_at: Time.current, updated_at: Time.current) + columns = attributes.keys + values = attributes.values.map { |value| quoted_value(value) } + sql = "INSERT INTO #{connection.quote_table_name(table)} (#{columns.map { |column| connection.quote_column_name(column) }.join(', ')}) " \ + "VALUES (#{values.join(', ')}) RETURNING id" + + connection.select_value(sql).to_i + end + + def select_value(table, id, column) + connection.select_value( + "SELECT #{connection.quote_column_name(column)} FROM #{connection.quote_table_name(table)} WHERE id = #{Integer(id)}" + ) + end + + def quoted_value(value) + value = value.to_json if value.is_a?(Hash) || value.is_a?(Array) + connection.quote(value) + end + + def connection + ActiveRecord::Base.connection + end end