chatwoot/app/models/agent_bot.rb
ramalau 035d2858f5
fix(agent-bots): destroy permissibles on AgentBot deletion and skip orphans in index (#14273)
\`GET /platform/api/v1/agent_bots\` returns 500 when any \`AgentBot\`
that was previously registered with a Platform App has since been
deleted. The bug was introduced by a missing \`dependent: :destroy\` on
the \`AgentBot\` model — deleting a bot left orphaned rows in
\`platform_app_permissibles\`, which the index action later iterated
over and crashed rendering with a \`NoMethodError\` on \`nil\`.

Closes #13407

## Root cause

The index action loads all \`platform_app_permissibles\` for the
platform app and passes each \`resource.permissible\` (the associated
\`AgentBot\`) to a Jbuilder partial. When the \`AgentBot\` no longer
exists, \`resource.permissible\` returns \`nil\` and the partial crashes
calling \`.id\`, \`.name\`, etc. on it.

Every other \`AgentBot\` association (\`agent_bot_inboxes\`,
\`messages\`, \`assigned_conversations\`) had a \`dependent:\` option —
\`platform_app_permissibles\` was the only one missing it. There was
also an N+1 query: the index fired a separate SQL query per permissible
to load each bot.

## What changed

**1. Model — prevent orphans at deletion time**
\`\`\`ruby
has_many :platform_app_permissibles, as: :permissible, dependent:
:destroy
\`\`\`

**2. Controller — eager-load to eliminate N+1**
\`\`\`ruby
@resources = @platform_app.platform_app_permissibles
               .where(permissible_type: 'AgentBot')
               .includes(:permissible)
\`\`\`

**3. Jbuilder — defensive nil guard for pre-existing orphans**
\`\`\`ruby
bot = resource.permissible
next if bot.nil?
json.partial! '...', resource: bot
\`\`\`

## Trade-offs considered

| Option | Decision |
|---|---|
| Rescue \`NoMethodError\` in jbuilder | Hides the failure rather than
fixing it. Rejected. |
| Only add the nil guard, skip the model fix | Leaves the data integrity
gap open — future deletions continue creating orphans. Rejected. |
| Both layers (chosen) | Model fix prevents new orphans; nil guard is
defence-in-depth for any orphans that survived before deployment. |
| \`dependent: :nullify\` | Doesn't apply — a nullified permissible
would still cause the same nil dereference. Rejected. |

## How to reproduce

1. Create an AgentBot via the Platform API
2. Delete the AgentBot via any path (admin UI, API, or direct model
call)
3. Call \`GET /platform/api/v1/agent_bots\` with a Platform App token
4. Observe 500

After this fix, the endpoint returns 200 with an empty array.

Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
2026-04-27 19:17:32 +05:30

72 lines
1.7 KiB
Ruby

# == Schema Information
#
# Table name: agent_bots
#
# id :bigint not null, primary key
# bot_config :jsonb
# bot_type :integer default("webhook")
# description :string
# name :string
# outgoing_url :string
# secret :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
#
# Indexes
#
# index_agent_bots_on_account_id (account_id)
#
class AgentBot < ApplicationRecord
include AccessTokenable
include Avatarable
include WebhookSecretable
scope :accessible_to, lambda { |account|
account_id = account&.id
where(account_id: [nil, account_id])
}
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :platform_app_permissibles, as: :permissible, dependent: :destroy
has_many :assigned_conversations, class_name: 'Conversation',
foreign_key: :assignee_agent_bot_id,
dependent: :nullify,
inverse_of: :assignee_agent_bot
belongs_to :account, optional: true
enum bot_type: { webhook: 0 }
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
def available_name
name
end
def push_event_data(inbox = nil)
{
id: id,
name: name,
avatar_url: avatar_url || inbox&.avatar_url,
type: 'agent_bot'
}
end
def webhook_data
{
id: id,
name: name,
type: 'agent_bot'
}
end
def system_bot?
account.nil?
end
end
AgentBot.include_mod_with('Audit::AgentBot')