Files
chatwoot/app/controllers/platform/api/v1/agent_bots_controller.rb
T
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

47 lines
1.2 KiB
Ruby

class Platform::Api::V1::AgentBotsController < PlatformController
before_action :set_resource, except: [:index, :create]
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
end
def show; end
def create
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
@resource.save!
process_avatar_from_url
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def update
@resource.update!(agent_bot_params.except(:avatar_url))
process_avatar_from_url
end
def destroy
@resource.destroy!
head :ok
end
def avatar
@resource.avatar.purge if @resource.avatar.attached?
@resource
end
private
def set_resource
@resource = AgentBot.find(params[:id])
end
def agent_bot_params
params.permit(:name, :description, :account_id, :outgoing_url, :avatar, :avatar_url)
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@resource, params[:avatar_url]) if params[:avatar_url].present?
end
end