Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
When an agent mentions themselves in a private note, they no longer
receive a redundant notification for their own mention.
Closes: #4096
# Pull Request Template
## Description
Agents who mention themselves in a private note no longer receive a
conversation_mention notification. Previously, the mention service would
generate a notification for every mentioned user without checking
whether the sender and the
mentioned user were the same person.
This PR fixes an issue where Slack emojis are rendered as text
shortcodes (e.g. 🚀) instead of the actual emoji characters in
Chatwoot messages.
It introduces a new EmojiFormatter class that uses the emoji-data
mapping to convert shortcodes to unicode characters.
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
The `ChatwootApp.chatwoot_cloud?` gate on the platform banners route in
#13943 reads `InstallationConfig` from the database. Because `routes.rb`
is evaluated during `Rails.application.initialize!`, this ran before the
database existed on a fresh setup, breaking `bundle exec rake db:create`
in CI and first-time installs with `ActiveRecord::NoDatabaseError: We
could not find your database: chatwoot_test`.
The route is now always mounted, and the cloud check moved to where the
database is guaranteed to be available — the controller
(`before_action`) and the super admin sidebar partial.
Closes the CI failure introduced by #13943.
## How to test
1. Drop your local databases: `bundle exec rake db:drop`
2. Run `bundle exec rake db:create` — it should succeed (previously
failed with `NoDatabaseError`)
3. Bring the DB back: `bundle exec rake db:setup`
4. On a non-cloud install, visit `/super_admin/platform_banners` —
should 404, and the sidebar entry should be hidden
5. With `DEPLOYMENT_ENV=cloud` configured (cloud install), the page and
sidebar entry should work as before
## What changed
- `config/routes.rb` — always mount `resources :platform_banners` (no DB
call at boot)
- `app/controllers/super_admin/platform_banners_controller.rb` —
`before_action` raises `ActionController::RoutingError` (404) when not
on Chatwoot Cloud
- `app/views/super_admin/application/_navigation.html.erb` — hides the
sidebar entry on non-cloud installs
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Adds a platform-wide status banner system to notify all users about
external service outages. Super Admins can create, edit, and manage
banners via the Super Admin console. Banners support markdown for links
and are dismissible by users.
<img width="1099" height="236" alt="image"
src="https://github.com/user-attachments/assets/047a7994-d885-4a8a-b9c4-aeb32f15474a"
/>
## How to test
1. Set `ENABLE_PLATFORM_BANNERS=true` in your environment
2. Go to Super Admin → Platform Banners
3. Create a banner with a message like: `Elevated error rates from Meta
APIs. [Check status](https://metastatus.com)`
4. Select a banner type: `info` (blue), `warning` (amber), or `error`
(red)
5. Visit the dashboard — the banner should appear at the top
6. Click "Dismiss" — the banner hides and stays dismissed across page
reloads
7. Deactivate the banner in Super Admin — it disappears on next page
load
## What changed
- New `PlatformBanner` model with `banner_message`, `banner_type`
(info/warning/error), and `active` flag
- Super Admin CRUD via Administrate (controller, dashboard, routes,
sidebar icon)
- `DashboardController` serves active banners via `globalConfig`
- `StatusBanner.vue` component renders banners with markdown support and
per-banner localStorage dismiss
- Feature gated behind `ENABLE_PLATFORM_BANNERS` env var
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
# Pull Request Template
## Description
- Wires up Controllers to auto-sync job
- adds plan based sync schedule
- a scheduler that runs every hour to check syncable documents
- guards the whole feature behind feature flag by reclaiming
`twilio_content_templates`
- Adds a global and account level cap on how many documents to enqueue
to prevent sudden burst at first run
- some refactor to simplify code
- specs
Fixes # (issue)
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
specs and locally
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
Voice calling is now a capability on the existing TwilioSms rather than
a separate Voice model. A single Twilio phone number handles both SMS
and voice calls through one inbox.
Fixes
https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel
and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms
**What changed**
- Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms
- Added voice_enabled, twiml_app_sid, api_key_secret columns to
channel_twilio_sms table
- Dropped channel_voice table (no production data)
- All voice logic lives in Enterprise layer via
prepend_mod_with('Channel::TwilioSms')
- Added Voice settings tab on Twilio SMS inbox settings to
enable/disable voice
- Validates Twilio number voice capability before provisioning
- Teardown service cleans up TwiML app and credentials when voice is
disabled
- Frontend voice detection uses isVoiceCallEnabled() /
getVoiceCallProvider() helpers — extensible to future providers
- Gated by channel_voice feature flag
**How to test**
1. Enable feature flag:
Account.find(<id>).enable_features('channel_voice')
2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials →
verify incoming/outgoing calls and SMS work
3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox
→ Voice tab → toggle on → provide API key credentials → verify calls
work
4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted,
credentials cleared, SMS still works
5. Re-enable voice: Toggle on again → must provide api_key_secret again
→ new TwiML app provisioned
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
## Description
Adds Playwright E2E testing infrastructure with project configuration
and a login flow test. This is
Phase 1 of the Playwright E2E suite, kept minimal with only the core
setup and login component.
Ref: Discussion #13500, PR #13067
## Type of change
Please delete options that are not relevant.
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Verified all imports resolve correctly
(`login-flow-ui-validation.spec.ts` only imports `Login` from
`@components/ui`)
- Ran login flow test locally against a running Chatwoot instance
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
## Description
Fix a Postgres planner trap on the "Pending Response: Longest first"
sort that causes the conversation list to hang on busy accounts.
The current `sort_on_waiting_since` generates query with `ORDER BY
waiting_since ASC NULLS LAST, created_at ASC`. That order-by is exactly
the shape of the single-column `index_conversations_on_waiting_since`
btree, so the planner picks a forward index walk thinking `LIMIT 25`
will stop early. In practice the per-account matches are spread along
the global waiting_since timeline, so the scan reads tens of millions of
rows from other accounts and discards them via the filter before
producing any results which in turn causes the requests to time out and
the conversation list spinner never resolves.
DESC direction and every other sort (`priority`, `created_at`,
`last_activity_at`) are unaffected. They fall through to
`conv_acid_inbid_stat_asgnid_idx` (account-scoped composite), which is
the right index for this access pattern.
This change leads the ORDER BY with the expression `(waiting_since IS
NULL)`, which no column-only btree can satisfy. The planner falls back
to the same account-scoped index used by every other sort, and sorts in
memory. Same logical NULLS LAST output for both directions; no behavior
change for users.
Fixes CW-6965
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
- [x] Existing specs pass
- [x] Added new specs to cover NULL case
- [x] Verify results and order for old and new query in prod
- [x] Tested in prod since staging data was not sufficient
`EXPLAIN (ANALYZE, BUFFERS)` for the same query (status=0, ASC, LIMIT
25) on a representative production account, before vs after:
| Metric | Before | After |
| --- | --- | --- |
| Execution time | 34,679 ms | 0.71 ms |
| Rows discarded by filter | 36,906,962 | 0 |
| Shared buffer hits | 12,699,924 | 11 |
| Blocks read from disk | 851,226 | 112 |
| I/O read time | 17,785 ms | 0.3 ms |
| Pages dirtied / written | 98 / 31,043 | 0 / 0 |
Verified on two production accounts: identical row IDs in identical
order between the old and new ORDER BY for both ASC and DESC. A
NULL-bucket regression spec was added covering ASC/DESC tail ordering
when some conversations have a null `waiting_since`.
Roughly `49,000×` faster on this query (34,679 ms → 0.71 ms), and
trivially less I/O and buffer pressure on the cluster while it runs.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
# Pull Request Template
## Description
This PR fixes an issue where outgoing Email messages (via API) do not
preserve single line breaks in rendered HTML.
#### Cause
Messages are stored with `\n`, but rendering differs:
* **Other channel** (`markdown-it`, `breaks: true`) → `\n` → `<br>`
* **Email** (CommonMark) without `HARDBREAKS` → `\n` collapsed into
spaces
Result: multi-line messages appear as a single paragraph in Email.
#### Solution
* Added `hardbreaks:` option to `render_message` (default: false)
* Enabled `hardbreaks: true` in `EmailHelper#render_email_html`
This ensures `\n` renders as `<br />` in Email, matching web widget
behavior.
Fixes
https://linear.app/chatwoot/issue/CW-6941/outgoing-email-messages-strip-single-newlines-from-plain-text-content
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="604" height="104" alt="image"
src="https://github.com/user-attachments/assets/f9086ffb-a5c7-4688-99aa-97ea5edcccde"
/>
**After**
<img width="604" height="210" alt="image"
src="https://github.com/user-attachments/assets/a8f21c76-bcb8-4058-937a-dd185fb6745c"
/>
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
## Context
Same root-cause as #7822 / #14078, but in a second file that PR #14078
doesn't touch:
\`app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue\`.
\`countries.json\` entries only have \`id\` (e.g. \`"AF"\`) — there is
no \`code\` field. So:
\`\`\`js
const countriesMap = countries.reduce((acc, country) => {
acc[country.code] = country; // country.code is undefined →
acc[undefined] = country
acc[country.id] = country;
return acc;
}, {});
\`\`\`
…overwrites \`acc[undefined]\` on every iteration, leaving whichever
country comes last in the list (Zimbabwe, \`id: "ZW"\`). Later, the
\`countryDetails\` lookup falls back to that value whenever the
contact's \`country\` / \`countryCode\` is missing or unknown, and
Zimbabwe is displayed incorrectly.
## Fix
One-line delete — drop the dead \`acc[country.code] = country\` write.
Lookups by ISO code continue to work via \`country.id\`.
## Scope
Only the search-results card. The Contacts card is already being fixed
in #14078 with the same one-line delete. It's worth patching this
surface too so the same symptom doesn't reappear when the same contact
is accessed via \`Ctrl+K\` search.
## Test plan
- [ ] Reproduce: search for a contact with \`country: null\` /
\`countryCode: null\` — before this patch the flag renders as Zimbabwe;
after, it renders as no country (current expected fallback).
- [ ] Search for a contact with a valid ISO \`countryCode\` (e.g.
\`"IN"\`) — country still resolves correctly.
- [ ] Contacts list page (\`ContactsCard.vue\`, fixed in #14078) — no
regression.
## Follow-up (not in this PR)
Both components keep rebuilding the same \`countriesMap\` per mount. A
small \`shared/constants/countries.js\` export (\`export const byId =
countries.reduce(...)\`, computed once at module load) would save the
per-mount cost and centralise the shape so this bug can't return.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
When a WhatsApp contact starts a new conversation by sending multiple
images at once (an album), each image arrives as a separate webhook.
Because no conversation exists yet, the concurrent workers each pass the
"does a conversation exist?" check and each create their own
conversation — producing one conversation per image instead of one
grouped conversation.
This fix serializes webhook processing per `(inbox, contact)` using a
Redis lock at the job level, so only one webhook at a time can create
the initial conversation for a given contact. Concurrent workers retry
with backoff and append to the same conversation once the lock is
released.
## Closes
- Closes#13261
## How to test
1. On a WhatsApp inbox, ensure there is no active (open) conversation
with a specific test contact — resolve or delete any existing one.
2. From a phone, select 6+ images in the WhatsApp gallery and send them
as a single album to the Chatwoot-connected number.
3. Open the Chatwoot dashboard and confirm exactly **one** new
conversation is created, with all images grouped under it.
4. Repeat the test with a mix of attachment types (XMLs, PDFs, images)
sent in rapid succession — still one conversation.
## What changed
- New Redis key `WHATSAPP_MESSAGE_CREATE_LOCK::<inbox_id>::<sender_id>`
in `lib/redis/redis_keys.rb`.
- `Webhooks::WhatsappEventsJob` now inherits from `MutexApplicationJob`
and wraps event processing in `with_lock(key)`, matching the pattern
already used by `FacebookEventsJob`, `InstagramEventsJob`, and
`TiktokEventsJob`.
- Uses `retry_on LockAcquisitionError, wait: 1.second, attempts: 8` so
concurrent webhooks retry until the lock is free instead of poll-waiting
inside the service.
- Sender ID is derived from the webhook payload (contact's `from`, or
`to` for SMB echo events); status-only webhooks bypass the lock.
- Issue 1 from the report (same `source_id` redelivery) was already
handled previously by `Whatsapp::MessageDedupLock` (atomic `SET NX EX`);
no changes needed there.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a customer responds to a bot's interactive prompt (input_select,
input_csat, form, input_email) from the widget, the response shows up in
the Chatwoot agent UI but is not reflected in the linked Slack channel —
Slack only ever shows the original question. This happens because the
widget submits the answer as an UPDATE to the original message (writing
`content_attributes.submitted_values` or `submitted_email`), but the
Slack hook only listened to `message.created`, so updates were ignored.
Closes https://linear.app/chatwoot/issue/PLA-147
### Preview
<img width="1290" height="1106" alt="CleanShot 2026-04-21 at 13 19
19@2x"
src="https://github.com/user-attachments/assets/cd2a9d3f-89d3-4e81-9230-5b078e1b7b44"
/>
### How to test
1. Connect a web widget inbox to a Slack channel.
2. Trigger each bot message type (input_select, form, input_csat,
input_email) in a conversation.
3. Submit responses from the widget.
4. Verify each response now appears in the Slack thread, appended to the
original bot question.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
## Description
This PR resolves an issue that was causing Zimbabwe country being
selected in the Contacts list.
The root cause is objects in `countries.json` file are missing the
`code` property which was causing the countries map to always have the
last country in the list also map to an `undefined` key. In turn, this
was causing an error when contact's `country` was undefined.
Additional thing to keep in mind: I am not sure if there is a case when
contact's `country` property is used or if it can be removed as well,
but in my Chatwoot installation contacts only have a `countryCode`
property, and there is no way to set `country` from the widget sdk.
Fixes#7822
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Simply running the vue application locally targeting my live chatwoot
API.
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6883/allow-disabling-2fa-using-a-backup-code
## Description
When a user loses access to their authenticator app, they can now
disable 2FA using one of their saved backup codes (in addition to their
password), so they can re-enroll a new authenticator. The disable dialog
includes a toggle to switch between entering a verification code and a
backup code.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Via UI flows
<img width="495" height="423" alt="Screenshot 2026-04-20 at 2 17 21 PM"
src="https://github.com/user-attachments/assets/cc6b3dc5-39e6-4104-b5b9-cdabdc46947e"
/>
<img width="475" height="409" alt="Screenshot 2026-04-20 at 2 17 36 PM"
src="https://github.com/user-attachments/assets/97c7304d-4adb-42ed-b7b4-50f5b38585a3"
/>
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Updating portal settings (name, header text, page title, homepage link)
on a portal that already has a logo attached returns 500. The error is
\`NoMethodError: undefined method 'valid_encoding?' for an instance of
Integer\`. The fix is a two-character change in
\`process_attached_logo\`.
Closes#13300
## Root cause
\`ActiveStorage::Blob.find_signed\` expects a signed ID string (e.g.
\`"eyJfcmFpbH..."\`). Internally it calls \`valid_encoding?\` on the
argument to validate the signature payload — a method that exists on
\`String\` but not \`Integer\`.
When a portal already has a logo, the frontend includes the blob's raw
database integer ID (e.g. \`blob_id: 170\`) in the update request
payload. The controller passes this integer directly to \`find_signed\`,
which immediately raises \`NoMethodError\` before any database query is
made.
\`\`\`ruby
# before, crashes when blob_id is an Integer
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id) # NoMethodError here
@portal.logo.attach(blob)
\`\`\`
## What changed
\`\`\`ruby
# after, safe for any input type
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
\`\`\`
\`.to_s\` on an Integer produces a plain decimal string (\`"170"\`),
which is not a valid signed ID. \`find_signed\` returns \`nil\` for any
invalid signature rather than raising, so the nil guard prevents a
broken \`attach\` call. The existing logo remains attached and the
settings update succeeds.
## Trade-offs considered
| Option | Decision |
|---|---|
| \`find(blob_id)\` when input is an Integer | Bypasses signature
verification — any authenticated user knowing a blob ID could attach
arbitrary files to a portal. Security risk. Rejected. |
| Raise a 422 for non-string blob_id | Overly strict — the frontend
sending an integer is pre-existing behaviour this PR shouldn't break. |
| Silently no-op for invalid blob_id (chosen) | Correct product
behaviour: if no valid signed upload is provided, leave the logo
unchanged. The settings update still succeeds. |
## Known limitation
The correct long-term fix is also on the frontend: it should only send
\`blob_id\` when attaching a **new** upload (using the signed ID from
the direct-upload flow), not when re-submitting the existing logo's raw
database integer ID. This PR makes the server robust against the current
frontend behaviour without requiring a coordinated frontend change.
## How to reproduce
1. Create a Help Center portal and upload a logo
2. Update any text field via \`PUT /api/v1/accounts/:id/portals/:slug\`
while including \`blob_id: <integer>\` in the payload
3. Observe 500 with \`NoMethodError: undefined method 'valid_encoding?'
for an instance of Integer\`
After this fix, the request returns 200, settings are updated, and the
existing logo is preserved.
Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
This routes external downloads used by webhook fetch used by macros and
acutomations through SafeFetch. It closes the SSRF exposure from raw
Down.download paths, preserves provider-specific auth and header flows,
and adds regression coverage for blocked internal URLs plus
authenticated downloads.
Fixes # (issue):
[CW-6940](https://linear.app/chatwoot/issue/CW-6940/ssrf-via-webhooksautomationmacros-non-upload-non-avatar)
\`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>