/), so we keep those together.
+const splitBlocks = text => {
+ const lines = (text || '').split('\n');
+ const blocks = [];
+ let buffer = [];
+ let fence = null;
+ let inList = false;
+
+ const flush = () => {
+ const block = buffer.join('\n');
+ if (block.trim()) blocks.push(block);
+ buffer = [];
+ inList = false;
+ };
+
+ lines.forEach((line, index) => {
+ const marker = line.match(FENCE_RE)?.[1];
+ if (marker && !fence) fence = marker;
+ else if (fence && line.trimStart().startsWith(fence)) fence = null;
+
+ if (fence) {
+ buffer.push(line);
+ return;
+ }
+
+ if (LIST_ITEM_RE.test(line)) inList = true;
+
+ if (line.trim() !== '') {
+ buffer.push(line);
+ return;
+ }
+
+ // Blank line: keep it when the current list continues on the next non-blank
+ // line (another item or an indented continuation); otherwise end the block.
+ const next = lines.slice(index + 1).find(other => other.trim() !== '');
+ if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
+ buffer.push(line);
+ } else {
+ flush();
+ }
+ });
+
+ flush();
+ return blocks;
+};
+
+const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
+
+// Diffs the body block by block. Blocks match when they render to the same HTML
+// (the check staging uses), so only edits that change the page show as a diff.
+export const buildDiffBlocks = (oldText, newText) => {
+ const toBlocks = text =>
+ splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
+ const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
+ return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
+};
+
+export const hasPendingChanges = article =>
+ article?.draftTitle != null || article?.draftContent != null;
diff --git a/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js b/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js
new file mode 100644
index 000000000..9e9f0cea9
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js
@@ -0,0 +1,170 @@
+import {
+ renderInlineDiff,
+ buildDiffBlocks,
+ hasPendingChanges,
+ rendersIdentically,
+} from '../articleDiffHelper';
+
+describe('articleDiffHelper', () => {
+ describe('renderInlineDiff', () => {
+ it('returns the text unchanged when there is no difference', () => {
+ const result = renderInlineDiff('hello world', 'hello world');
+ expect(result).toBe('hello world');
+ expect(result).not.toContain('', () => {
+ const result = renderInlineDiff('hello', 'hello there');
+ expect(result).toContain('hello');
+ expect(result).toContain('', () => {
+ const result = renderInlineDiff('hello there', 'hello');
+ expect(result).toContain(' {
+ const result = renderInlineDiff(
+ 'How to use Agent bots?',
+ 'How How to Agent bots?'
+ );
+ expect(result).toBe(
+ 'How How to use Agent bots?'
+ );
+ });
+
+ it('escapes markup when diffing plain text', () => {
+ const result = renderInlineDiff('a', 'a ');
+ expect(result).toContain('<b>');
+ expect(result).not.toContain('');
+ });
+
+ it('treats a cleared empty string as a full deletion', () => {
+ const result = renderInlineDiff('gone', '');
+ expect(result).toContain(' {
+ it('passes an unchanged block through as equal', () => {
+ const blocks = buildDiffBlocks('same para', 'same para');
+ expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
+ });
+
+ it('marks an appended block as added', () => {
+ const blocks = buildDiffBlocks('a', 'a\n\nb');
+ expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
+ expect(blocks).toContainEqual({ type: 'added', md: 'b' });
+ });
+
+ it('marks a deleted block as removed', () => {
+ const blocks = buildDiffBlocks('a\n\nb', 'a');
+ expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
+ });
+
+ it('emits the old block then the new block for a reworded section', () => {
+ const blocks = buildDiffBlocks('hello world', 'hello there');
+ expect(blocks).toEqual([
+ { type: 'removed', md: 'hello world' },
+ { type: 'added', md: 'hello there' },
+ ]);
+ });
+
+ it('keeps a fenced code block whole when it contains blank lines', () => {
+ const code = '```\nline one\n\nline two\n```';
+ const blocks = buildDiffBlocks(code, code);
+ expect(blocks).toEqual([{ type: 'equal', md: code }]);
+ });
+
+ it('diffs an edited code block as one whole removed + added block', () => {
+ const live = '```\ncode line\n```';
+ const draft = '```\ncode line\n\nsd\n```';
+ const blocks = buildDiffBlocks(live, draft);
+ expect(blocks).toContainEqual({ type: 'removed', md: live });
+ expect(blocks).toContainEqual({ type: 'added', md: draft });
+ });
+
+ it('surfaces whitespace edits that change the rendered output', () => {
+ expect(
+ buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
+ block => block.type !== 'equal'
+ )
+ ).toBe(true);
+ expect(
+ buildDiffBlocks('line one\nline two', 'line one \nline two').some(
+ block => block.type !== 'equal'
+ )
+ ).toBe(true);
+ });
+
+ it('surfaces an indented code block turning into a paragraph', () => {
+ const blocks = buildDiffBlocks(
+ ' curl example.com',
+ 'curl example.com'
+ );
+ expect(blocks).toContainEqual({
+ type: 'removed',
+ md: ' curl example.com',
+ });
+ expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
+ });
+
+ it('keeps spacing the renderer ignores as equal', () => {
+ const blocks = buildDiffBlocks('a\nb', 'a \nb');
+ expect(blocks.every(block => block.type === 'equal')).toBe(true);
+ });
+
+ it('keeps a loose list with item descriptions as one block', () => {
+ const list =
+ '1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
+ const blocks = buildDiffBlocks(list, list);
+ expect(blocks).toEqual([{ type: 'equal', md: list }]);
+ });
+ });
+
+ describe('rendersIdentically', () => {
+ it('ignores blank-line / empty-paragraph differences', () => {
+ expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
+ expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
+ });
+
+ it('counts code-block indentation changes', () => {
+ expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
+ });
+
+ it('counts smart vs straight quotes (no typographer)', () => {
+ expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
+ });
+
+ it('counts real text changes', () => {
+ expect(rendersIdentically('hello world', 'hello there')).toBe(false);
+ });
+
+ it('treats nullish input as empty', () => {
+ expect(rendersIdentically(null, '')).toBe(true);
+ expect(rendersIdentically(undefined, 'x')).toBe(false);
+ });
+ });
+
+ describe('hasPendingChanges', () => {
+ it('is true when a draft title or content is staged', () => {
+ expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
+ expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
+ });
+
+ it('treats a cleared empty-string draft as a pending change', () => {
+ expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
+ });
+
+ it('is false with no draft columns', () => {
+ expect(hasPendingChanges({ title: 'live' })).toBe(false);
+ expect(hasPendingChanges({})).toBe(false);
+ expect(hasPendingChanges(null)).toBe(false);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index ae099a7a9..53a745dc2 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -533,6 +533,8 @@
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
+ "PENDING_EDITS": "Unpublished edits",
+ "PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
"CATEGORY": {
"UNCATEGORISED": "Uncategorised"
}
@@ -616,6 +618,8 @@
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
+ "STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
+ "STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
@@ -763,10 +767,30 @@
},
"PREVIEW": "Preview",
"PUBLISH": "Publish",
+ "PUBLISH_CHANGES": "Publish changes",
+ "PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
+ "PUBLISH_CHANGES_ERROR": "Could not publish changes",
+ "SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
+ "DISCARD_CHANGES": "Discard changes",
+ "DISCARD_CHANGES_SUCCESS": "Changes discarded",
+ "DISCARD_CHANGES_ERROR": "Could not discard changes",
+ "PENDING_CHANGES": "Pending changes",
+ "VIEW_CHANGES": "View unpublished changes",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"BACK_TO_ARTICLES": "Back to articles"
},
+ "PENDING_CHANGES_POPOVER": {
+ "TITLE": "Unpublished changes",
+ "DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
+ "APPLY": "Apply changes",
+ "DISCARD": "Discard changes"
+ },
+ "DIFF_DIALOG": {
+ "TITLE": "Unpublished changes",
+ "DESCRIPTION": "Compare your draft against the version that's currently live.",
+ "TITLE_LABEL": "Title"
+ },
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
index 36508ded5..794f8c839 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
@@ -4,8 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
-import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
+import {
+ buildPortalArticleURL,
+ ARTICLE_STATUSES,
+} from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
+import { rendersIdentically } from 'dashboard/helper/articleDiffHelper';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
@@ -40,13 +44,60 @@ const articleLink = computed(() => {
);
});
+// On a published article, title/content edits stage into draft_* columns (kept
+// off the live site). Anywhere else they save straight to the live record — and
+// we drop any leftover draft (e.g. left behind when the card/bulk menu moved a
+// published article to draft) so a later publish can't resurrect stale content.
+const stageDraftFields = values => {
+ if (article.value?.status !== ARTICLE_STATUSES.PUBLISHED) {
+ const hasStaleDraft =
+ article.value?.draftTitle != null || article.value?.draftContent != null;
+ if (!hasStaleDraft) return values;
+ // The editor is showing the staged draft, so promote both fields to the live
+ // record (the field being autosaved wins) before dropping the drafts —
+ // otherwise saving one field would snap the other back to the old live value.
+ return {
+ ...values,
+ title: values.title ?? article.value.draftTitle ?? article.value.title,
+ content:
+ values.content ?? article.value.draftContent ?? article.value.content,
+ draft_title: null,
+ draft_content: null,
+ };
+ }
+
+ const staged = { ...values };
+ ['title', 'content'].forEach(field => {
+ if (field in staged) {
+ staged[`draft_${field}`] = staged[field];
+ delete staged[field];
+ }
+ });
+
+ // Clear the draft when it matches the live version (a revert, or a body edit
+ // the renderer ignores like a blank line) so it doesn't leave a "pending
+ // changes" badge with nothing to compare. The title is shown as raw escaped
+ // text, so compare it exactly; only the body is Markdown, so compare its render.
+ const liveTitle = article.value.title ?? '';
+ const liveContent = article.value.content ?? '';
+ const nextTitle = staged.draft_title ?? article.value.draftTitle ?? liveTitle;
+ const nextContent =
+ staged.draft_content ?? article.value.draftContent ?? liveContent;
+ if (nextTitle === liveTitle && rendersIdentically(liveContent, nextContent)) {
+ staged.draft_title = null;
+ staged.draft_content = null;
+ }
+
+ return staged;
+};
+
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
- ...values,
+ ...stageDraftFields(values),
});
isSaved.value = true;
} catch (error) {
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
index 7dd42e749..a9da13106 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
@@ -96,6 +96,32 @@ export const actions = {
}
},
+ // Push the draft to live and clear it, optionally changing status in the same
+ // update. Only edited fields are sent so an untouched live value survives.
+ publishDraft: ({ dispatch, state }, { portalSlug, articleId, status }) => {
+ const article = state.articles.byId[articleId];
+ const payload = {
+ portalSlug,
+ articleId,
+ status,
+ draft_title: null,
+ draft_content: null,
+ };
+ if (article?.draftTitle != null) payload.title = article.draftTitle;
+ if (article?.draftContent != null) payload.content = article.draftContent;
+ return dispatch('update', payload);
+ },
+
+ // Clear the draft (optionally changing status); live content is left untouched.
+ discardDraft: ({ dispatch }, { portalSlug, articleId, status }) =>
+ dispatch('update', {
+ portalSlug,
+ articleId,
+ status,
+ draft_title: null,
+ draft_content: null,
+ }),
+
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
try {
const { data } = await articlesAPI.getArticles({
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
index 6517f44fd..5f3215fab 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
@@ -150,6 +150,101 @@ describe('#actions', () => {
});
});
+ describe('#publishDraft', () => {
+ const state = {
+ articles: {
+ byId: {
+ 1: {
+ id: 1,
+ draftTitle: 'Draft title',
+ draftContent: 'Draft content',
+ },
+ },
+ },
+ };
+
+ it('dispatches update promoting the edited fields and clearing the draft', async () => {
+ await actions.publishDraft(
+ { dispatch, state },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ title: 'Draft title',
+ content: 'Draft content',
+ });
+ });
+
+ it('only sends the fields that were actually edited', async () => {
+ const partialState = {
+ articles: { byId: { 1: { id: 1, draftContent: 'Only content' } } },
+ };
+ await actions.publishDraft(
+ { dispatch, state: partialState },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ content: 'Only content',
+ });
+ });
+
+ it('forwards a status to change it in the same update', async () => {
+ await actions.publishDraft(
+ { dispatch, state },
+ { portalSlug: 'room-rental', articleId: 1, status: 'archived' }
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ 'update',
+ expect.objectContaining({
+ status: 'archived',
+ title: 'Draft title',
+ content: 'Draft content',
+ draft_title: null,
+ draft_content: null,
+ })
+ );
+ });
+ });
+
+ describe('#discardDraft', () => {
+ it('dispatches update clearing the draft columns', async () => {
+ await actions.discardDraft(
+ { dispatch },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ });
+ });
+
+ it('forwards a status to change it in the same update', async () => {
+ await actions.discardDraft(
+ { dispatch },
+ { portalSlug: 'room-rental', articleId: 1, status: 'draft' }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: 'draft',
+ draft_title: null,
+ draft_content: null,
+ });
+ });
+ });
+
describe('#updateArticleMeta', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index 1a14266f0..b5dc61702 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -67,8 +67,11 @@ const createMarkdownInstance = (linkify = true) => {
// `` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
-// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
-const COLWIDTHS_MARKER_REGEX = /\r?\n?/g;
+// rendered/plain output (e.g. dashboard search snippets). Strip the whole marker
+// line, including any blockquote prefix, so a quoted table's `>` prefixes don't
+// collapse together and break table parsing.
+const COLWIDTHS_MARKER_REGEX =
+ /^[ \t>]*[ \t]*\r?\n?/gm;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 20d64005a..70e71c46d 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -153,6 +153,15 @@ After`;
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
+
+ it('strips a blockquote-prefixed marker so the quoted table still renders', () => {
+ const message =
+ '> \n> | A | B |\n> | --- | --- |\n> | 1 | 2 |';
+ const { formattedMessage } = new MessageFormatter(message);
+ expect(formattedMessage).not.toContain('cw-colwidths');
+ expect(formattedMessage).toContain('');
+ expect(formattedMessage).toContain('');
+ });
});
describe('#sanitize', () => {
diff --git a/app/models/article.rb b/app/models/article.rb
index 9d1247e8b..eb843493a 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -5,6 +5,8 @@
# id :bigint not null, primary key
# content :text
# description :text
+# draft_content :text
+# draft_title :string
# locale :string default("en"), not null
# meta :jsonb
# position :integer
diff --git a/app/views/api/v1/accounts/articles/_article.json.jbuilder b/app/views/api/v1/accounts/articles/_article.json.jbuilder
index a6eb7a551..426b24afe 100644
--- a/app/views/api/v1/accounts/articles/_article.json.jbuilder
+++ b/app/views/api/v1/accounts/articles/_article.json.jbuilder
@@ -4,6 +4,8 @@ json.title article.title
json.content article.content
json.description article.description
json.status article.status
+json.draft_title article.draft_title
+json.draft_content article.draft_content
json.position article.position
json.account_id article.account_id
json.updated_at article.updated_at.to_i
diff --git a/db/migrate/20260623000000_add_draft_columns_to_articles.rb b/db/migrate/20260623000000_add_draft_columns_to_articles.rb
new file mode 100644
index 000000000..a6f5e0cd7
--- /dev/null
+++ b/db/migrate/20260623000000_add_draft_columns_to_articles.rb
@@ -0,0 +1,6 @@
+class AddDraftColumnsToArticles < ActiveRecord::Migration[7.1]
+ def change
+ add_column :articles, :draft_title, :string
+ add_column :articles, :draft_content, :text
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 43e7135b9..14b634c30 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -211,6 +211,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.string "slug", null: false
t.integer "position"
t.string "locale", default: "en", null: false
+ t.string "draft_title"
+ t.text "draft_content"
t.index ["account_id"], name: "index_articles_on_account_id"
t.index ["associated_article_id"], name: "index_articles_on_associated_article_id"
t.index ["author_id"], name: "index_articles_on_author_id"
diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
index bf3ae2aa9..6651a5b20 100644
--- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
@@ -170,6 +170,29 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(json_response['payload']['status']).to eql(article_params[:article][:status])
expect(json_response['payload']['position']).to eql(article_params[:article][:position])
end
+
+ it 'stages draft-only fields without bumping updated_at' do
+ expect do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: { article: { draft_title: 'Draft title', draft_content: 'Draft body' } },
+ headers: admin.create_new_auth_token
+ end.not_to(change { article.reload.updated_at })
+
+ expect(response).to have_http_status(:success)
+ expect(article.draft_title).to eq('Draft title')
+ expect(article.draft_content).to eq('Draft body')
+ end
+
+ it 'rejects an over-length draft without persisting it' do
+ expect do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: { article: { draft_content: 'a' * 20_001 } },
+ headers: admin.create_new_auth_token
+ end.not_to(change { article.reload.draft_content })
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to include('too long')
+ end
end
end
From 0a25a0ef664ef71be58065ab487862144d792bc9 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Thu, 16 Jul 2026 13:35:20 +0400
Subject: [PATCH 105/143] fix(whatsapp): log manual transfer only for embedded
signup migrations (#15032)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to
track embedded signup → manual migrations was firing on any WhatsApp
credential change — including routine `api_key` rotations on inboxes
that were already manually configured — inflating the migration count.
The log now fires only for the actual migration, and uses a new tag so
log searches don't match the older over-counted entries.
## How to reproduce
1. On a manually configured WhatsApp Cloud inbox, update the API key
from inbox settings → Configuration.
2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER]
success` line even though no migration happened; after this change it
stays silent.
3. Switching an embedded signup inbox to manual setup still logs the
migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`).
## What changed
- `Channel::Whatsapp#log_credentials_transfer` now keys off the
migration's unique signal — `provider_config['source']` changing from
`embedded_signup` to absent — instead of diffing credential keys.
- Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to
`WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the
corrected entries are searchable without matching pre-fix false
positives.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
---
app/models/channel/whatsapp.rb | 16 ++++++++++++----
.../whatsapp/providers/whatsapp_cloud_service.rb | 6 +++---
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 7a109c455..87ff199d5 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -101,6 +101,13 @@ class Channel::Whatsapp < ApplicationRecord
end
end
+ # Whether the pending (unsaved) provider_config change drops the embedded_signup
+ # source marker, i.e. this save is an embedded signup → manual setup transfer.
+ def embedded_to_manual_transfer_pending?
+ before, after = provider_config_change
+ before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
+ end
+
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
@@ -130,13 +137,14 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
- # Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
+ # Logs only the embedded signup → manual migration (the save drops the
+ # embedded_signup source marker), so credential rotations on inboxes that are
+ # already manual stay silent.
def log_credentials_transfer
before, after = saved_change_to_provider_config
- keys = %w[api_key phone_number_id business_account_id]
- return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
+ return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
- Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
+ Rails.logger.info("[WHATSAPP_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}")
end
def perform_webhook_setup
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 373e47b3c..d65c6cc62 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -94,12 +94,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
private
- # Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
+ # Only saves dropping the embedded_signup source marker are transfer attempts; creation/rotation failures are setup errors. Returns false.
def log_transfer_failure(check, response)
- return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
+ return false unless whatsapp_channel.embedded_to_manual_transfer_pending?
error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
- Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
+ Rails.logger.warn("[WHATSAPP_EMBEDDED_TO_MANUAL] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
"check=#{check} http_status=#{response.code} meta_error=#{error_message}")
false
end
From 522e3c4d3ffdcb76854632d6d3975f24315a9500 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Thu, 16 Jul 2026 15:13:49 +0530
Subject: [PATCH 106/143] feat: enforce `api_and_webhooks` feature for token
API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.
## What changed
- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.
## Existing paid-account rollout
Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.
```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)
total = accounts.count
enabled = 0
skipped = 0
puts "Enabling api_and_webhooks for #{total} paid account(s)..."
accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
if account.feature_enabled?('api_and_webhooks')
skipped += 1
else
account.enable_features!('api_and_webhooks')
enabled += 1
end
puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end
puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```
For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:
```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```
## How to test
- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.
### Screenshots
## Cloud
---------
Co-authored-by: Muhsin Keloth
---
.../api/v1/accounts/base_controller.rb | 9 +++
app/controllers/api/v1/accounts_controller.rb | 7 ++
.../components-next/button/ConfirmButton.vue | 2 +
app/javascript/dashboard/featureFlags.js | 1 +
.../i18n/locale/en/integrations.json | 7 ++
.../dashboard/i18n/locale/en/settings.json | 1 +
.../settings/integrations/Webhooks/Index.vue | 51 +++++++++++---
.../integrations/Webhooks/WebhookPaywall.vue | 27 ++++++++
.../settings/profile/AccessToken.vue | 5 ++
.../dashboard/settings/profile/Index.vue | 26 +++++++-
app/listeners/webhook_listener.rb | 2 +
app/models/account.rb | 4 ++
app/views/api/v1/models/_user.json.jbuilder | 3 +-
.../enterprise/api/v1/accounts_controller.rb | 7 ++
enterprise/app/models/enterprise/account.rb | 6 ++
spec/controllers/api/base_controller_spec.rb | 61 +++++++++++++++++
.../v1/accounts/webhook_controller_spec.rb | 11 ++++
.../api/v1/accounts_controller_spec.rb | 66 +++++++++++++++++++
.../api/v1/profiles_controller_spec.rb | 58 ++++++++++++++++
.../api/v1/accounts_controller_spec.rb | 24 +++++++
spec/enterprise/models/account_spec.rb | 22 +++++++
spec/listeners/webhook_listener_spec.rb | 44 +++++++++++++
spec/models/account_spec.rb | 9 +++
23 files changed, 441 insertions(+), 12 deletions(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookPaywall.vue
diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb
index e30effc59..f08b87e60 100644
--- a/app/controllers/api/v1/accounts/base_controller.rb
+++ b/app/controllers/api/v1/accounts/base_controller.rb
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
+
+ private
+
+ def validate_token_api_access
+ return if Current.account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index fb991949a..9438a1660 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
+ def validate_token_api_access
+ return if @account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
+
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
diff --git a/app/javascript/dashboard/components-next/button/ConfirmButton.vue b/app/javascript/dashboard/components-next/button/ConfirmButton.vue
index 854d5d452..00b9300c6 100644
--- a/app/javascript/dashboard/components-next/button/ConfirmButton.vue
+++ b/app/javascript/dashboard/components-next/button/ConfirmButton.vue
@@ -14,6 +14,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
+ disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -61,6 +62,7 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
+ :disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index 058921eea..e3c57e99d 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -12,6 +12,7 @@ export const FEATURE_FLAGS = {
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
+ API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index 629dbd27c..82782988f 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -31,6 +31,13 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
+ "PAYWALL": {
+ "TITLE": "Webhooks are available on paid plans",
+ "AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
+ "UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
+ "UPGRADE_NOW": "Upgrade now",
+ "CANCEL_ANYTIME": "Change or cancel your plan anytime."
+ },
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index eaecd7b80..ceb0438b1 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -100,6 +100,7 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
+ "PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue
index 7213c735a..75dc30812 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
+import WebhookPaywall from './WebhookPaywall.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
@@ -20,6 +22,7 @@ export default {
NewWebhook,
EditWebhook,
WebhookRow,
+ WebhookPaywall,
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -39,7 +42,19 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
+ accountId: 'getCurrentAccountId',
+ isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
+ isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
+ apiAndWebhooksEnabled() {
+ return (
+ !this.isOnChatwootCloud ||
+ this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.API_AND_WEBHOOKS
+ )
+ );
+ },
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
@@ -57,9 +72,16 @@ export default {
];
},
},
+ watch: {
+ apiAndWebhooksEnabled: {
+ immediate: true,
+ handler(enabled) {
+ if (enabled) this.$store.dispatch('webhooks/get');
+ },
+ },
+ },
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
- this.$store.dispatch('webhooks/get');
},
methods: {
openAddPopup() {
@@ -105,10 +127,10 @@ export default {
-
+
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
-
+
+
+
-
+
+import { useRouter } from 'vue-router';
+import { useMapGetter } from 'dashboard/composables/store';
+import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
+
+const router = useRouter();
+const accountId = useMapGetter('getCurrentAccountId');
+
+const openBilling = () => {
+ router.push({
+ name: 'billing_settings_index',
+ params: { accountId: accountId.value },
+ });
+};
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue
index 027492629..a2e7db1f0 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
+ disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['onCopy', 'onReset']);
@@ -41,12 +42,14 @@ const onReset = () => {
}"
:type="inputType"
:model-value="value"
+ :disabled="disabled"
readonly
>
@@ -61,6 +64,7 @@ const onReset = () => {
type="button"
icon="i-lucide-copy"
class="rounded-xl"
+ :disabled="disabled"
@click="onClick"
/>
{
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
+ :disabled="disabled"
@click="onReset"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
index 04de7bda2..c794ac7a0 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
@@ -101,7 +101,24 @@ export default {
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
+ isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
+ apiAndWebhooksEnabled() {
+ if (!this.isOnChatwootCloud) return true;
+
+ return this.currentUser.accounts.some(
+ account => account.api_and_webhooks
+ );
+ },
+ accessTokenDescription() {
+ if (!this.apiAndWebhooksEnabled) {
+ return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
+ }
+
+ return this.replaceInstallationName(
+ this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
+ );
+ },
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
@@ -191,10 +208,14 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
},
async onCopyToken(value) {
+ if (!this.apiAndWebhooksEnabled) return;
+
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
async resetAccessToken() {
+ if (!this.apiAndWebhooksEnabled) return;
+
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
@@ -339,12 +360,11 @@ export default {
diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb
index c64b36ca0..bb44649dd 100644
--- a/app/listeners/webhook_listener.rb
+++ b/app/listeners/webhook_listener.rb
@@ -108,6 +108,8 @@ class WebhookListener < BaseListener
end
def deliver_account_webhooks(payload, account)
+ return unless account.api_and_webhooks_enabled?
+
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
diff --git a/app/models/account.rb b/app/models/account.rb
index 4295b162e..00201739a 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -154,6 +154,10 @@ class Account < ApplicationRecord
}
end
+ def api_and_webhooks_enabled?
+ true
+ end
+
def locale_english_name
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
# the format is `_`
diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder
index e856031c5..e4f795566 100644
--- a/app/views/api/v1/models/_user.json.jbuilder
+++ b/app/views/api/v1/models/_user.json.jbuilder
@@ -1,4 +1,4 @@
-json.access_token resource.access_token.token
+json.access_token resource.accounts.any?(&:api_and_webhooks_enabled?) ? resource.access_token.token : ''
json.account_id resource.active_account_user&.account_id
json.available_name resource.available_name
json.avatar_url resource.avatar_url
@@ -31,6 +31,7 @@ json.accounts do
# availability derived from presence
json.availability_status account_user.availability_status
json.auto_offline account_user.auto_offline
+ json.api_and_webhooks account_user.account.feature_enabled?('api_and_webhooks')
json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise?
end
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
index 621a508e4..ef6219d59 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
@@ -1,6 +1,7 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
@@ -89,6 +90,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
private
+ def validate_token_api_access
+ return if @account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
+
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb
index 62898803a..eda28712f 100644
--- a/enterprise/app/models/enterprise/account.rb
+++ b/enterprise/app/models/enterprise/account.rb
@@ -73,6 +73,12 @@ module Enterprise::Account
saml_settings&.saml_enabled? || false
end
+ def api_and_webhooks_enabled?
+ return true unless ChatwootApp.chatwoot_cloud?
+
+ feature_enabled?('api_and_webhooks')
+ end
+
def billing_currency
# Feature off => everyone is billed in USD (legacy behaviour).
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb
index 0034715eb..6269e027f 100644
--- a/spec/controllers/api/base_controller_spec.rb
+++ b/spec/controllers/api/base_controller_spec.rb
@@ -23,6 +23,52 @@ RSpec.describe 'API Base', type: :request do
end
end
+ context 'when API and webhook access is disabled for the account' do
+ let!(:admin) { create(:user, :administrator, account: account) }
+ let!(:conversation) { create(:conversation, account: account) }
+
+ before do
+ allow(Account).to receive(:find).and_call_original
+ allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+ end
+
+ it 'returns forbidden for token authenticated requests' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
+ end
+
+ it 'allows session authenticated requests' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'when a self-hosted account has the feature flag disabled' do
+ let!(:admin) { create(:user, :administrator, account: account) }
+ let!(:conversation) { create(:conversation, account: account) }
+
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ account.disable_features!('api_and_webhooks')
+ end
+
+ it 'allows token authenticated requests' do
+ get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
context 'when it is an invalid api_access_token' do
it 'returns unauthorized' do
get '/api/v1/profile',
@@ -94,6 +140,21 @@ RSpec.describe 'API Base', type: :request do
end
end
+ context 'when API and webhook access is disabled for the account' do
+ it 'returns forbidden for accessible bot endpoints' do
+ create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
+ allow(Account).to receive(:find).and_call_original
+ allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
+ headers: { api_access_token: agent_bot.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+
context 'when the account is suspended' do
it 'returns 401 unauthorized' do
account.update!(status: :suspended)
diff --git a/spec/controllers/api/v1/accounts/webhook_controller_spec.rb b/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
index 86f4d4e7e..63a39c1d1 100644
--- a/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
@@ -26,6 +26,17 @@ RSpec.describe 'Webhooks API', type: :request do
expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count
end
end
+
+ context 'when api_and_webhooks feature is disabled' do
+ it 'allows session authenticated admins to manage webhooks' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ account.disable_features!('api_and_webhooks')
+ get "/api/v1/accounts/#{account.id}/webhooks",
+ headers: administrator.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ end
+ end
end
describe 'POST /api/v1/accounts//webhooks' do
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index d93503418..ec020b656 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -199,6 +199,22 @@ RSpec.describe 'Accounts API', type: :request do
expect(response.body).to include(account.locale)
end
end
+
+ context 'when API and webhook access is disabled for the account' do
+ it 'returns forbidden for API token authentication' do
+ account_scope = double
+ allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
+ allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+
+ get "/api/v1/accounts/#{account.id}",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
+ end
+ end
end
describe 'GET /api/v1/accounts/{account.id}/cache_keys' do
@@ -225,6 +241,21 @@ RSpec.describe 'Accounts API', type: :request do
expect(response.headers['Cache-Control']).to include('private')
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300')
end
+
+ context 'when API and webhook access is disabled for the account' do
+ it 'returns forbidden for API token authentication' do
+ account_scope = double
+ allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
+ allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+
+ get "/api/v1/accounts/#{account.id}/cache_keys",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
end
describe 'PATCH /api/v1/accounts/{account.id}' do
@@ -324,6 +355,24 @@ RSpec.describe 'Accounts API', type: :request do
expect(json_response['message']).to eq('Name is too long (maximum is 255 characters)')
end
end
+
+ context 'when API and webhook access is disabled for the account' do
+ it 'returns forbidden without modifying the account for API token authentication' do
+ account_scope = double
+ allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
+ allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+
+ expect do
+ patch "/api/v1/accounts/#{account.id}",
+ params: { name: 'Updated through API' },
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+ end.not_to(change { account.reload.name })
+
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
end
describe 'POST /api/v1/accounts/{account.id}/update_active_at' do
@@ -349,5 +398,22 @@ RSpec.describe 'Accounts API', type: :request do
expect(agent.account_users.first.active_at).not_to be_nil
end
end
+
+ context 'when API and webhook access is disabled for the account' do
+ it 'returns forbidden without updating active_at for API token authentication' do
+ account_scope = double
+ allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
+ allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+ account_user = agent.account_users.first
+
+ post "/api/v1/accounts/#{account.id}/update_active_at",
+ headers: { api_access_token: agent.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(account_user.reload.active_at).to be_nil
+ end
+ end
end
end
diff --git a/spec/controllers/api/v1/profiles_controller_spec.rb b/spec/controllers/api/v1/profiles_controller_spec.rb
index 19054b523..fb5819182 100644
--- a/spec/controllers/api/v1/profiles_controller_spec.rb
+++ b/spec/controllers/api/v1/profiles_controller_spec.rb
@@ -29,6 +29,49 @@ RSpec.describe 'Profile API', type: :request do
expect(json_response['custom_attributes']['test']).to eq('test')
expect(json_response['message_signature']).to be_nil
end
+
+ it 'returns an empty access token when all accounts have API and webhook access disabled' do
+ account.disable_features!('api_and_webhooks')
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+ allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
+
+ get '/api/v1/profile',
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ json_response = response.parsed_body
+ expect(json_response['access_token']).to eq('')
+ expect(json_response['accounts'].first['api_and_webhooks']).to be false
+ end
+
+ it 'returns the access token when any account has API and webhook access enabled' do
+ account.disable_features!('api_and_webhooks')
+ enabled_account = create(:account)
+ enabled_account.enable_features!('api_and_webhooks')
+ create(:account_user, account: enabled_account, user: agent)
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+ allow(enabled_account).to receive(:api_and_webhooks_enabled?).and_return(true)
+ allow_any_instance_of(User).to receive(:accounts).and_return([account, enabled_account]) # rubocop:disable RSpec/AnyInstance
+
+ get '/api/v1/profile',
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ json_response = response.parsed_body
+ expect(json_response['access_token']).to eq(agent.access_token.token)
+ expect(json_response['accounts'].find { |item| item['id'] == enabled_account.id }['api_and_webhooks']).to be true
+ end
+
+ it 'returns the access token for self-hosted accounts even when the stored feature flag is disabled' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ account.disable_features!('api_and_webhooks')
+
+ get '/api/v1/profile',
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response.parsed_body['access_token']).to eq(agent.access_token.token)
+ end
end
end
@@ -338,6 +381,21 @@ RSpec.describe 'Profile API', type: :request do
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
end
+
+ it 'regenerates the stored token but returns an empty token when no account has API and webhook access enabled' do
+ account.disable_features!('api_and_webhooks')
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+ allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
+ old_token = agent.access_token.token
+
+ post '/api/v1/profile/reset_access_token',
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(agent.reload.access_token.token).not_to eq(old_token)
+ expect(response.parsed_body['access_token']).to eq('')
+ end
end
end
end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
index cfabf6b7e..d0b137dce 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
@@ -5,6 +5,30 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
+ describe 'API token access' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ account.disable_features!('api_and_webhooks')
+ end
+
+ it 'returns forbidden when API and webhook access is disabled for the account' do
+ get "/enterprise/api/v1/accounts/#{account.id}/limits",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
+ end
+
+ it 'allows session-authenticated requests' do
+ get "/enterprise/api/v1/accounts/#{account.id}/limits",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb
index 7c57e217f..f3457d9a5 100644
--- a/spec/enterprise/models/account_spec.rb
+++ b/spec/enterprise/models/account_spec.rb
@@ -32,6 +32,28 @@ RSpec.describe Account, type: :model do
end
end
+ describe '#api_and_webhooks_enabled?' do
+ let(:account) { create(:account) }
+
+ it 'is always enabled for self-hosted enterprise accounts' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ account.disable_features!('api_and_webhooks')
+
+ expect(account.api_and_webhooks_enabled?).to be true
+ end
+
+ it 'uses the account feature flag on Chatwoot Cloud' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ account.disable_features!('api_and_webhooks')
+
+ expect(account.api_and_webhooks_enabled?).to be false
+
+ account.enable_features!('api_and_webhooks')
+
+ expect(account.api_and_webhooks_enabled?).to be true
+ end
+ end
+
describe 'sla_policies' do
let!(:account) { create(:account) }
let!(:sla_policy) { create(:sla_policy, account: account) }
diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb
index b63f43c2f..8af0fd4d3 100644
--- a/spec/listeners/webhook_listener_spec.rb
+++ b/spec/listeners/webhook_listener_spec.rb
@@ -44,6 +44,50 @@ describe WebhookListener do
end
end
+ context 'when API and webhook access is disabled for the account' do
+ before do
+ allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
+ allow(message).to receive(:inbox).and_return(inbox)
+ allow(inbox).to receive(:account).and_return(account)
+ end
+
+ it 'does not trigger account webhooks' do
+ create(:webhook, inbox: inbox, account: account)
+ expect(WebhookJob).not_to receive(:perform_later)
+ listener.message_created(message_created_event)
+ end
+
+ it 'still triggers API inbox webhooks' do
+ channel_api = create(:channel_api, account: account)
+ api_inbox = channel_api.inbox
+ api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
+ api_message = create(:message, message_type: 'outgoing', account: account, inbox: api_inbox, conversation: api_conversation)
+ api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
+ allow(api_message).to receive(:inbox).and_return(api_inbox)
+ allow(api_inbox).to receive(:account).and_return(account)
+ expect(WebhookJob).to receive(:perform_later).with(
+ channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
+ :api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
+ ).once
+ listener.message_created(api_event)
+ end
+ end
+
+ context 'when api_and_webhooks feature is disabled on self-hosted' do
+ it 'still triggers account webhooks' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ account.disable_features!('api_and_webhooks')
+ webhook = create(:webhook, inbox: inbox, account: account)
+
+ expect(WebhookJob).to receive(:perform_later).with(
+ webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
+ secret: webhook.secret, delivery_id: instance_of(String)
+ ).once
+
+ listener.message_created(message_created_event)
+ end
+ end
+
context 'when inbox is an API Channel' do
it 'triggers webhook if webhook_url is present' do
channel_api = create(:channel_api, account: account)
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 00b464f73..8821d5749 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -50,6 +50,15 @@ RSpec.describe Account do
end
end
+ describe '#api_and_webhooks_enabled?' do
+ it 'is enabled for self-hosted accounts regardless of the stored feature flag' do
+ account = create(:account)
+ account.disable_features!('api_and_webhooks')
+
+ expect(account.api_and_webhooks_enabled?).to be true
+ end
+ end
+
describe 'captain defaults for new accounts' do
it 'does not store Captain model overrides or enable premium Captain features' do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
From 2891a72cb9cb43129e3ae8a197412f6184c030d4 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Thu, 16 Jul 2026 16:18:58 +0530
Subject: [PATCH 107/143] feat(whatsapp): enable reconfigure for embedded
signup inboxes (#15038)
---
.../whatsapp/authorizations_controller.rb | 9 ++-
app/javascript/dashboard/featureFlags.js | 1 +
.../inbox/settingsPage/ConfigurationPage.vue | 60 ++++++++++++++++++-
.../whatsapp/embedded_signup_service.rb | 2 +-
.../whatsapp/reauthorization_service.rb | 6 +-
config/features.yml | 4 ++
.../authorizations_controller_spec.rb | 19 ++----
spec/models/account_spec.rb | 2 +-
.../whatsapp/embedded_signup_service_spec.rb | 4 +-
9 files changed, 82 insertions(+), 25 deletions(-)
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index db94113d9..580ae77c6 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,4 +1,6 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
+ # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
+ before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -31,7 +33,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
- return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
+ return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
render json: {
success: false,
@@ -39,10 +41,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
- def can_upgrade_to_embedded_signup?
+ def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
+ # Reconfiguring a live embedded-signup channel requires the feature flag.
+ return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
+
true
end
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index e3c57e99d..eb8e1b7bd 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -8,6 +8,7 @@ export const FEATURE_FLAGS = {
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
+ WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
index e1ecdbd3f..61038cec1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
@@ -1,5 +1,9 @@
+
+
+
+
+
+
+ {{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
+
+ {{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}
+
+
+
+ {{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
+
+ {{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
index 1ab9e9503..2737cb353 100644
--- a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
+++ b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
@@ -1,6 +1,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.subtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
new file mode 100644
index 000000000..b07868863
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
@@ -0,0 +1,224 @@
+import { mount } from '@vue/test-utils';
+import { h } from 'vue';
+import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
+
+const OPTIONS = [
+ { value: 1, label: 'Getting started', subtitle: 'Guides' },
+ { value: 2, label: 'Billing', subtitle: 'Payments' },
+ { value: 3, label: 'Security' },
+ { value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
+];
+
+// A findable dropdown stub that exposes the `focus()` the component calls on open.
+const ComboBoxDropdownStub = {
+ name: 'ComboBoxDropdown',
+ props: [
+ 'open',
+ 'options',
+ 'searchValue',
+ 'searchPlaceholder',
+ 'emptyState',
+ 'loading',
+ ],
+ emits: ['select', 'update:searchValue'],
+ methods: { focus() {} },
+ template: '
',
+};
+
+// Renders a real so clicks reach the parent handlers; `data-icon`
+// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
+const ButtonStub = {
+ name: 'Button',
+ props: ['label', 'icon', 'disabled'],
+ emits: ['click'],
+ template:
+ ' {{ label }} ',
+};
+
+const mountSelect = (props = {}, slots = {}) =>
+ mount(ReorderableMultiSelect, {
+ props: { options: OPTIONS, max: 3, ...props },
+ slots,
+ global: {
+ stubs: {
+ Button: ButtonStub,
+ ComboBoxDropdown: ComboBoxDropdownStub,
+ Spinner: true,
+ Icon: true,
+ EmojiIcon: true,
+ OnClickOutside: { template: '
' },
+ },
+ },
+ });
+
+const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
+const addTrigger = wrapper =>
+ wrapper.findAll('button').find(button => !button.attributes('data-icon'));
+const removeButtons = wrapper =>
+ wrapper.findAll('button[data-icon="i-lucide-x"]');
+const rows = wrapper => wrapper.findAll('[draggable="true"]');
+const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
+
+describe('ReorderableMultiSelect', () => {
+ describe('rendering selected rows', () => {
+ it('renders rows in model order with labels resolved from options', () => {
+ const wrapper = mountSelect({ modelValue: [2, 1] });
+
+ const labels = rows(wrapper).map(row => row.find('p').text());
+ expect(labels).toEqual(['Billing', 'Getting started']);
+ });
+
+ it('falls back to the stringified id when an option is unknown', () => {
+ const wrapper = mountSelect({ modelValue: [99] });
+
+ expect(rows(wrapper)[0].find('p').text()).toBe('99');
+ });
+
+ it('renders the progress dots filled up to the selection count', () => {
+ const wrapper = mountSelect({
+ modelValue: [1, 2],
+ max: 3,
+ label: 'Tags',
+ });
+
+ const filled = wrapper.findAll('.bg-n-brand').length;
+ expect(filled).toBe(2);
+ });
+
+ it('exposes remaining and max to the counter slot', () => {
+ const wrapper = mountSelect(
+ { modelValue: [1], max: 3 },
+ { counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
+ );
+
+ expect(wrapper.text()).toContain('2/3');
+ });
+ });
+
+ describe('adding options', () => {
+ it('appends the chosen option to the model', () => {
+ const wrapper = mountSelect({ modelValue: [1] });
+
+ dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
+
+ expect(lastModel(wrapper)).toEqual([1, 2]);
+ });
+
+ it('hides the add trigger once the model reaches max', () => {
+ const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
+
+ expect(addTrigger(wrapper)).toBeUndefined();
+ expect(dropdown(wrapper).exists()).toBe(false);
+ });
+
+ it('closes the dropdown when the last slot is filled', async () => {
+ const wrapper = mountSelect({ modelValue: [1], max: 2 });
+ await addTrigger(wrapper).trigger('click');
+ expect(dropdown(wrapper).props('open')).toBe(true);
+
+ dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
+ await wrapper.vm.$nextTick();
+
+ // Reaching max removes the trigger (and its dropdown) entirely.
+ expect(dropdown(wrapper).exists()).toBe(false);
+ });
+
+ it('excludes already-selected options from the dropdown', () => {
+ const wrapper = mountSelect({ modelValue: [1] });
+
+ const values = dropdown(wrapper)
+ .props('options')
+ .map(option => option.value);
+ expect(values).toEqual([2, 3, 4]);
+ });
+ });
+
+ describe('removing options', () => {
+ it('removes the clicked item from the model', async () => {
+ const wrapper = mountSelect({ modelValue: [1, 2, 3] });
+
+ await removeButtons(wrapper)[1].trigger('click');
+
+ expect(lastModel(wrapper)).toEqual([1, 3]);
+ });
+ });
+
+ describe('searching', () => {
+ it('filters options locally by label', async () => {
+ const wrapper = mountSelect({ modelValue: [] });
+
+ dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
+ await wrapper.vm.$nextTick();
+
+ const values = dropdown(wrapper)
+ .props('options')
+ .map(option => option.value);
+ expect(values).toEqual([2]);
+ });
+
+ it('emits search and skips local filtering when serverSearch is set', async () => {
+ const wrapper = mountSelect({ modelValue: [], serverSearch: true });
+
+ dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
+ // All unselected options remain; the parent owns filtering.
+ expect(dropdown(wrapper).props('options')).toHaveLength(4);
+ });
+
+ it('emits an empty search when the trigger opens', async () => {
+ const wrapper = mountSelect({ modelValue: [1] });
+
+ await addTrigger(wrapper).trigger('click');
+
+ expect(wrapper.emitted('search').at(-1)).toEqual(['']);
+ expect(dropdown(wrapper).props('open')).toBe(true);
+ });
+ });
+
+ describe('reordering', () => {
+ it('moves a row to the dropped position within the model', async () => {
+ const wrapper = mountSelect({ modelValue: [1, 2, 3] });
+
+ await rows(wrapper)[0].trigger('dragstart');
+ await rows(wrapper)[2].trigger('dragover');
+
+ expect(lastModel(wrapper)).toEqual([2, 3, 1]);
+ });
+ });
+
+ describe('loading state', () => {
+ it('shows skeleton rows when loading a non-empty, closed selection', () => {
+ const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
+
+ const skeleton = wrapper.find('[aria-busy="true"]');
+ expect(skeleton.exists()).toBe(true);
+ expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
+ });
+
+ it('does not show skeletons when the selection is empty', () => {
+ const wrapper = mountSelect({ modelValue: [], loading: true });
+
+ expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
+ });
+
+ it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
+ // Open first (trigger is enabled), then a live search turns loading on.
+ const wrapper = mountSelect({ modelValue: [1, 2] });
+ await addTrigger(wrapper).trigger('click');
+
+ await wrapper.setProps({ loading: true });
+
+ expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
+ expect(rows(wrapper)).toHaveLength(2);
+ });
+
+ it('forwards loading to the dropdown and disables the closed trigger', () => {
+ const wrapper = mountSelect({ modelValue: [1], loading: true });
+
+ expect(dropdown(wrapper).props('loading')).toBe(true);
+ expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
+ });
+ });
+});
diff --git a/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js b/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js
new file mode 100644
index 000000000..1651f8e6d
--- /dev/null
+++ b/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js
@@ -0,0 +1,120 @@
+import { effectScope } from 'vue';
+import { useAbortableRequest } from '../useAbortableRequest';
+
+// Resolves when the request "completes", rejects like axios does when the
+// signal is aborted mid-flight.
+const abortableRunner =
+ (value, { fail = false } = {}) =>
+ signal =>
+ new Promise((resolve, reject) => {
+ signal.addEventListener('abort', () => {
+ const error = new Error('canceled');
+ error.name = 'CanceledError';
+ reject(error);
+ });
+ // Defer so a follow-up `run`/`abort` can supersede this one first.
+ Promise.resolve().then(() => {
+ if (signal.aborted) return;
+ if (fail) {
+ reject(new Error('boom'));
+ return;
+ }
+ resolve(value);
+ });
+ });
+
+describe('useAbortableRequest', () => {
+ it('passes a fresh signal to the runner and returns its result', async () => {
+ const { run } = useAbortableRequest();
+ let received = null;
+
+ const result = await run(signal => {
+ received = signal;
+ return Promise.resolve('ok');
+ });
+
+ expect(received).toBeInstanceOf(AbortSignal);
+ expect(received.aborted).toBe(false);
+ expect(result).toBe('ok');
+ });
+
+ it('toggles isPending around the request', async () => {
+ const { run, isPending } = useAbortableRequest();
+ expect(isPending.value).toBe(false);
+
+ const pending = run(() => Promise.resolve('done'));
+ expect(isPending.value).toBe(true);
+
+ await pending;
+ expect(isPending.value).toBe(false);
+ });
+
+ it('aborts the previous request when a new one starts', async () => {
+ const { run } = useAbortableRequest();
+
+ const first = run(abortableRunner('first'));
+ const second = run(abortableRunner('second'));
+
+ await expect(first).resolves.toBeUndefined();
+ await expect(second).resolves.toBe('second');
+ });
+
+ it('returns the onAbort value when a request is superseded', async () => {
+ const { run } = useAbortableRequest();
+
+ const first = run(abortableRunner('first'), { onAbort: null });
+ const second = run(abortableRunner('second'));
+
+ await expect(first).resolves.toBeNull();
+ await expect(second).resolves.toBe('second');
+ });
+
+ it('abort cancels the in-flight request and clears isPending', async () => {
+ const { run, abort, isPending } = useAbortableRequest();
+
+ const pending = run(abortableRunner('value'));
+ expect(isPending.value).toBe(true);
+
+ abort();
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(isPending.value).toBe(false);
+ });
+
+ it('rethrows non-abort errors and clears isPending', async () => {
+ const { run, isPending } = useAbortableRequest();
+
+ await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow(
+ 'boom'
+ );
+ expect(isPending.value).toBe(false);
+ });
+
+ it('aborts the in-flight request when its scope is disposed', async () => {
+ const scope = effectScope();
+ let request;
+ scope.run(() => {
+ request = useAbortableRequest();
+ });
+
+ const pending = request.run(abortableRunner('value'));
+ expect(request.isPending.value).toBe(true);
+
+ scope.stop();
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(request.isPending.value).toBe(false);
+ });
+
+ it('keeps separate controllers per instance', async () => {
+ const a = useAbortableRequest();
+ const b = useAbortableRequest();
+
+ const first = a.run(abortableRunner('a'));
+ // Starting b's request must not abort a's.
+ const second = b.run(abortableRunner('b'));
+
+ await expect(first).resolves.toBe('a');
+ await expect(second).resolves.toBe('b');
+ });
+});
diff --git a/app/javascript/dashboard/composables/useAbortableRequest.js b/app/javascript/dashboard/composables/useAbortableRequest.js
new file mode 100644
index 000000000..c6e33367a
--- /dev/null
+++ b/app/javascript/dashboard/composables/useAbortableRequest.js
@@ -0,0 +1,62 @@
+import { getCurrentScope, onScopeDispose, ref } from 'vue';
+
+export const isAbortError = error =>
+ error?.name === 'AbortError' ||
+ error?.name === 'CanceledError' ||
+ error?.code === 'ERR_CANCELED';
+
+/**
+ * Keeps only the latest request alive. Starting a new `run` (or calling
+ * `abort`) cancels the previous request through its `AbortSignal`, so
+ * out-of-order responses can never overwrite fresher data.
+ *
+ * @example
+ * const { run, abort, isPending } = useAbortableRequest();
+ * const results = await run(signal => api.search(query, { signal }));
+ *
+ * @returns {{
+ * run: (runner: (signal: AbortSignal) => Promise, options?: { onAbort?: any }) => Promise,
+ * abort: () => void,
+ * isPending: import('vue').Ref,
+ * }}
+ * `run` resolves with the runner's value, or `options.onAbort` (default
+ * `undefined`) when the request was superseded. Non-abort errors are rethrown.
+ */
+export function useAbortableRequest() {
+ const isPending = ref(false);
+ let controller = null;
+
+ const abort = () => {
+ controller?.abort();
+ controller = null;
+ isPending.value = false;
+ };
+
+ const run = async (runner, { onAbort } = {}) => {
+ controller?.abort();
+ const currentController = new AbortController();
+ controller = currentController;
+ isPending.value = true;
+
+ try {
+ return await runner(currentController.signal);
+ } catch (error) {
+ if (currentController.signal.aborted || isAbortError(error))
+ return onAbort;
+ throw error;
+ } finally {
+ // Only the latest run owns the shared state; a superseded run leaves it
+ // for the run that replaced it.
+ if (controller === currentController) {
+ controller = null;
+ isPending.value = false;
+ }
+ }
+ };
+
+ // Cancel any in-flight request when the owning scope is disposed.
+ // Guarded so the composable can also be used outside an effect scope.
+ if (getCurrentScope()) onScopeDispose(abort);
+
+ return { run, abort, isPending };
+}
diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js
index 89f13f8cd..2d5272dde 100644
--- a/app/javascript/dashboard/helper/portalHelper.js
+++ b/app/javascript/dashboard/helper/portalHelper.js
@@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = {
value: 'customize-content',
icon: 'i-lucide-pencil',
},
+ selectPopularContent: {
+ label:
+ 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT',
+ action: 'select-popular-content',
+ value: 'select-popular-content',
+ icon: 'i-lucide-sparkles',
+ },
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
@@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
LOCALE_MENU_ITEMS.moveToDraft,
]),
LOCALE_MENU_ITEMS.customizeContent,
+ LOCALE_MENU_ITEMS.selectPopularContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
}
@@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
return [
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
+ LOCALE_MENU_ITEMS.selectPopularContent,
LOCALE_MENU_ITEMS.delete,
];
}
@@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
+ LOCALE_MENU_ITEMS.selectPopularContent,
LOCALE_MENU_ITEMS.delete,
];
};
diff --git a/app/javascript/dashboard/helper/specs/portalHelper.spec.js b/app/javascript/dashboard/helper/specs/portalHelper.spec.js
index e8d200518..1a6316520 100644
--- a/app/javascript/dashboard/helper/specs/portalHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/portalHelper.spec.js
@@ -74,26 +74,34 @@ describe('PortalHelper', () => {
});
describe('buildLocaleMenuItems', () => {
- it('disables other actions but keeps customize enabled for the default locale', () => {
+ it('disables other actions but keeps content actions enabled for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
- const customize = items.find(item => item.action === 'customize-content');
+ const enabledActions = ['customize-content', 'select-popular-content'];
- expect(customize).toBeTruthy();
- expect(customize.disabled).toBeFalsy();
+ enabledActions.forEach(action => {
+ expect(
+ items.find(item => item.action === action)?.disabled
+ ).toBeFalsy();
+ });
expect(
items
- .filter(item => item.action !== 'customize-content')
+ .filter(item => !enabledActions.includes(item.action))
.every(item => item.disabled)
).toBe(true);
});
- it('returns publish, customize, and delete actions for draft locales', () => {
+ it('returns publish, customize, popular content, and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
- ).toEqual(['publish-locale', 'customize-content', 'delete']);
+ ).toEqual([
+ 'publish-locale',
+ 'customize-content',
+ 'select-popular-content',
+ 'delete',
+ ]);
});
it('returns default, draft, customize, and delete actions for live locales', () => {
@@ -106,6 +114,7 @@ describe('PortalHelper', () => {
'change-default',
'move-to-draft',
'customize-content',
+ 'select-popular-content',
'delete',
]);
});
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index 53a745dc2..e69853f7a 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -720,9 +720,33 @@
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
+ "SELECT_POPULAR_CONTENT": "Select recommended content",
"DELETE": "Delete"
}
},
+ "POPULAR_CONTENT_DIALOG": {
+ "TITLE": "Recommended content",
+ "DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.",
+ "SEARCH": "Search...",
+ "EMPTY": "No matching results",
+ "ADD_ANOTHER": "Add another...",
+ "SLOTS_LEFT": "{count} slots left",
+ "OVERRIDING_DEFAULTS": "Overriding defaults for this locale",
+ "CONFIRM": "Save recommendations",
+ "CATEGORIES": {
+ "LABEL": "Recommended categories",
+ "ARTICLES_COUNT": "No articles | {count} article | {count} articles"
+ },
+ "ARTICLES": {
+ "LABEL": "Recommended articles",
+ "IN_CATEGORY": "in {category}",
+ "UNCATEGORIZED": "Uncategorized"
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "Recommended content updated successfully",
+ "ERROR_MESSAGE": "Unable to update recommended content. Try again."
+ }
+ },
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb
index de338b830..7e506a076 100644
--- a/app/models/concerns/portal_config_schema.rb
+++ b/app/models/concerns/portal_config_schema.rb
@@ -14,6 +14,18 @@ module PortalConfigSchema
'additionalProperties' => false
}.freeze
+ # Per-locale recommended content for the portal home page: an ordered list of
+ # `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the
+ # "Recommended" articles section). When empty, the portal uses its defaults.
+ POPULAR_CONTENT_SCHEMA = {
+ 'type' => 'object',
+ 'properties' => {
+ 'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } },
+ 'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }
+ },
+ 'additionalProperties' => false
+ }.freeze
+
CONFIG_PARAMS_SCHEMA = {
'type' => 'object',
'properties' => {
@@ -27,6 +39,10 @@ module PortalConfigSchema
'locale_translations' => {
'type' => %w[object null],
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
+ },
+ 'popular_content' => {
+ 'type' => %w[object null],
+ 'additionalProperties' => POPULAR_CONTENT_SCHEMA
}
},
'required' => [],
diff --git a/app/models/portal.rb b/app/models/portal.rb
index 9d2da6965..b1f387b49 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -53,7 +53,12 @@ class Portal < ApplicationRecord
scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
- CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
+ CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
+ popular_content].freeze
+
+ # Max number of recommended categories/articles shown per locale.
+ POPULAR_CATEGORY_LIMIT = 3
+ POPULAR_ARTICLE_LIMIT = 6
def file_base_data
{
@@ -115,6 +120,14 @@ class Portal < ApplicationRecord
config_value('layout').presence || 'classic'
end
+ def popular_category_ids(locale = default_locale)
+ Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT)
+ end
+
+ def popular_article_ids(locale = default_locale)
+ Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT)
+ end
+
def social_profiles
config_value('social_profiles') || {}
end
diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
index 93626ee36..2e4e78f1c 100644
--- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder
+++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
@@ -19,6 +19,7 @@ json.config do
json.layout portal.layout
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
+ json.popular_content portal.config['popular_content'] || {}
end
if portal.channel_web_widget
diff --git a/app/views/layouts/_portal_scripts.html.erb b/app/views/layouts/_portal_scripts.html.erb
index b1479cace..2df4a02fd 100644
--- a/app/views/layouts/_portal_scripts.html.erb
+++ b/app/views/layouts/_portal_scripts.html.erb
@@ -67,6 +67,11 @@ html.light {
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
+/* Recommended topic pills in the classic hero */
+.recommended-pill:hover {
+ border-color: var(--dynamic-hover-color);
+ color: var(--dynamic-hover-color);
+}
+
+
+
+
+
+
+ {{ contactName }}
+
+
+
+
+ {{ call.conversation.displayId }}
+
+
+
+
+
+
+ {{ agentActionLabel }}
+
+
+
+ {{ call.agent.name }}
+
+
+
+ {{ resultLabel }}
+
+
+
+
+ {{ call.inbox.name }}
+
+
+ {{ createdAtLabel }}
+
+
+
+
+
+ {{ createdAtLabel }}
+
+
+
+
+
+
+
+
+ {{ contactName }}
+
+
+
+
+
+
+
+ {{ agentActionLabel }}
+
+
+
+
+ {{ call.agent.name }}
+
+
+
+
+ {{ resultLabel }}
+
+
+
+
+
+
+
+ {{ call.inbox.name }}
+
+
+
+
+ {{ call.conversation.displayId }}
+
+
+
+ {{ createdAtLabel }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue
new file mode 100644
index 000000000..6eaa81b7b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayedTime }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue
new file mode 100644
index 000000000..b0da9c41e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+ {{
+ t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
+ }}
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue
new file mode 100644
index 000000000..f57d154eb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
new file mode 100644
index 000000000..4d01b0500
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+ {{
+ totalCount === null
+ ? t('CALLS_PAGE.ALL_CALLS')
+ : t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
+ }}
+
+
+ {{ activeChipLabel }}
+
+
+
+
+
+
+ {{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
+
+
+
+
+
+
+
+
+ {{ selectedAssigneeLabel }}
+
+
+
+
+
+
+ {{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js
new file mode 100644
index 000000000..f7399bfef
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Calls/constants.js
@@ -0,0 +1,51 @@
+import {
+ VOICE_CALL_STATUS,
+ VOICE_CALL_DIRECTION,
+} from 'dashboard/components-next/message/constants';
+
+export const CALL_KIND = {
+ ONGOING: 'ongoing',
+ INCOMING: 'incoming',
+ OUTGOING: 'outgoing',
+ MISSED: 'missed',
+ NO_REPLY: 'no_reply',
+ FAILED: 'failed',
+};
+
+// The API returns display values: status (ringing/in-progress/completed/
+// no-answer/failed) and direction (inbound/outbound). The list UI presents
+// them as a single "kind" per row.
+export const getCallKind = call => {
+ if (
+ [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
+ call.status
+ )
+ ) {
+ return CALL_KIND.ONGOING;
+ }
+ if (
+ [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
+ ) {
+ return CALL_KIND.FAILED;
+ }
+ const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
+ if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
+ return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
+ }
+ return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
+};
+
+// Filter chips map to the status/direction params supported by CallFinder.
+export const CALL_ACTIVITY_PARAMS = {
+ missed: {
+ status: VOICE_CALL_STATUS.NO_ANSWER,
+ direction: VOICE_CALL_DIRECTION.INBOUND,
+ },
+ no_reply: {
+ status: VOICE_CALL_STATUS.NO_ANSWER,
+ direction: VOICE_CALL_DIRECTION.OUTBOUND,
+ },
+ incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
+ outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
+ in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
+};
diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
new file mode 100644
index 000000000..c716ffbfb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayedTime }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 294e0dd5d..909a44c69 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -2,6 +2,7 @@
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
+import { useConfig } from 'dashboard/composables/useConfig';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
@@ -43,7 +44,14 @@ const emit = defineEmits([
]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
+const { isEnterprise } = useConfig();
const store = useStore();
+
+// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
+// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
+const isCallsAvailable = computed(
+ () => isOnChatwootCloud.value || isEnterprise
+);
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
},
],
},
+ ...(isCallsAvailable.value
+ ? [
+ {
+ name: 'Calls',
+ label: t('SIDEBAR.CALLS'),
+ icon: 'i-lucide-phone',
+ to: accountScopedRoute('calls_dashboard_index'),
+ activeOn: ['calls_dashboard_index'],
+ },
+ ]
+ : []),
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
diff --git a/app/javascript/dashboard/i18n/locale/en/calls.json b/app/javascript/dashboard/i18n/locale/en/calls.json
new file mode 100644
index 000000000..0ca5441f0
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/en/calls.json
@@ -0,0 +1,45 @@
+{
+ "CALLS_PAGE": {
+ "HEADER": "Calls",
+ "ALL_CALLS": "All Calls",
+ "ALL_CALLS_COUNT": "All Calls ({count})",
+ "EMPTY_STATE": "No calls found",
+ "SETUP": {
+ "TITLE": "Make and receive calls in one place",
+ "SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.",
+ "ACTION": "Set up voice channel"
+ },
+ "FILTERS": {
+ "MISSED": "Missed",
+ "NO_REPLY": "No reply",
+ "OTHER_ACTIVITY": "Other activity",
+ "INCOMING": "Incoming",
+ "OUTGOING": "Outgoing",
+ "IN_PROGRESS": "In progress",
+ "ASSIGNEE": "Assignee",
+ "ALL_ASSIGNEES": "All assignees",
+ "MORE_FILTERS": "More filters",
+ "INBOX": "Inbox",
+ "ALL_INBOXES": "All inboxes"
+ },
+ "STATUS": {
+ "ONGOING": "Ongoing",
+ "INCOMING": "Incoming",
+ "OUTGOING": "Outgoing",
+ "MISSED": "Missed",
+ "NO_REPLY": "No reply",
+ "FAILED": "Failed"
+ },
+ "ROW": {
+ "PICKED_BY": "Picked by",
+ "DIALED_BY": "Dialed by",
+ "ANSWERED": "Answered",
+ "RINGING": "Ringing",
+ "IN_PROGRESS": "In progress",
+ "NO_AGENT": "No agent answered this call",
+ "NO_CONTACT_ANSWER": "Contact did not answer",
+ "FAILED": "This call could not be connected",
+ "YESTERDAY": "Yesterday"
+ }
+ }
+}
diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js
index 12db16ba7..990ab9835 100644
--- a/app/javascript/dashboard/i18n/locale/en/index.js
+++ b/app/javascript/dashboard/i18n/locale/en/index.js
@@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json';
import auditLogs from './auditLogs.json';
import automation from './automation.json';
import bulkActions from './bulkActions.json';
+import calls from './calls.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
@@ -51,6 +52,7 @@ export default {
...auditLogs,
...automation,
...bulkActions,
+ ...calls,
...campaign,
...cannedMgmt,
...chatlist,
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index ceb0438b1..c013a177f 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -324,6 +324,7 @@
"COMPANIES": "Companies",
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
+ "CALLS": "Calls",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
new file mode 100644
index 000000000..80f8e08c5
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CALLS_PAGE.EMPTY_STATE') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js
new file mode 100644
index 000000000..fa952a749
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js
@@ -0,0 +1,22 @@
+import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
+import {
+ CONVERSATION_PERMISSIONS,
+ ROLES,
+} from 'dashboard/constants/permissions';
+import { frontendURL } from '../../../helper/URLHelper';
+import CallsIndex from './pages/CallsIndex.vue';
+
+export const routes = [
+ {
+ path: frontendURL('accounts/:accountId/calls'),
+ name: 'calls_dashboard_index',
+ component: CallsIndex,
+ meta: {
+ permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
+ installationTypes: [
+ INSTALLATION_TYPES.CLOUD,
+ INSTALLATION_TYPES.ENTERPRISE,
+ ],
+ },
+ },
+];
diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js
index 04d11c621..4611aad38 100644
--- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js
@@ -1,6 +1,7 @@
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
+import { routes as callRoutes } from './calls/routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
@@ -25,6 +26,7 @@ export default {
...inboxRoutes,
...conversation.routes,
...settings.routes,
+ ...callRoutes,
...contactRoutes,
...companyRoutes,
...searchRoutes,
diff --git a/app/javascript/dashboard/stores/callHistory.js b/app/javascript/dashboard/stores/callHistory.js
new file mode 100644
index 000000000..c0a2c0fa9
--- /dev/null
+++ b/app/javascript/dashboard/stores/callHistory.js
@@ -0,0 +1,40 @@
+import camelcaseKeys from 'camelcase-keys';
+import CallsAPI from 'dashboard/api/calls';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+import { defineStore } from 'pinia';
+
+export const useCallHistoryStore = defineStore('callHistory', {
+ state: () => ({
+ records: [],
+ meta: { count: 0, currentPage: 1, totalPages: 0 },
+ uiFlags: { isFetching: false },
+ fetchRequestToken: 0,
+ }),
+
+ actions: {
+ async fetchCalls(params = {}) {
+ this.uiFlags.isFetching = true;
+ this.fetchRequestToken += 1;
+ const requestToken = this.fetchRequestToken;
+ try {
+ const { data } = await CallsAPI.get(params);
+ // A newer fetch (filter/page change) superseded this one; drop the result.
+ if (this.fetchRequestToken !== requestToken) return this.records;
+ this.records = camelcaseKeys(data.payload, { deep: true });
+ this.meta = camelcaseKeys(data.meta);
+ return this.records;
+ } catch (error) {
+ // Don't surface errors from a fetch that a newer request already replaced.
+ if (this.fetchRequestToken !== requestToken) return this.records;
+ // Drop the previous results so stale rows aren't shown under the new view.
+ this.records = [];
+ this.meta = { count: 0, currentPage: 1, totalPages: 0 };
+ return throwErrorMessage(error);
+ } finally {
+ if (this.fetchRequestToken === requestToken) {
+ this.uiFlags.isFetching = false;
+ }
+ }
+ },
+ },
+});
diff --git a/app/javascript/dashboard/stores/specs/callHistory.spec.js b/app/javascript/dashboard/stores/specs/callHistory.spec.js
new file mode 100644
index 000000000..834ae8ab5
--- /dev/null
+++ b/app/javascript/dashboard/stores/specs/callHistory.spec.js
@@ -0,0 +1,117 @@
+import { setActivePinia, createPinia } from 'pinia';
+import CallsAPI from 'dashboard/api/calls';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+import { useCallHistoryStore } from '../callHistory';
+
+vi.mock('dashboard/api/calls', () => ({
+ default: {
+ get: vi.fn(),
+ },
+}));
+
+vi.mock('dashboard/store/utils/api', () => ({
+ throwErrorMessage: vi.fn(error => error),
+}));
+
+const createDeferred = () => {
+ let resolve;
+ const promise = new Promise(res => {
+ resolve = res;
+ });
+
+ return { promise, resolve };
+};
+
+const buildResponse = (payload, meta) => ({ data: { payload, meta } });
+
+describe('callHistory store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.clearAllMocks();
+ });
+
+ it('fetches calls and stores camelized records and meta', async () => {
+ CallsAPI.get.mockResolvedValue(
+ buildResponse(
+ [{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }],
+ { count: 44, current_page: 1, total_pages: 2 }
+ )
+ );
+ const store = useCallHistoryStore();
+
+ await store.fetchCalls({ page: 1, status: 'no-answer' });
+
+ expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' });
+ expect(store.records).toEqual([
+ { id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } },
+ ]);
+ expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 });
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+
+ it('drops a superseded response that resolves after the latest one', async () => {
+ const firstRequest = createDeferred();
+ const secondRequest = createDeferred();
+ CallsAPI.get
+ .mockImplementationOnce(() => firstRequest.promise)
+ .mockImplementationOnce(() => secondRequest.promise);
+ const store = useCallHistoryStore();
+
+ const staleFetch = store.fetchCalls({ page: 1 });
+ const currentFetch = store.fetchCalls({ page: 2 });
+
+ secondRequest.resolve(
+ buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
+ );
+ await currentFetch;
+
+ firstRequest.resolve(
+ buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
+ );
+ await staleFetch;
+
+ expect(store.records).toEqual([{ id: 2 }]);
+ expect(store.meta.count).toBe(1);
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+
+ it('keeps fetching state when a superseded response resolves first', async () => {
+ const firstRequest = createDeferred();
+ const secondRequest = createDeferred();
+ CallsAPI.get
+ .mockImplementationOnce(() => firstRequest.promise)
+ .mockImplementationOnce(() => secondRequest.promise);
+ const store = useCallHistoryStore();
+
+ const staleFetch = store.fetchCalls({ page: 1 });
+ const currentFetch = store.fetchCalls({ page: 2 });
+
+ firstRequest.resolve(
+ buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
+ );
+ await staleFetch;
+
+ expect(store.records).toEqual([]);
+ expect(store.uiFlags.isFetching).toBe(true);
+
+ secondRequest.resolve(
+ buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
+ );
+ await currentFetch;
+
+ expect(store.records).toEqual([{ id: 2 }]);
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+
+ it('surfaces the error and resets fetching state on failure', async () => {
+ const error = new Error('Request failed');
+ CallsAPI.get.mockRejectedValue(error);
+ const store = useCallHistoryStore();
+
+ await store.fetchCalls();
+
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(store.records).toEqual([]);
+ expect(store.uiFlags.isFetching).toBe(false);
+ });
+});
diff --git a/app/javascript/dashboard/stores/companies.spec.js b/app/javascript/dashboard/stores/specs/companies.spec.js
similarity index 99%
rename from app/javascript/dashboard/stores/companies.spec.js
rename to app/javascript/dashboard/stores/specs/companies.spec.js
index 1c44d4292..98d9fd9b6 100644
--- a/app/javascript/dashboard/stores/companies.spec.js
+++ b/app/javascript/dashboard/stores/specs/companies.spec.js
@@ -1,6 +1,6 @@
import { setActivePinia, createPinia } from 'pinia';
import CompanyAPI from 'dashboard/api/companies';
-import { useCompaniesStore } from './companies';
+import { useCompaniesStore } from '../companies';
vi.mock('dashboard/api/companies', () => ({
default: {
diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js
index d12a42bc8..8a4b50b17 100644
--- a/app/javascript/shared/helpers/specs/timeHelper.spec.js
+++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js
@@ -1,11 +1,12 @@
import {
- messageStamp,
- messageTimestamp,
- dynamicTime,
dateFormat,
- shortTimestamp,
+ dynamicTime,
getDayDifferenceFromNow,
hasOneDayPassed,
+ messageStamp,
+ messageTimestamp,
+ relativeDayTimestamp,
+ shortTimestamp,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -37,6 +38,33 @@ describe('#messageTimestamp', () => {
});
});
+describe('#relativeDayTimestamp', () => {
+ // System time is mocked to May 5, 2023 00:00 UTC.
+ const toUnix = date => Math.floor(date / 1000);
+
+ it('returns the time for timestamps from today', () => {
+ const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0));
+ expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM');
+ });
+
+ it('returns the supplied label for timestamps from yesterday', () => {
+ const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0));
+ expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday');
+ });
+
+ it('returns a day and month for older timestamps in the current year', () => {
+ const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0));
+ expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual(
+ 'Feb 10'
+ );
+ });
+
+ it('returns a full date for timestamps from a previous year', () => {
+ const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0));
+ expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021');
+ });
+});
+
describe('#dynamicTime', () => {
it('returns correct value', () => {
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js
index db5609d89..cde5ccf89 100644
--- a/app/javascript/shared/helpers/timeHelper.js
+++ b/app/javascript/shared/helpers/timeHelper.js
@@ -1,6 +1,9 @@
import {
format,
isSameYear,
+ isThisYear,
+ isToday,
+ isYesterday,
fromUnixTime,
formatDistanceToNow,
differenceInDays,
@@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
return messageDate;
};
+/**
+ * Formats a Unix timestamp relative to today: the time for today, a caller-
+ * supplied label for yesterday, and a date otherwise. The yesterday label is
+ * passed in so the caller keeps ownership of translation.
+ * @param {number} time - Unix timestamp.
+ * @param {string} yesterdayLabel - Localized label shown for yesterday.
+ * @returns {string} Formatted timestamp string.
+ */
+export const relativeDayTimestamp = (time, yesterdayLabel) => {
+ const date = fromUnixTime(time);
+ if (isToday(date)) return format(date, 'h:mm a');
+ if (isYesterday(date)) return yesterdayLabel;
+ if (isThisYear(date)) return format(date, 'MMM d');
+ return format(date, 'MMM d, yyyy');
+};
+
/**
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
* @param {number} time - Unix timestamp.
From 08f49f5896f8d2959c10c2434553705e5668570d Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:28:48 +0530
Subject: [PATCH 126/143] fix: prevent channel list crash on hard reload
(#15074)
---
.../routes/dashboard/settings/inbox/ChannelList.vue | 12 ++----------
1 file changed, 2 insertions(+), 10 deletions(-)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
index de0c6059b..fd509d350 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
@@ -1,5 +1,5 @@
From 7a299307b882aaf86af2f336cc77a688124c4f13 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 20 Jul 2026 19:28:58 +0530
Subject: [PATCH 127/143] fix: apply installation name to sender name preview
(#15076)
---
.../inbox/components/SenderNameExamplePreview.vue | 8 +++++---
.../shared/composables/specs/useBranding.spec.js | 6 +++---
app/javascript/shared/composables/useBranding.js | 5 +++--
3 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue
index abd2552bf..b08d282aa 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
+import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({
senderNameType: {
@@ -22,6 +23,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
+const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [
{
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
- businessName: 'Chatwoot',
+ businessName: replaceInstallationName('Chatwoot'),
email: '',
},
},
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
- businessName: 'Chatwoot',
+ businessName: replaceInstallationName('Chatwoot'),
email: '',
},
},
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
- : keyOption.preview.businessName;
+ : props.businessName || keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
diff --git a/app/javascript/shared/composables/specs/useBranding.spec.js b/app/javascript/shared/composables/specs/useBranding.spec.js
index 6f3f06b67..4477ac052 100644
--- a/app/javascript/shared/composables/specs/useBranding.spec.js
+++ b/app/javascript/shared/composables/specs/useBranding.spec.js
@@ -73,13 +73,13 @@ describe('useBranding', () => {
expect(result).toBe('Welcome to our platform');
});
- it('should be case-sensitive for "Chatwoot"', () => {
+ it('should replace "Chatwoot" regardless of casing', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
- 'Welcome to chatwoot and CHATWOOT'
+ 'Welcome to chatwoot, Chatwoot and CHATWOOT'
);
- expect(result).toBe('Welcome to chatwoot and CHATWOOT');
+ expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
});
it('should handle special characters in installation name', () => {
diff --git a/app/javascript/shared/composables/useBranding.js b/app/javascript/shared/composables/useBranding.js
index fccdcd32b..d9be3e696 100644
--- a/app/javascript/shared/composables/useBranding.js
+++ b/app/javascript/shared/composables/useBranding.js
@@ -7,7 +7,8 @@ import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
- * Replaces "Chatwoot" in text with the installation name from global config
+ * Replaces "Chatwoot" (any casing) in text with the installation name from
+ * global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
@@ -17,7 +18,7 @@ export function useBranding() {
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
- return text.replace(/Chatwoot/g, installationName);
+ return text.replace(/chatwoot/gi, installationName);
};
return {
From 160732c07d6b23d8ea2bea1114b4a411c5b50b8d Mon Sep 17 00:00:00 2001
From: Sony Mathew
Date: Mon, 20 Jul 2026 20:41:39 +0530
Subject: [PATCH 128/143] fix: rate limit agent management APIs (#15081)
# Pull Request Template
Bring agent create and delete requests under rack attack throttling
Related to https://linear.app/chatwoot/issue/CW-7637
Co-authored-by: Vishnu Narayanan
---
config/initializers/rack_attack.rb | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index e08a6a6e1..41ccae971 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -212,6 +212,24 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
+ ## Prevent abuse of agent create APIs (per account, covers bulk_create)
+ throttle('/api/v1/accounts/:account_id/agents POST',
+ limit: ENV.fetch('RATE_LIMIT_AGENT_CREATE', '100').to_i, period: 1.day) do |req|
+ next unless req.post?
+
+ match_data = %r{\A/api/v1/accounts/(?\d+)/agents(?:/bulk_create)?/?\z}.match(req.path_without_extensions)
+ match_data[:account_id] if match_data.present?
+ end
+
+ ## Prevent abuse of agent delete API (per account)
+ throttle('/api/v1/accounts/:account_id/agents/:id DELETE',
+ limit: ENV.fetch('RATE_LIMIT_AGENT_DELETE', '50').to_i, period: 1.day) do |req|
+ next unless req.delete?
+
+ match_data = %r{\A/api/v1/accounts/(?\d+)/agents/(?\d+)/?\z}.match(req.path_without_extensions)
+ match_data[:account_id] if match_data.present?
+ end
+
## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?\d+)/upload}.match(req.path)
From eae9841eb438bd0611729a8a545b04ac138abcb6 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Mon, 20 Jul 2026 15:28:33 -0700
Subject: [PATCH 129/143] fix: restore token access to account APIs (#15088)
Token-authenticated requests to Agent Bots, Labels, and affected Captain
endpoints return normal responses again. The regression was caused by
duplicate `current_account` callbacks in subclasses moving account
resolution behind the API entitlement check, leaving `Current.account`
unset.
## Closes
- https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis
## How to reproduce
1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid
administrator API access token.
2. Observe a `500` from `validate_token_api_access` because
`Current.account` is `nil`.
3. With this change, account resolution runs in the base-controller
order and the request succeeds.
## What changed
- Removed redundant `current_account` callbacks from account-scoped
controllers that already inherit the callback from
`Api::V1::Accounts::BaseController`.
- Kept the standalone direct-upload controller callback unchanged.
- Added regression coverage for administrator API-token access to Agent
Bots.
---
app/controllers/api/v1/accounts/agent_bots_controller.rb | 1 -
.../api/v1/accounts/captain/preferences_controller.rb | 1 -
app/controllers/api/v1/accounts/labels_controller.rb | 1 -
.../v1/accounts/captain/assistant_responses_controller.rb | 1 -
.../api/v1/accounts/captain/assistants_controller.rb | 1 -
.../api/v1/accounts/captain/bulk_actions_controller.rb | 1 -
.../api/v1/accounts/captain/custom_tools_controller.rb | 1 -
.../api/v1/accounts/captain/documents_controller.rb | 1 -
.../api/v1/accounts/captain/inboxes_controller.rb | 1 -
.../api/v1/accounts/captain/scenarios_controller.rb | 1 -
.../api/v1/accounts/agent_bots_controller_spec.rb | 8 ++++++++
11 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb
index de3d10081..3f0309024 100644
--- a/app/controllers/api/v1/accounts/agent_bots_controller.rb
+++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
index 482b001d6..3b7fafad5 100644
--- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb
index 6889d30a4..f678fee42 100644
--- a/app/controllers/api/v1/accounts/labels_controller.rb
+++ b/app/controllers/api/v1/accounts/labels_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
index 151cf279c..80e05e912 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index 4fbb93d20..8a6f158cf 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
index 7e2817f69..2bd7c8eed 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
index 874197870..f0222434c 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action :ensure_custom_tools_enabled
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
index d88cc6b48..cb6fb782d 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb
index f4ec303b6..88b8f2fb3 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
index 376cee0b3..fa7157dae 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
- before_action :current_account
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
diff --git a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb
index a98f787e0..6e1b03bac 100644
--- a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb
@@ -64,6 +64,14 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.access_token.token)
end
+
+ it 'supports API token authentication' do
+ get "/api/v1/accounts/#{account.id}/agent_bots",
+ headers: { api_access_token: admin.access_token.token },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
end
end
From 71fffdd2b91a405fbb70f35a7aa01c21f8862097 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Tue, 21 Jul 2026 04:39:29 +0530
Subject: [PATCH 130/143] fix: rate limit widget conversation transcript API
(#15085)
## Description
The widget conversation transcript endpoint (`POST
/api/v1/widget/conversations/transcript`) has no rate limit. Every other
comparable endpoint does: the agent-facing transcript API and the widget
conversation-create and contact-update endpoints are all throttled. This
gap lets a single client trigger a large burst of transcript emails from
one conversation.
This adds an IP-based throttle (5 requests/hour) for the endpoint,
placed inside the existing widget-API throttle block so it inherits the
`ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients.
The limit is generous for legitimate use (a visitor emailing themselves
a transcript) while stopping abusive loops. Throttled requests get the
standard 429 the widget already handles.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
`config/initializers/rack_attack.rb` throttles have no existing specs in
this file, so this follows the established convention (no new spec).
Verified `ruby -c` and `rubocop` pass on the file. The new throttle
mirrors the sibling widget throttles directly above it (same IP key,
path guard, and structure).
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
---------
Co-authored-by: Sojan Jose
---
config/initializers/rack_attack.rb | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 41ccae971..caf49c047 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -31,10 +31,11 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
- # Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
- # example /auth & /auth.json would both work
+ # Rails allows paths with extensions and trailing slashes, so compare against a normalized path.
+ # For example, /auth, /auth.json, and /auth/ should all use the same throttle.
def path_without_extensions
- path[/^[^.]+/]
+ normalized_path = path[/^[^.]+/]
+ normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
end
end
@@ -188,6 +189,11 @@ class Rack::Attack
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
+
+ ## Prevent Transcript Bombing on Widget API ###
+ throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
+ req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
+ end
end
##-----------------------------------------------##
From d1fa8d8c2f844e5868a8f327b73249d203624f3f Mon Sep 17 00:00:00 2001
From: Devi R
Date: Tue, 21 Jul 2026 11:38:23 +0530
Subject: [PATCH 131/143] refactor: share whatsapp/twilio template logic via
@chatwoot/utils (#15001)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
Moves the WhatsApp & Twilio content-template logic to the shared
[`@chatwoot/utils`](https://github.com/chatwoot/utils)
([PR](https://github.com/chatwoot/utils/pull/62)) package so web and
mobile share one implementation. The neutral core takes the raw template
and returns `processed_params`, the same shape the web parsers already
use, so it's a drop-in with no behavior change.
- `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`,
`findComponentByType`, `processVariable`, `buildTemplateParameters`,
`extractFilenameFromUrl` from the package
- `inboxes.js` → filters with shared `isSendableTemplate`
- `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` →
`isFormInvalid` and Twilio media helpers now use the shared
`isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename`
> Depends on the `@chatwoot/utils` release adding the shared template
API, bump `package.json` from `^0.0.55` to the published version before
merge.
Fixes
[CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils)
## Type of change
- [x] Breaking change (Refactor)
---------
Co-authored-by: Muhsin Keloth
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
---
.../ContentTemplateParser.vue | 78 +++++----------
.../whatsapp/WhatsAppTemplateParser.vue | 28 +-----
app/javascript/dashboard/helper/URLHelper.js | 21 +---
.../helper/specs/templateHelper.spec.js | 12 ++-
.../dashboard/helper/templateHelper.js | 96 +++----------------
.../dashboard/store/modules/inboxes.js | 42 +-------
package.json | 2 +-
pnpm-lock.yaml | 10 +-
8 files changed, 66 insertions(+), 223 deletions(-)
diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue
index 4d526d558..1390ea0f7 100644
--- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue
+++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
-import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
-import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
+import {
+ isTwilioComplete,
+ isTwilioMediaTemplate,
+ getTwilioMediaVariableKey,
+ getTwilioMediaUrl,
+ applyTwilioMediaFilename,
+} from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
return props.template.body || '';
});
-const hasMediaTemplate = computed(() => {
- return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
-});
+// Media-template detection and variable extraction are shared with the mobile
+// app via @chatwoot/utils.
+const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
-const mediaVariableKey = computed(() => {
- if (!hasMediaTemplate.value) return null;
- const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
- if (!mediaUrl) return null;
- return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
-});
+const mediaVariableKey = computed(() =>
+ getTwilioMediaVariableKey(props.template)
+);
-const hasMediaVariable = computed(() => {
- return hasMediaTemplate.value && mediaVariableKey.value !== null;
-});
+const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
-const templateMediaUrl = computed(() => {
- if (!hasMediaTemplate.value) return '';
-
- return props.template?.types?.['twilio/media']?.media?.[0] || '';
-});
+const templateMediaUrl = computed(() =>
+ hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
+);
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
return rendered;
});
-const isFormInvalid = computed(() => {
- if (!hasVariables.value && !hasMediaVariable.value) return false;
-
- if (hasVariables.value) {
- const hasEmptyVariable = variablePattern.value.some(
- variable => !processedParams.value[variable]
- );
- if (hasEmptyVariable) return true;
- }
-
- if (
- hasMediaVariable.value &&
- mediaVariableKey.value &&
- !processedParams.value[mediaVariableKey.value]
- ) {
- return true;
- }
-
- return false;
-});
+// Completeness validation is shared with the mobile app via @chatwoot/utils.
+const isFormInvalid = computed(
+ () => !isTwilioComplete(props.template, processedParams.value)
+);
const v$ = useVuelidate(
{
@@ -135,19 +117,11 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
- // Process parameters and extract filename from media URL if needed
- const processedParameters = { ...processedParams.value };
-
- // For media templates, extract filename from full URL
- if (
- hasMediaVariable.value &&
- mediaVariableKey.value &&
- processedParameters[mediaVariableKey.value]
- ) {
- processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
- processedParameters[mediaVariableKey.value]
- );
- }
+ // For media templates, reduce the media URL to a filename before sending.
+ const processedParameters = applyTwilioMediaFilename(
+ props.template,
+ processedParams.value
+ );
const payload = {
message: renderedTemplate.value,
diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
index 6df06642c..620955cb4 100644
--- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
+++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
+import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
-const isFormInvalid = computed(() => {
- if (!hasVariables.value && !hasMediaHeader.value) return false;
-
- if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
- return true;
- }
-
- if (hasVariables.value && processedParams.value.body) {
- const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
- value => !value
- );
- if (hasEmptyBodyVariable) return true;
- }
-
- if (processedParams.value.buttons) {
- const hasEmptyButtonParameter = processedParams.value.buttons.some(
- button => !button.parameter
- );
- if (hasEmptyButtonParameter) return true;
- }
-
- return false;
-});
+// Completeness validation is shared with the mobile app via @chatwoot/utils.
+const isFormInvalid = computed(
+ () => !isWhatsAppComplete(props.template, processedParams.value)
+);
const v$ = useVuelidate(
{
diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js
index 76a5d8bd4..8437e116f 100644
--- a/app/javascript/dashboard/helper/URLHelper.js
+++ b/app/javascript/dashboard/helper/URLHelper.js
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
}
};
-/**
- * Extracts filename from a URL
- * @param {string} url - The URL to extract filename from
- * @returns {string} - The extracted filename or original URL if extraction fails
- */
-export const extractFilenameFromUrl = url => {
- if (!url || typeof url !== 'string') return url;
-
- try {
- const urlObj = new URL(url);
- const pathname = urlObj.pathname;
- const filename = pathname.split('/').pop();
- return filename || url;
- } catch (error) {
- // If URL parsing fails, try to extract filename using regex
- const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
- return match ? match[1] : url;
- }
-};
+// Shared with the mobile app via @chatwoot/utils.
+export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Normalizes a comma/newline separated list of domains
diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
index 375e38a2d..ba056c317 100644
--- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
]);
});
- it('should handle templates with no variables', () => {
+ it('should handle templates with no variables but a media header', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
- const result = buildTemplateParameters(emptyTemplate, false);
- expect(result).toEqual({});
+ const result = buildTemplateParameters(emptyTemplate);
+ // hasMediaHeader is derived from the template, so the document header is kept.
+ expect(result.body).toBeUndefined();
+ expect(result.header).toEqual({
+ media_url: '',
+ media_type: 'document',
+ media_name: '',
+ });
});
it('should build parameters for templates with multiple component types', () => {
diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js
index 1fb61d760..c875871f4 100644
--- a/app/javascript/dashboard/helper/templateHelper.js
+++ b/app/javascript/dashboard/helper/templateHelper.js
@@ -1,19 +1,16 @@
-// Constants
+import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
+
+// Constants and pure template helpers are shared with the mobile app via
+// @chatwoot/utils so the logic lives in one place.
+export {
+ MEDIA_FORMATS,
+ COMPONENT_TYPES,
+ findComponentByType,
+ processVariable,
+} from '@chatwoot/utils';
+
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
-export const COMPONENT_TYPES = {
- HEADER: 'HEADER',
- BODY: 'BODY',
- BUTTONS: 'BUTTONS',
-};
-export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
-
-export const findComponentByType = (template, type) =>
- template.components?.find(component => component.type === type);
-
-export const processVariable = str => {
- return str.replace(/{{|}}/g, '');
-};
export const allKeysRequired = value => {
const keys = Object.keys(value);
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
});
};
-export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
- const allVariables = {};
-
- const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
- const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
-
- if (!bodyComponent) return allVariables;
-
- const templateString = bodyComponent.text;
-
- // Process body variables
- const matchedVariables = templateString.match(/{{([^}]+)}}/g);
- if (matchedVariables) {
- allVariables.body = {};
- matchedVariables.forEach(variable => {
- const key = processVariable(variable);
- allVariables.body[key] = '';
- });
- }
-
- if (hasMediaHeaderValue) {
- if (!allVariables.header) allVariables.header = {};
- allVariables.header.media_url = '';
- allVariables.header.media_type = headerComponent.format.toLowerCase();
-
- // For document templates, include media_name field for filename support
- if (headerComponent.format.toLowerCase() === 'document') {
- allVariables.header.media_name = '';
- }
- }
-
- // Process button variables
- const buttonComponents = template.components.filter(
- component => component.type === COMPONENT_TYPES.BUTTONS
- );
-
- buttonComponents.forEach(buttonComponent => {
- if (buttonComponent.buttons) {
- buttonComponent.buttons.forEach((button, index) => {
- // Handle URL buttons with variables
- if (button.type === 'URL' && button.url && button.url.includes('{{')) {
- const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
- if (buttonVars.length > 0) {
- if (!allVariables.buttons) allVariables.buttons = [];
- allVariables.buttons[index] = {
- type: 'url',
- parameter: '',
- url: button.url,
- variables: buttonVars.map(v => processVariable(v)),
- };
- }
- }
-
- // Handle copy code buttons
- if (button.type === 'COPY_CODE') {
- if (!allVariables.buttons) allVariables.buttons = [];
- allVariables.buttons[index] = {
- type: 'copy_code',
- parameter: '',
- };
- }
- });
- }
- });
-
- return allVariables;
-};
+// The media-header flag is derived from the template inside the shared helper;
+// the second argument is kept for backwards-compatible call sites.
+export const buildTemplateParameters = template =>
+ buildWhatsAppProcessedParams(template);
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index ce24ef653..64a29c215 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
+import { isSendableTemplate } from '@chatwoot/utils';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -67,45 +68,8 @@ export const getters = {
return [];
}
- return templates.filter(template => {
- // Ensure template has required properties
- if (!template || !template.status || !template.components) {
- return false;
- }
-
- // Only show approved templates
- if (template.status.toLowerCase() !== 'approved') {
- return false;
- }
-
- // Filter out authentication templates
- if (template.category === 'AUTHENTICATION') {
- return false;
- }
-
- // Filter out CSAT templates (customer_satisfaction_survey and its versions)
- if (
- template.name &&
- template.name.startsWith('customer_satisfaction_survey')
- ) {
- return false;
- }
-
- // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
- const hasUnsupportedComponents = template.components.some(
- component =>
- ['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
- component.type
- ) ||
- (component.type === 'HEADER' && component.format === 'LOCATION')
- );
-
- if (hasUnsupportedComponents) {
- return false;
- }
-
- return true;
- });
+ // Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
+ return templates.filter(isSendableTemplate);
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
diff --git a/package.json b/package.json
index bf00dde8a..c699787f2 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.22",
- "@chatwoot/utils": "^0.0.55",
+ "@chatwoot/utils": "^0.0.56",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ffb85c18..40f84477a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -28,8 +28,8 @@ importers:
specifier: 1.3.22
version: 1.3.22
'@chatwoot/utils':
- specifier: ^0.0.55
- version: 0.0.55
+ specifier: ^0.0.56
+ version: 0.0.56
'@formkit/core':
specifier: ^1.7.2
version: 1.7.2
@@ -464,8 +464,8 @@ packages:
'@chatwoot/prosemirror-schema@1.3.22':
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
- '@chatwoot/utils@0.0.55':
- resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
+ '@chatwoot/utils@0.0.56':
+ resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5154,7 +5154,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.55':
+ '@chatwoot/utils@0.0.56':
dependencies:
date-fns: 2.30.0
From ae49af354d018f17935f8824921c9c29c9d27948 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:04:12 +0530
Subject: [PATCH 132/143] fix: serialize multimodal Captain session content
(#15096)
Captain now saves agent session records when a user message includes an
image. The saved record keeps the image URL and excludes downloaded
image bytes, so image replies no longer report a JSON serialization
error after delivery.
Fixes:
https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue¬ification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945
## Root cause
RubyLLM downloads image attachments and caches the binary bytes inside
`RubyLLM::Content`. `SessionCaptureService` passed the live object to
the `run_context` JSON column. Rails then tried to encode the cached
JPEG bytes as UTF-8 and raised `JSON::GeneratorError`.
The error did not block replies, handoffs, or credit updates because
session capture rescues its own failures. The failed write meant that
Chatwoot lost the agent session record for the response.
## How to reproduce
1. Send an image to a Captain V2 assistant.
2. Let RubyLLM load the image during the model request.
3. Save the resulting conversation history in an agent session.
4. Observe the JSON encoding error when Rails reaches the cached image
bytes.
## What changed
`SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe
hash before saving the current turn. The hash contains the message text
and attachment URL without the cached bytes. Other message content is
unchanged.
The focused service spec covers a cached JPEG byte payload and passes
with 12 examples. RuboCop reports no offenses in the changed service and
spec.
---
.../assistant/session_capture_service.rb | 7 ++++++-
.../assistant/session_capture_service_spec.rb | 19 +++++++++++++++++++
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/enterprise/app/services/captain/assistant/session_capture_service.rb b/enterprise/app/services/captain/assistant/session_capture_service.rb
index 36af50308..ceeb28210 100644
--- a/enterprise/app/services/captain/assistant/session_capture_service.rb
+++ b/enterprise/app/services/captain/assistant/session_capture_service.rb
@@ -59,6 +59,11 @@ class Captain::Assistant::SessionCaptureService
def current_turn_history
history = Array(context[:conversation_history])
last_user_index = history.rindex { |message| message[:role].to_s == 'user' }
- last_user_index ? history[last_user_index..] : history
+ current_turn = last_user_index ? history[last_user_index..] : history
+
+ current_turn.map do |message|
+ content = message[:content]
+ content.is_a?(RubyLLM::Content) ? message.merge(content: content.to_h) : message
+ end
end
end
diff --git a/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb b/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb
index ef25b1e2b..daf38c8d6 100644
--- a/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb
+++ b/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb
@@ -111,6 +111,25 @@ RSpec.describe Captain::Assistant::SessionCaptureService do
expect(history.first).to include('role' => 'user', 'content' => 'CUST001')
end
+ it 'stores multimodal content without cached attachment bytes' do
+ content = RubyLLM::Content.new('See image', ['https://example.com/image.jpg'])
+ content.attachments.first.instance_variable_set(:@content, "\xFF\xD8\xFF\xE0JFIF".b)
+ run_context[:conversation_history] = [
+ { role: :user, content: content },
+ { role: :assistant, content: 'I can see the image', agent_name: 'Assistant' }
+ ]
+
+ history = service.capture!.run_context
+
+ expect(history.first).to include(
+ 'role' => 'user',
+ 'content' => {
+ 'text' => 'See image',
+ 'attachments' => [{ 'type' => 'image', 'source' => 'https://example.com/image.jpg' }]
+ }
+ )
+ end
+
it 'stores the full history when it contains no user message' do
run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user }
From 920a98ccf463ef390a2fefedb69f622054bf8cf1 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 21 Jul 2026 15:00:08 +0530
Subject: [PATCH 133/143] fix: calls dashboard load race (#15094)
---
.../components-next/Calls/CallListItem.vue | 7 +++--
.../dashboard/calls/pages/CallsIndex.vue | 30 ++++++++++++-------
2 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
index 8b0a56bfd..dd2b3af37 100644
--- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue
+++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue
@@ -25,8 +25,11 @@ const route = useRoute();
const kind = computed(() => getCallKind(props.call));
-const contactName = computed(
- () => props.call.contact.name || props.call.contact.phoneNumber
+const contactName = computed(() =>
+ (props.call.contact.name || props.call.contact.phoneNumber || '').replace(
+ /^\+/,
+ ''
+ )
);
const agentActionLabel = computed(() => {
diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
index 80f8e08c5..553cbceac 100644
--- a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
+++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue
@@ -1,5 +1,6 @@
From 7a5385cc32061c95ff28ec1d0af4fa2ddc269fcf Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 21 Jul 2026 15:14:18 +0530
Subject: [PATCH 134/143] feat: improve captain overview loading and reuse
stats for summary [CW-7610] (#15105)
---
.../dashboard/api/captain/assistant.js | 13 +++---
.../pageComponents/overview/MetricCard.vue | 7 ++-
.../pageComponents/overview/WelcomeCard.vue | 33 +++++++++++---
.../captain/assistants/overview/Index.vue | 45 ++++++++++++++++---
.../accounts/captain/assistants_controller.rb | 22 ++++++---
lib/captain/overview_summary_service.rb | 1 +
.../captain_overview_summary.liquid | 1 +
.../captain/assistants_controller_spec.rb | 14 +++++-
8 files changed, 112 insertions(+), 24 deletions(-)
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 1fc17798d..5af6110ab 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range }) {
- return axios.get(`${this.url}/${assistantId}/stats`, {
+ getStats({ assistantId, range, signal }) {
+ const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
- });
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
- getSummary({ assistantId, range }) {
+ getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
- params: { range, timezone_offset: getTimezoneOffset() },
+ params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
index cf66a0a2f..9a68b71ce 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
+ loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
-
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
index df336a7e4..5e868956f 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
+ stats: {
+ type: Object,
+ default: null,
+ },
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
+// Increments on every fetch so a slow response for a superseded
+// range/stats/assistant can't overwrite the latest request's state.
+let fetchToken = 0;
+
const fetchSummary = async () => {
+ fetchToken += 1;
+ const token = fetchToken;
+
+ if (!props.stats) {
+ welcomeMarkdown.value = '';
+ isLoading.value = false;
+ return;
+ }
+
isLoading.value = true;
+ let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
+ stats: props.stats,
});
- welcomeMarkdown.value = data.message ?? '';
+ message = data.message ?? '';
} catch {
- welcomeMarkdown.value = '';
- } finally {
- isLoading.value = false;
+ message = '';
}
+
+ if (token !== fetchToken) return;
+ welcomeMarkdown.value = message;
+ isLoading.value = false;
};
-watch([() => props.range, assistantId], fetchSummary, { immediate: true });
+watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
+ immediate: true,
+});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
index 29411e56d..ba31bc05d 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ step.name }}
+
+
+
+
+ {{ step.detail }}
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
+
+
+ {{
+ t(
+ 'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
+ citations.length
+ )
+ }}
+
+
+
+
+
+
+ {{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
+
+
+ {{ reasoning }}
+
+
+
+ {{ devDetails }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue
index 457b583ea..e799bd755 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
+import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
-import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
+import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
-const { variant, orientation, inReplyTo, shouldGroupWithNext } =
- useMessageContext();
+const {
+ variant,
+ orientation,
+ inReplyTo,
+ shouldGroupWithNext,
+ id,
+ sender,
+ senderType,
+} = useMessageContext();
const { t } = useI18n();
+const isCaptainMessage = computed(
+ () =>
+ (sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
+);
+
+const metaColorClass = computed(() =>
+ variant.value === MESSAGE_VARIANTS.PRIVATE
+ ? 'text-n-amber-12/50'
+ : 'text-n-slate-11'
+);
+
+const emailMetaClass = computed(() =>
+ variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
+);
+
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
-
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index fe71b8974..f3c81615b 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -72,6 +72,20 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
+ "CAPTAIN_GENERATION": {
+ "TITLE": "How was this reply generated?",
+ "GENERATED_BY": "Generated by Captain",
+ "LOADING": "Loading details…",
+ "EMPTY": "No generation details available for this message.",
+ "TIMELINE": "Generation steps",
+ "STEP_TOOL": "Called {name}",
+ "STEP_HANDOFF": "Handed off to {name}",
+ "REASONING": "Reasoning",
+ "SOURCES": "Knowledge base",
+ "SOURCES_SUMMARY": "{count} result | {count} results",
+ "MODEL": "Generated with {model}",
+ "CREDITS": "Credits: {credits}"
+ },
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
diff --git a/app/javascript/dashboard/store/captain/agentSessions.js b/app/javascript/dashboard/store/captain/agentSessions.js
new file mode 100644
index 000000000..e72d2e58c
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/agentSessions.js
@@ -0,0 +1,62 @@
+import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
+import camelcaseKeys from 'camelcase-keys';
+
+const SET_SESSION = 'SET_SESSION';
+const SET_FETCHING = 'SET_FETCHING';
+
+// Session capture runs right after the message is broadcast (and well after,
+// for handoff notes created mid-run), so a 404 on a fresh message may just
+// mean the session isn't written yet. Skip caching those so a later
+// hover/click retries; older misses are permanent (V1 messages, failed runs).
+const RECENT_MESSAGE_WINDOW_SECONDS = 60;
+
+// Caches Captain agent-session metadata per message id. A missing session
+// (404) is cached as null so the UI shows an empty state without refetching.
+export default {
+ namespaced: true,
+ state: {
+ sessions: {},
+ fetchingIds: [],
+ },
+ getters: {
+ getSessionByMessageId: state => messageId => state.sessions[messageId],
+ isFetching: state => messageId => state.fetchingIds.includes(messageId),
+ hasFetched: state => messageId => messageId in state.sessions,
+ },
+ actions: {
+ fetch: async ({ state, commit }, { messageId, createdAt }) => {
+ if (messageId in state.sessions) return;
+ if (state.fetchingIds.includes(messageId)) return;
+
+ commit(SET_FETCHING, { messageId, isFetching: true });
+ try {
+ const { data } = await CaptainAgentSessionsAPI.show(messageId);
+ commit(SET_SESSION, {
+ messageId,
+ session: camelcaseKeys(data, { deep: true }),
+ });
+ } catch (error) {
+ const isRecentMessage =
+ createdAt &&
+ Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
+ // Only a 404 means "no session exists"; transient failures (5xx,
+ // network errors) stay uncached so a later hover retries.
+ if (error.response?.status === 404 && !isRecentMessage) {
+ commit(SET_SESSION, { messageId, session: null });
+ }
+ } finally {
+ commit(SET_FETCHING, { messageId, isFetching: false });
+ }
+ },
+ },
+ mutations: {
+ [SET_SESSION](state, { messageId, session }) {
+ state.sessions = { ...state.sessions, [messageId]: session };
+ },
+ [SET_FETCHING](state, { messageId, isFetching }) {
+ state.fetchingIds = isFetching
+ ? [...state.fetchingIds, messageId]
+ : state.fetchingIds.filter(id => id !== messageId);
+ },
+ },
+};
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 23685bfad..d0c8e5002 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -50,6 +50,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
+import captainAgentSessions from './captain/agentSessions';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
@@ -115,6 +116,7 @@ export default createStore({
teams,
userNotificationSettings,
webhooks,
+ captainAgentSessions,
captainAssistants,
captainDocuments,
captainResponses,
diff --git a/config/routes.rb b/config/routes.rb
index dfdc23727..0bf6b40c8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -76,6 +76,7 @@ Rails.application.routes.draw do
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
resources :scenarios
end
+ resources :agent_sessions, only: [:show]
resources :assistant_responses
resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb
new file mode 100644
index 000000000..f9163de04
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb
@@ -0,0 +1,25 @@
+class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController
+ before_action :set_message
+ before_action :authorize_conversation
+
+ def show
+ @agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id)
+ return head :not_found if @agent_session.blank?
+
+ @citations = Current.account.captain_assistant_responses
+ .where(id: @agent_session.faq_ids)
+ .includes(:documentable)
+ @scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids)
+ .pluck(:id, :title).to_h
+ end
+
+ private
+
+ def set_message
+ @message = Current.account.messages.find(params[:id])
+ end
+
+ def authorize_conversation
+ authorize @message.conversation, :show?
+ end
+end
diff --git a/enterprise/app/services/captain/assistant/session_capture_service.rb b/enterprise/app/services/captain/assistant/session_capture_service.rb
index ceeb28210..816fe5355 100644
--- a/enterprise/app/services/captain/assistant/session_capture_service.rb
+++ b/enterprise/app/services/captain/assistant/session_capture_service.rb
@@ -22,13 +22,12 @@ class Captain::Assistant::SessionCaptureService
def capture!
model = @assistant.agent_model
- metadata = context.dig(:state, :cw_metadata) || {}
Captain::AgentSession.create!(
assistant: @assistant,
session_type: :assistant,
subject: @conversation,
- result: @result_message,
+ result: result_message,
llm_model: "#{Llm::Models.provider_for(model)}-#{model}",
credits_consumed: @credits_consumed,
faq_ids: metadata[:faq_ids] || [],
@@ -44,6 +43,23 @@ class Captain::Assistant::SessionCaptureService
@run_result.context || {}
end
+ def metadata
+ @metadata ||= context.dig(:state, :cw_metadata) || {}
+ end
+
+ # On handoff, HandoffTool records the private reason note it created; the session
+ # attaches there so agents can inspect the generation path on the note itself.
+ def result_message
+ handoff_note || @result_message
+ end
+
+ def handoff_note
+ note_id = metadata[:handoff_note_id]
+ return if note_id.blank?
+
+ @conversation.messages.find_by(id: note_id)
+ end
+
def scenario_ids
ids = current_turn_history.filter_map do |message|
next unless message[:role].to_s == 'assistant'
diff --git a/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder
new file mode 100644
index 000000000..1e1cd1d7d
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder
@@ -0,0 +1,18 @@
+json.id @agent_session.id
+json.message_id @agent_session.result_id
+json.llm_model @agent_session.llm_model
+json.credits_consumed @agent_session.credits_consumed
+json.run_context @agent_session.run_context.is_a?(Array) ? @agent_session.run_context : []
+json.citations @citations do |citation|
+ json.id citation.id
+ json.title citation.question
+ # display_url resolves uploaded PDFs to their blob URL; external_link holds a
+ # "PDF: ..." placeholder for those. Guard on scheme so placeholders render as
+ # plain text instead of dead anchors.
+ link = citation.documentable.is_a?(Captain::Document) ? citation.documentable.display_url : nil
+ json.link link&.match?(%r{\Ahttps?://}) ? link : nil
+end
+json.scenarios @scenario_titles do |id, title|
+ json.id id
+ json.title title
+end
diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb
index d126840be..f3ca8b1fe 100644
--- a/enterprise/lib/captain/tools/handoff_tool.rb
+++ b/enterprise/lib/captain/tools/handoff_tool.rb
@@ -13,7 +13,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
})
# Use existing handoff mechanism from ResponseBuilderJob
- trigger_handoff(conversation, reason)
+ trigger_handoff(tool_context, conversation, reason)
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
rescue StandardError => e
@@ -23,9 +23,9 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
private
- def trigger_handoff(conversation, reason)
+ def trigger_handoff(tool_context, conversation, reason)
# post the reason as a private note
- conversation.messages.create!(
+ note = conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: @assistant,
@@ -34,6 +34,15 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
content: reason
)
+ # Session capture attributes the run to this note so agents can inspect the
+ # generation path on the handoff reason instead of the canned follow-up message.
+ # A reason-less note has no content and never renders in the dashboard, so
+ # leave it unrecorded and let capture fall back to the follow-up message.
+ if reason.present?
+ metadata = tool_context.state[:cw_metadata] ||= {}
+ metadata[:handoff_note_id] = note.id
+ end
+
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb
new file mode 100644
index 000000000..1444dc0a8
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb
@@ -0,0 +1,120 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::AgentSessions', type: :request do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:message) do
+ create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
+ end
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/captain/agent_sessions/:id' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when the message has an agent session' do
+ let(:document) { create(:captain_document, account: account, assistant: assistant) }
+ let(:documented_faq) do
+ create(:captain_assistant_response, account: account, assistant: assistant,
+ question: 'How do I reset my password?', documentable: document)
+ end
+ let(:plain_faq) do
+ create(:captain_assistant_response, account: account, assistant: assistant, question: 'How do I change my email?')
+ end
+ let(:pdf_document) do
+ create(:captain_document, account: account, assistant: assistant, external_link: nil,
+ pdf_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/sample.pdf'), 'application/pdf'))
+ end
+ let(:pdf_faq) do
+ create(:captain_assistant_response, account: account, assistant: assistant,
+ question: 'What are the pricing tiers?', documentable: pdf_document)
+ end
+ let(:scenario) { create(:captain_scenario, account: account, assistant: assistant, title: 'Refund flow') }
+ let(:run_context) do
+ [
+ { 'role' => 'user', 'content' => 'I want a refund' },
+ { 'role' => 'assistant', 'content' => '', 'agent_name' => 'Assistant',
+ 'tool_calls' => [{ 'id' => 'call_1', 'name' => 'faq_lookup', 'arguments' => { 'query' => 'refund' } }] },
+ { 'role' => 'tool', 'content' => 'Refunds take 5 days', 'tool_call_id' => 'call_1' },
+ { 'role' => 'assistant', 'content' => 'Refunds take 5 days', 'agent_name' => "scenario_#{scenario.id}_refund_flow" }
+ ]
+ end
+ let!(:agent_session) do
+ create(:captain_agent_session, account: account, assistant: assistant,
+ subject: conversation, result: message,
+ llm_model: 'openai-gpt-5.2', credits_consumed: 1.0,
+ faq_ids: [documented_faq.id, plain_faq.id, pdf_faq.id, documented_faq.id + 100_000],
+ scenario_ids: [scenario.id],
+ run_context: run_context)
+ end
+
+ it 'returns the session with hydrated citations and scenarios' do
+ get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ aggregate_failures do
+ expect(json_response[:id]).to eq(agent_session.id)
+ expect(json_response[:message_id]).to eq(message.id)
+ expect(json_response[:llm_model]).to eq('openai-gpt-5.2')
+ expect(json_response[:credits_consumed]).to eq(1.0)
+ expect(json_response[:run_context].length).to eq(4)
+ expect(json_response[:run_context].second[:tool_calls].first[:arguments][:query]).to eq('refund')
+
+ citations = json_response[:citations].index_by { |citation| citation[:id] }
+ expect(citations.keys).to contain_exactly(documented_faq.id, plain_faq.id, pdf_faq.id)
+ expect(citations[documented_faq.id][:title]).to eq('How do I reset my password?')
+ expect(citations[documented_faq.id][:link]).to eq(document.external_link)
+ expect(citations[plain_faq.id][:link]).to be_nil
+ expect(pdf_document.external_link).to start_with('PDF:')
+ expect(citations[pdf_faq.id][:link]).to eq(pdf_document.display_url)
+ expect(citations[pdf_faq.id][:link]).to match(%r{\Ahttps?://})
+
+ expect(json_response[:scenarios]).to eq([{ id: scenario.id, title: 'Refund flow' }])
+ end
+ end
+
+ it 'does not allow an agent without access to the conversation' do
+ other_agent = create(:user, account: account, role: :agent)
+
+ get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
+ headers: other_agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when the message has no agent session' do
+ it 'returns not found' do
+ get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'when the message does not belong to the account' do
+ it 'returns not found' do
+ other_message = create(:message)
+
+ get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{other_message.id}",
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
index eb1cb641c..08bb2a50d 100644
--- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
@@ -561,6 +561,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
+ it 'attributes the handoff session to the private reason note when the tool recorded one' do
+ handoff_note = create(:message, conversation: conversation, account: account, message_type: :outgoing,
+ private: true, sender: assistant, content: 'Needs a human')
+ run_context[:state][:cw_metadata][:handoff_note_id] = handoff_note.id
+ allow(mock_agent_runner_service).to receive(:generate_response) do
+ conversation.update!(status: :open)
+ { 'response' => 'Let me connect you', 'handoff_tool_called' => true }
+ end
+
+ described_class.perform_now(conversation, assistant)
+
+ session = Captain::AgentSession.last
+ expect(session.credits_consumed).to eq(0.0)
+ expect(session.result_id).to eq(handoff_note.id)
+ end
+
it 'creates a zero-credit session when the handoff tool fired but failed to commit' do
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
'response' => 'I tried to hand off',
diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
index 492d24f32..db5ee462c 100644
--- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
+++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
@@ -86,6 +86,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: reason)
end
+
+ it 'records the handoff note id in the run state for session capture' do
+ tool.perform(tool_context, reason: 'Customer needs specialized support')
+
+ expect(tool_context.state[:cw_metadata][:handoff_note_id]).to eq(Message.last.id)
+ end
end
context 'without reason provided' do
@@ -107,6 +113,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context)
end
+
+ it 'does not record a handoff note id since the empty note never renders' do
+ tool.perform(tool_context)
+
+ expect(tool_context.state[:cw_metadata]).to be_nil
+ end
end
context 'when handoff fails' do
From 0e376f4fe238ec5011c1ab63d81774d3ac186fc5 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Tue, 21 Jul 2026 16:19:53 +0530
Subject: [PATCH 136/143] feat(whatsapp-call): support BSUID callers for
inbound voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling
## Description
Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Locally via UI
## 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
---------
Co-authored-by: Muhsin Keloth
---
.../controllers/twilio/voice_controller.rb | 4 +-
.../services/voice/inbound_call_builder.rb | 59 +++------
.../whatsapp/inbound_call_identity_builder.rb | 48 ++++++++
.../whatsapp/incoming_call_service.rb | 25 ++--
.../twilio/voice_controller_spec.rb | 4 +-
.../voice/inbound_call_builder_spec.rb | 21 ++--
.../whatsapp/incoming_call_service_spec.rb | 114 ++++++++++++++++++
7 files changed, 203 insertions(+), 72 deletions(-)
create mode 100644 enterprise/app/services/whatsapp/inbound_call_identity_builder.rb
diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb
index 66fef9583..52ec2637b 100644
--- a/enterprise/app/controllers/twilio/voice_controller.rb
+++ b/enterprise/app/controllers/twilio/voice_controller.rb
@@ -107,8 +107,8 @@ class Twilio::VoiceController < ApplicationController
when 'inbound'
Voice::InboundCallBuilder.perform!(
inbox: inbox,
- from_number: twilio_from,
- call_sid: twilio_call_sid
+ call_sid: twilio_call_sid,
+ caller: { source_ids: [twilio_from], contact_attributes: { name: twilio_from, phone_number: twilio_from } }
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
index eef70e76b..c928da43f 100644
--- a/enterprise/app/services/voice/inbound_call_builder.rb
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -1,17 +1,19 @@
class Voice::InboundCallBuilder
- attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
+ attr_reader :inbox, :call_sid, :provider, :extra_meta, :source_ids, :contact_attributes
- def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
- new(inbox: inbox, from_number: from_number, call_sid: call_sid,
- provider: provider, extra_meta: extra_meta).perform!
+ # `caller` carries the contact identity: { source_ids:, contact_attributes: }. Twilio passes
+ # its single +phone source_id; WhatsApp passes the message-path phone/user_id/parent_user_id set.
+ def self.perform!(inbox:, call_sid:, caller:, provider: :twilio, extra_meta: {})
+ new(inbox: inbox, call_sid: call_sid, caller: caller, provider: provider, extra_meta: extra_meta).perform!
end
- def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
+ def initialize(inbox:, call_sid:, caller:, provider: :twilio, extra_meta: {})
@inbox = inbox
- @from_number = from_number
@call_sid = call_sid
@provider = provider.to_sym
@extra_meta = extra_meta || {}
+ @source_ids = Array(caller[:source_ids]).compact_blank
+ @contact_attributes = caller[:contact_attributes] || {}
end
def perform!
@@ -43,46 +45,17 @@ class Voice::InboundCallBuilder
.find_by(provider: provider, provider_call_id: call_sid)
end
- # Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
- # creating with a colliding source_id under a different contact would raise
- # RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
- # A concurrent message webhook for the same wa_id can win the (inbox_id, source_id)
- # race; rescue and re-find so the call path doesn't drop the connect.
+ # Resolve the contact/ContactInbox the same way inbound messages do — match across every
+ # candidate source_id (phone + BSUID aliases) so a call reuses the existing thread, creating
+ # one keyed on the first (phone, else BSUID) only when none exists. Shared with messaging via
+ # ContactInboxSourceIdResolver, which also rescues the concurrent-webhook create race.
def ensure_contact_inbox!
- sid = source_id_for_provider
- existing = inbox.contact_inboxes.find_by(source_id: sid)
- return existing if existing
-
- ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
- rescue ActiveRecord::RecordNotUnique
- inbox.contact_inboxes.find_by!(source_id: sid)
+ ContactInboxSourceIdResolver.new(
+ inbox: inbox, source_ids: source_ids, contact_attributes: contact_attributes
+ ).perform
end
- def ensure_contact!
- contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record|
- record.name = contact_name.presence || from_number
- end
- contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number
- contact
- end
-
- # WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio
- # calls don't, so contact naming falls back to the phone number.
- def contact_name
- extra_meta['contact_name'].presence
- end
-
- # WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
- # Run BR/AR-style wa_id normalization (same path messaging uses) so an inbound call
- # finds the existing ContactInbox instead of forking a new contact/conversation.
- def source_id_for_provider
- return from_number unless provider == :whatsapp
-
- digits = from_number.to_s.delete_prefix('+')
- Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
- end
-
- # Mirror incoming-message routing: reuse the open conversation (or the last one when locked), else create new.
+ # Mirror Whatsapp::IncomingMessageBaseService#set_conversation: reuse this row's open conversation (or last when locked), else create.
def resolve_conversation!(contact, contact_inbox)
reusable = if inbox.lock_to_single_conversation
contact_inbox.conversations.last
diff --git a/enterprise/app/services/whatsapp/inbound_call_identity_builder.rb b/enterprise/app/services/whatsapp/inbound_call_identity_builder.rb
new file mode 100644
index 000000000..590a1f923
--- /dev/null
+++ b/enterprise/app/services/whatsapp/inbound_call_identity_builder.rb
@@ -0,0 +1,48 @@
+class Whatsapp::InboundCallIdentityBuilder
+ pattr_initialize [:inbox!, :params!]
+
+ # Build the message path's source_id set (phone wa_id -> user_id -> parent_user_id) plus
+ # contact attributes, so the resolver lands a call on the same ContactInbox a message would.
+ # BSUIDs ride in from_user_id/from_parent_user_id (or the contact's user_id/parent_user_id),
+ # never in `from` (the phone wa_id).
+ def perform(payload)
+ contact = caller_contact(payload)
+ phone = contact[:wa_id].presence || payload[:from].presence
+ source_ids = [
+ phone_source_id(phone),
+ payload[:from_user_id].presence || contact[:user_id].presence,
+ payload[:from_parent_user_id].presence || contact[:parent_user_id].presence
+ ].compact_blank.uniq
+ { source_ids: source_ids, contact_attributes: contact_attributes(contact, phone, source_ids.first) }
+ end
+
+ private
+
+ # Normalize the wa_id the same way messaging does so a call matches its stored source_id.
+ def phone_source_id(phone)
+ return unless phone.to_s.match?(/\A\d{1,15}\z/)
+
+ Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(phone.to_s, :cloud)
+ end
+
+ def contact_attributes(contact, phone, source_identifier)
+ name = contact.dig(:profile, :name).presence || source_identifier
+ return { name: name } unless phone.to_s.match?(/\A\d{1,15}\z/)
+
+ formatted = "+#{phone}"
+ { name: name == phone ? formatted : name, phone_number: formatted }
+ end
+
+ # Match the contacts entry to THIS caller so batched payloads don't borrow another's identity.
+ def caller_contact(payload)
+ Array(params[:contacts]).map(&:with_indifferent_access).find do |c|
+ identifier_match?(c[:wa_id], payload[:from]) ||
+ identifier_match?(c[:user_id], payload[:from_user_id]) ||
+ identifier_match?(c[:parent_user_id], payload[:from_parent_user_id])
+ end || {}.with_indifferent_access
+ end
+
+ def identifier_match?(left, right)
+ left.present? && right.present? && left.to_s == right.to_s
+ end
+end
diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb
index 11b35d95a..ea08c8415 100644
--- a/enterprise/app/services/whatsapp/incoming_call_service.rb
+++ b/enterprise/app/services/whatsapp/incoming_call_service.rb
@@ -95,28 +95,21 @@ class Whatsapp::IncomingCallService
# commit) already terminal, never `ringing` — agents aren't rung for a dead call.
def build_inbound_call(payload, sdp_offer)
ActiveRecord::Base.transaction do
- call = Voice::InboundCallBuilder.perform!(inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
- provider: :whatsapp, extra_meta: inbound_extra_meta(payload, sdp_offer))
+ identity = Whatsapp::InboundCallIdentityBuilder.new(inbox: inbox, params: params).perform(payload)
+ extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
+ call = Voice::InboundCallBuilder.perform!(inbox: inbox, call_sid: payload[:id],
+ provider: :whatsapp, extra_meta: extra_meta, caller: identity)
+ sync_caller_identifiers(call, identity)
tombstone = consume_terminate_tombstone(payload[:id])
finalize_terminate(call, tombstone['duration'], tombstone['terminate_reason']) if tombstone
call
end
end
- def inbound_extra_meta(payload, sdp_offer)
- extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
- name = caller_profile_name(payload)
- extra_meta['contact_name'] = name if name.present?
- extra_meta
- end
-
- # Match strictly on wa_id (== calls[].from): in a batched payload missing this
- # call's contact entry, borrowing another caller's name would corrupt this
- # contact, so fall back to the phone number (nil here) instead of contacts.first.
- def caller_profile_name(payload)
- contacts = Array(params[:contacts]).map(&:with_indifferent_access)
- match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s }
- match&.dig(:profile, :name).presence
+ # Backfill every caller alias (the builder only stores the first) so a later event keyed on any one lands on this thread.
+ def sync_caller_identifiers(call, identity)
+ Whatsapp::IdentifierSyncService.new(contact_inbox: call.conversation.contact_inbox, contact: call.contact)
+ .perform(source_ids: identity[:source_ids], phone_number: identity.dig(:contact_attributes, :phone_number))
end
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
index 79fdd67a8..0f537af20 100644
--- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb
+++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
@@ -33,8 +33,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
inbox: inbox,
- from_number: from_number,
- call_sid: call_sid
+ call_sid: call_sid,
+ caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } }
).and_return(call)
post "/twilio/voice/call/#{digits}", params: {
diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb
index e36c9a1b7..c8c6b978e 100644
--- a/spec/enterprise/services/voice/inbound_call_builder_spec.rb
+++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb
@@ -17,8 +17,8 @@ RSpec.describe Voice::InboundCallBuilder do
def perform_builder
described_class.perform!(
inbox: inbox,
- from_number: from_number,
- call_sid: call_sid
+ call_sid: call_sid,
+ caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } }
)
end
@@ -100,26 +100,29 @@ RSpec.describe Voice::InboundCallBuilder do
end
end
- context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do
+ context 'when a WhatsApp call shares a BSUID with an existing ContactInbox' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: { 'phone_number_id' => '123', 'source' => 'embedded_signup', 'calling_enabled' => true },
validate_provider_config: false, sync_templates: false)
end
let(:whatsapp_inbox) { whatsapp_channel.inbox }
- let!(:stored_contact) { create(:contact, account: account, phone_number: '+5541988887777') }
+ let!(:stored_contact) { create(:contact, account: account) }
let!(:stored_contact_inbox) do
- create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777')
+ create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: 'IN.2081978709342942')
end
before { account.enable_features!('channel_voice') }
- it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do
+ # Closes the gap: the contact was keyed by BSUID, but the call also carries a phone.
+ # Matching across every source_id reuses the contact instead of forking on the phone.
+ it 'reuses the contact by matching any source_id, not just the first' do
call = described_class.perform!(
inbox: whatsapp_inbox,
- from_number: '+554188887777',
- call_sid: 'wacall_br_1',
- provider: :whatsapp
+ call_sid: 'wacall_bsuid_1',
+ provider: :whatsapp,
+ caller: { source_ids: ['5541988887777', 'IN.2081978709342942'],
+ contact_attributes: { name: 'Ada Lovelace', phone_number: '+5541988887777' } }
)
expect(call.contact).to eq(stored_contact)
diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
index a3c5246d2..cf031f1ca 100644
--- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
+++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
@@ -74,6 +74,120 @@ describe Whatsapp::IncomingCallService do
end
end
+ describe 'inbound connect from a username (BSUID) caller' do
+ let(:sdp_offer) { "v=0\r\n...sdp..." }
+ let(:bsuid) { 'IN.2081978709342942' }
+ let!(:agent) { create(:user, account: account) }
+
+ before { create(:inbox_member, inbox: inbox, user: agent) }
+
+ it 'keys a phone caller by the phone (matching messaging) even when a BSUID is also present' do
+ allow(ActionCable.server).to receive(:broadcast)
+
+ params = {
+ calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect',
+ session: { sdp: sdp_offer, sdp_type: 'offer' } }],
+ contacts: [{ wa_id: from_number, user_id: bsuid, profile: { name: 'Ada Lovelace' } }]
+ }
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to change(Call, :count).by(1).and change(Conversation, :count).by(1)
+
+ contact_inbox = Call.last.conversation.contact_inbox
+ expect(contact_inbox.source_id).to eq(from_number)
+ expect(contact_inbox.contact.name).to eq('Ada Lovelace')
+ end
+
+ it 'keys a username-only caller by the BSUID when no phone `from` is present' do
+ allow(ActionCable.server).to receive(:broadcast)
+
+ params = {
+ calls: [{ id: provider_call_id, from_user_id: bsuid, event: 'connect',
+ session: { sdp: sdp_offer, sdp_type: 'offer' } }],
+ contacts: [{ user_id: bsuid, profile: { name: 'Ada Lovelace' } }]
+ }
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to change(Call, :count).by(1)
+
+ contact_inbox = Call.last.conversation.contact_inbox
+ expect(contact_inbox.source_id).to eq(bsuid)
+ expect(contact_inbox.contact.name).to eq('Ada Lovelace')
+ end
+
+ it 'reuses the phone-keyed ContactInbox messaging created for a phone caller and backfills the BSUID alias' do
+ allow(ActionCable.server).to receive(:broadcast)
+ contact = create(:contact, account: account)
+ existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: from_number)
+
+ params = {
+ calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect',
+ session: { sdp: sdp_offer, sdp_type: 'offer' } }],
+ contacts: [{ wa_id: from_number, user_id: bsuid }]
+ }
+ # The conversation reuses the existing phone thread; the BSUID alias is backfilled onto the same contact.
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to change(Call, :count).by(1).and change(ContactInbox, :count).by(1)
+
+ expect(Call.last.contact).to eq(contact)
+ expect(Call.last.conversation.contact_inbox).to eq(existing)
+ expect(inbox.contact_inboxes.find_by(source_id: bsuid).contact).to eq(contact)
+ end
+
+ it 'reuses a phone ContactInbox via the same wa_id normalization messaging uses' do
+ allow(ActionCable.server).to receive(:broadcast)
+ contact = create(:contact, account: account, phone_number: '+5541988887777')
+ existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5541988887777')
+
+ params = {
+ calls: [{ id: provider_call_id, from: '554188887777', event: 'connect',
+ session: { sdp: sdp_offer, sdp_type: 'offer' } }],
+ contacts: [{ wa_id: '554188887777' }]
+ }
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to change(Call, :count).by(1).and not_change(ContactInbox, :count)
+
+ expect(Call.last.conversation.contact_inbox).to eq(existing)
+ end
+
+ it 'reuses the BSUID-keyed ContactInbox messaging created for a username-only caller' do
+ allow(ActionCable.server).to receive(:broadcast)
+ contact = create(:contact, account: account)
+ existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: bsuid)
+
+ params = {
+ calls: [{ id: provider_call_id, from_user_id: bsuid, event: 'connect',
+ session: { sdp: sdp_offer, sdp_type: 'offer' } }],
+ contacts: [{ user_id: bsuid }]
+ }
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to change(Call, :count).by(1).and not_change(ContactInbox, :count)
+
+ expect(Call.last.contact).to eq(contact)
+ expect(Call.last.conversation.contact_inbox).to eq(existing)
+ end
+
+ # The gap: messaging created the contact username-only (BSUID-keyed), and the call now
+ # also exposes a phone. Matching across every source_id reuses the BSUID thread instead
+ # of forking a new phone-keyed contact.
+ it 'reuses a BSUID-keyed ContactInbox even when the call also carries a phone and backfills the phone alias' do
+ allow(ActionCable.server).to receive(:broadcast)
+ contact = create(:contact, account: account)
+ existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: bsuid)
+
+ params = {
+ calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect',
+ session: { sdp: sdp_offer, sdp_type: 'offer' } }],
+ contacts: [{ wa_id: from_number, user_id: bsuid }]
+ }
+ # The conversation reuses the existing BSUID thread; the phone alias is backfilled onto the same contact.
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to change(Call, :count).by(1).and change(ContactInbox, :count).by(1)
+
+ expect(Call.last.contact).to eq(contact)
+ expect(Call.last.conversation.contact_inbox).to eq(existing)
+ expect(inbox.contact_inboxes.find_by(source_id: from_number).contact).to eq(contact)
+ end
+ end
+
describe 'outbound connect (existing call)' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
From 2144de92f26a6cab7614ae6c7b9ab4a66d935e97 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Tue, 21 Jul 2026 16:21:40 +0530
Subject: [PATCH 137/143] fix(whatsapp): reopen conversation across a contact's
coexistence identities (#15098)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WhatsApp contacts using coexistence are identified by more than one
source ID (a phone `wa_id` and a `BR.`/BSUID identity), so a single
contact ends up owning multiple `contact_inbox` records. The "reopen the
same conversation" feature scoped conversation reuse to a single
`contact_inbox`, so messages arriving under a different identity of the
same contact started a brand-new conversation — even with reopen enabled
— producing duplicate conversations.
This scopes reuse to the contact across all of its `contact_inbox`
records in the inbox instead of a single `contact_inbox`.
## Closes
- [CW-7651
](https://linear.app/chatwoot/issue/CW-7651/duplicate-conversations)
## How to reproduce
1. On a WhatsApp Cloud inbox with "reopen the same conversation" (lock
to single conversation) enabled.
2. Have a coexistence contact whose webhooks alternate between carrying
the phone `wa_id` and only the BSUID identity.
3. Before: each identity opens its own conversation → duplicates. After:
incoming messages reopen the contact's existing conversation regardless
of which identity the webhook carried.
## What changed
- `Whatsapp::IncomingMessageBaseService#set_conversation` now looks up
reusable conversations via `@contact.conversations.where(inbox_id:
@inbox.id)` instead of `@contact_inbox.conversations`.
- Updated existing specs to wire the conversation's `contact` to the
contact_inbox's contact, mirroring production data.
---
.../whatsapp/incoming_message_base_service.rb | 8 ++++---
.../whatsapp/incoming_message_service_spec.rb | 22 +++++++++----------
2 files changed, 16 insertions(+), 14 deletions(-)
diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb
index 00936ff15..09632c5b1 100644
--- a/app/services/whatsapp/incoming_message_base_service.rb
+++ b/app/services/whatsapp/incoming_message_base_service.rb
@@ -113,12 +113,14 @@ class Whatsapp::IncomingMessageBaseService
end
def set_conversation
+ # Scope reuse to the contact across all its contact_inboxes in this inbox: WhatsApp coexistence
+ # gives one contact multiple source_ids (phone + BSUID), so reopen must not be limited to a single contact_inbox.
+ conversations = @contact.conversations.where(inbox_id: @inbox.id)
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation
- @contact_inbox.conversations.last
+ conversations.last
else
- @contact_inbox.conversations
- .where.not(status: :resolved).last
+ conversations.where.not(status: :resolved).last
end
return if @conversation
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index 430aa1561..01b569cb9 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -34,8 +34,8 @@ describe Whatsapp::IncomingMessageService do
it 'appends to last conversation when if conversation already exists' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
- 2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) }
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ 2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) }
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(3)
@@ -46,7 +46,7 @@ describe Whatsapp::IncomingMessageService do
it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: true)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
last_conversation.update(status: 'resolved')
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
@@ -59,7 +59,7 @@ describe Whatsapp::IncomingMessageService do
it 'creates a new conversation if last conversation is resolved and lock to single conversation is disabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: false)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
last_conversation.update(status: 'resolved')
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# new conversation should be created
@@ -70,7 +70,7 @@ describe Whatsapp::IncomingMessageService do
it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: false)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
last_conversation.update(status: Conversation.statuses.except('resolved').keys.sample)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# new conversation should be created
@@ -238,7 +238,7 @@ describe Whatsapp::IncomingMessageService do
end
before do
- create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end
@@ -453,7 +453,7 @@ describe Whatsapp::IncomingMessageService do
it 'appends to existing contact if contact inbox exists' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -468,7 +468,7 @@ describe Whatsapp::IncomingMessageService do
context 'when a contact inbox exists in the old format without 9 included' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -480,7 +480,7 @@ describe Whatsapp::IncomingMessageService do
context 'when a contact inbox exists in the new format with 9 included' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '5541988887777')
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -515,7 +515,7 @@ describe Whatsapp::IncomingMessageService do
# Normalized format removes the 9 after country code
normalized_wa_id = '541123456789'
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -532,7 +532,7 @@ describe Whatsapp::IncomingMessageService do
context 'when a contact inbox exists with the same format' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
- last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
From 8c013415b85c1df6b72fa3b81c51d755c00c9bce Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 21 Jul 2026 16:29:08 +0530
Subject: [PATCH 138/143] fix: localize the captain overview summary greeting
(#15108)
---
.../openai/openai_prompts/captain_overview_summary.liquid | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
index 96734b4d9..c5637da0f 100644
--- a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
+++ b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
@@ -1,8 +1,8 @@
You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it.
Voice and format:
-- Write the entire summary in {{ language }}, including the opening greeting.
-- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic.
+- Write the entire summary in {{ language }}.
+- Address {{ first_name }} directly and open with a short casual greeting to {{ first_name }} in {{ language }}, the natural equivalent of "Hey {{ first_name }},". Never leave the greeting in English when {{ language }} is not English. Be conversational, never robotic.
- Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant".
- This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in".
- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up.
From 7d2f01e40242160d34bacaacef63154552532a51 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Tue, 21 Jul 2026 15:05:11 +0400
Subject: [PATCH 139/143] feat(whatsapp): unify embedded signup feature gating
(#15106)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WhatsApp embedded signup now uses
`whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud
rollout gate for inbox creation, proactive reconfiguration, and
disconnected inbox reauthorization. The authorization endpoint enforces
the same gate, so the UI and backend remain consistent.
Self-hosted installations keep their existing behavior.
## Things to know
- This reuses the existing feature flag; there is no migration or schema
change.
- The feature is shown as “WhatsApp Embedded Signup Flow” in feature
management.
- `whatsapp_reconfigure` remains visible and honored for self-hosted
proactive reconfiguration to preserve existing accounts. It can be
deprecated after the self-hosted dependency is removed or migrated.
## How to test
1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation`
for an account.
2. Confirm that new WhatsApp inbox creation, proactive reconfiguration,
and disconnected inbox reauthorization are available.
3. Disable the flag and confirm those entry points are hidden and
authorization requests are rejected.
4. On self-hosted, confirm proactive reconfiguration remains controlled
by the existing `whatsapp_reconfigure` account setting.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
---
.../v1/accounts/whatsapp/authorizations_controller.rb | 11 +++++++++--
app/javascript/dashboard/featureFlags.js | 3 +--
.../onboarding/inbox-setup/useChannelConfig.js | 4 +---
.../routes/dashboard/settings/inbox/Settings.vue | 5 +++++
.../dashboard/settings/inbox/channels/Whatsapp.vue | 4 +---
.../settings/inbox/settingsPage/ConfigurationPage.vue | 5 ++++-
config/features.yml | 2 +-
7 files changed, 22 insertions(+), 12 deletions(-)
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index 580ae77c6..46d89a1ba 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
+ before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
@@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
+ def ensure_embedded_signup_enabled
+ return unless ChatwootApp.chatwoot_cloud?
+ return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
+
+ raise Pundit::NotAuthorizedError
+ end
+
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
-
- # Reconfiguring a live embedded-signup channel requires the feature flag.
+ return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index e707284cb..04f46890e 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -7,8 +7,7 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
- WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
- 'whatsapp_embedded_signup_inbox_creation',
+ WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
index 36a78dcbe..6dedbe894 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
@@ -18,9 +18,7 @@ export function useChannelConfig() {
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
(!isOnChatwootCloud.value ||
- isCloudFeatureEnabled(
- FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
- )) &&
+ isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index 222d238a8..5e7f320f3 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -390,6 +390,11 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
+ (!this.isOnChatwootCloud ||
+ this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
+ )) &&
this.inbox.reauthorization_required
);
},
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
index b4e0c58af..6a4a361b0 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
@@ -42,9 +42,7 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
hasWhatsappAppId.value &&
(!isOnChatwootCloud.value ||
- isCloudFeatureEnabled(
- FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
- ))
+ isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
);
});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
index 61038cec1..1e9e2f704 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
@@ -56,6 +56,7 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
+ isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
@@ -65,7 +66,9 @@ export default {
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
- FEATURE_FLAGS.WHATSAPP_RECONFIGURE
+ this.isOnChatwootCloud
+ ? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
+ : FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},
diff --git a/config/features.yml b/config/features.yml
index 590f28814..950d6e7c5 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -265,6 +265,6 @@
enabled: false
column: feature_flags_ext_1
- name: whatsapp_embedded_signup_inbox_creation
- display_name: WhatsApp Embedded Signup Inbox Creation
+ display_name: WhatsApp Embedded Signup Flow
enabled: false
column: feature_flags_ext_1
From ed30ff9c2291084d0558f543be8da58003fe3025 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Tue, 21 Jul 2026 18:08:32 +0530
Subject: [PATCH 140/143] fix(whatsapp): allow calling a contact with no
existing conversation (#15014)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
Agents can now place a WhatsApp call to a contact straight from the
contacts screen, even if that contact has never messaged in. Previously
the call only worked once a conversation already existed, so a freshly
added contact would fail with "Unable to start the call. Please try
again." — the only workaround was to get the contact to message the
channel first.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Manually via UI
## 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
---------
Co-authored-by: Muhsin Keloth
---
.../api/channel/whatsapp/whatsappCallsAPI.js | 5 +-
.../Contacts/VoiceCallButton.vue | 36 +-----
.../message/bubbles/VoiceCall.vue | 6 +-
.../conversation/ConversationCallButton.vue | 6 +-
.../composables/useWhatsappCallSession.js | 10 +-
.../v1/accounts/whatsapp_calls_controller.rb | 122 +++++++-----------
.../whatsapp/call_conversation_builder.rb | 32 +++++
.../call_permission_request_service.rb | 63 +++++++++
.../whatsapp_calls/initiate.json.jbuilder | 1 +
9 files changed, 166 insertions(+), 115 deletions(-)
create mode 100644 enterprise/app/services/whatsapp/call_conversation_builder.rb
create mode 100644 enterprise/app/services/whatsapp/call_permission_request_service.rb
diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
index ec24aae34..d458c1e5d 100644
--- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
+++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
- initiate(conversationId, sdpOffer) {
+ // Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
+ initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
+ contact_id: contactId,
+ inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
index 7e2b6f0c4..5c7ec691d 100644
--- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
+++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
-import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
-// Find the most recent open conversation for this contact in the picked inbox.
-// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
-// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
-// contacts whose latest WhatsApp conversation falls outside the 20 most recent
-// across all inboxes would be treated as having no conversation.
-const findWhatsappConversationId = async inboxId => {
- const { data } = await ContactAPI.getConversations(props.contactId, {
- inboxId,
- });
- const conversations = data?.payload || [];
- const match = [...conversations].sort(
- (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
- )[0];
- return match?.id || null;
-};
-
const startWhatsappCall = async (inboxId, conversationIdHint) => {
- // WhatsApp /initiate is conversation-scoped, so we must hand it a
- // conversation. Use the caller's hint when given (in-conversation flow);
- // otherwise pick the most recent one in the inbox.
- const conversationId =
- conversationIdHint || (await findWhatsappConversationId(inboxId));
- if (!conversationId) {
- useAlert(t('CONTACT_PANEL.CALL_FAILED'));
- return;
- }
-
- const response =
- await whatsappCallSession.initiateOutboundCall(conversationId);
+ const response = await whatsappCallSession.initiateOutboundCall(
+ conversationIdHint
+ ? { conversationId: conversationIdHint }
+ : { contactId: props.contactId, inboxId }
+ );
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
+
+ const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index 34eb1eaff..1a127d9e6 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
- const response = await whatsappCallSession.initiateOutboundCall(
- conversationId.value
- );
+ const response = await whatsappCallSession.initiateOutboundCall({
+ conversationId: conversationId.value,
+ });
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue
index d0d2a2778..f59df929c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCallButton.vue
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
- const response = await whatsappCallSession.initiateOutboundCall(
- props.chat.id
- );
+ const response = await whatsappCallSession.initiateOutboundCall({
+ conversationId: props.chat.id,
+ });
// Composable returns LOCKED when init is already in flight or a call is
// active; soft no-op so a parallel click doesn't trigger a banner.
diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js
index b934c0997..9d17c6b06 100644
--- a/app/javascript/dashboard/composables/useWhatsappCallSession.js
+++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js
@@ -308,7 +308,8 @@ export function useWhatsappCallSession() {
}
};
- const initiateOutboundCall = async conversationId => {
+ // target: { conversationId } or { contactId, inboxId }
+ const initiateOutboundCall = async target => {
// Module-scoped lock + active-session guard so a second click — from the
// same composable instance OR a different one (header vs contact panel)
// OR while a call is already live — can't tear down the in-flight setup
@@ -320,10 +321,7 @@ export function useWhatsappCallSession() {
isInitiatingOutbound.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
- const response = await WhatsappCallsAPI.initiate(
- conversationId,
- sdpOffer
- );
+ const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
if (response?.id) {
activeCallId = response.id;
// A connect webhook that raced ahead of this response was buffered;
@@ -354,7 +352,7 @@ export function useWhatsappCallSession() {
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
) {
- return { status: data.status };
+ return { status: data.status, conversation_id: data.conversation_id };
}
throw e;
} finally {
diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
index a0627ce49..2f86e0de8 100644
--- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
@@ -1,8 +1,6 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
- PERMISSION_REQUEST_THROTTLE = 5.minutes
-
before_action :set_call, only: %i[show accept reject terminate upload_recording]
- before_action :set_conversation, only: :initiate
+ before_action :set_call_context, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
@@ -53,7 +51,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def provider_service
- @provider_service ||= @conversation.inbox.channel.provider_service
+ @provider_service ||= @inbox.channel.provider_service
end
def set_call
@@ -61,13 +59,38 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
authorize @call.conversation, :show?
end
- def set_conversation
+ def set_call_context
+ params[:conversation_id].present? ? set_context_from_conversation : set_context_from_contact
+ end
+
+ def set_context_from_conversation
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show?
+ @inbox = @conversation.inbox
+ @contact = @conversation.contact
+ end
+
+ def set_context_from_contact
+ @inbox = Current.account.inboxes.find(params[:inbox_id])
+ authorize @inbox, :show?
+ @contact = Current.account.contacts.find(params[:contact_id])
+ @conversation = conversation_builder.existing_conversation
+ # Authorize the thread the call will land in — after the dial is too late to refuse a ringing call.
+ authorize(@conversation || conversation_builder.new_conversation, :show?)
+ end
+
+ def conversation_builder
+ @conversation_builder ||= Whatsapp::CallConversationBuilder.new(inbox: @inbox, contact: @contact, user: Current.user)
+ end
+
+ # Created only after the dial succeeds, so a failed call leaves no empty thread and there is nothing to
+ # roll back. Re-authorized because a concurrent caller may have created the thread we get back.
+ def open_conversation!
+ (@conversation || conversation_builder.perform!).tap { |conversation| authorize conversation, :show? }
end
def ensure_calling_enabled
- channel = @conversation.inbox.channel
+ channel = @inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
@@ -80,7 +103,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def ensure_contact_phone
- return if @conversation.contact&.phone_number.present?
+ return if @contact.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
@@ -105,92 +128,45 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def create_outbound_call
- contact_phone = @conversation.contact.phone_number.delete('+')
- # Claim for the caller only if unassigned at trigger time (before the round-trip); wins over auto-assignment.
- claim_for_caller = @conversation.assignee_id.nil?
+ # A reused thread unassigned at click time is claimed for the caller (wins over auto-assignment); a
+ # fresh thread (@conversation nil until the dial succeeds) is created already assigned to the caller.
+ claim_for_caller = @conversation.present? && @conversation.assignee_id.nil?
- result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
+ result = provider_service.initiate_call(@contact.phone_number.delete('+'), params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
+ @conversation = open_conversation!
@conversation.with_lock { @conversation.update!(assignee: Current.user) } if claim_for_caller
+ create_call_record(provider_call_id)
+ end
+
+ def create_call_record(provider_call_id)
+ existing = Current.account.calls.whatsapp.find_by(provider_call_id: provider_call_id)
+ return existing if existing
+
Current.account.calls.create!(
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: Current.user.id,
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
)
+ rescue ActiveRecord::RecordNotUnique
+ # A webhook inserted the row between the find_by above and this create; reconcile to it.
+ Current.account.calls.whatsapp.find_by!(provider_call_id: provider_call_id)
end
- # Meta error 138006 means the contact hasn't opted in yet; send the opt-in
- # template (throttled, behind a conversation lock to prevent double-send).
def render_permission_request
- status = nil
- @conversation.with_lock do
- if permission_request_throttled?
- status = 'permission_pending'
- next
- end
-
- sent = send_permission_request_safely
- if sent
- record_permission_request_wamid(sent)
- emit_permission_requested_activity
- status = 'permission_requested'
- else
- status = 'failed'
- end
- end
+ # Raised mid-dial, so a fresh contact has no thread yet — open one for the opt-in template to land in.
+ @conversation = open_conversation!
+ status = Whatsapp::CallPermissionRequestService.new(conversation: @conversation).perform
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
# 422 (not 200) so any client treating 2xx as "call placed" can't mistake
# the permission-template path for a successful dial. The FE composable
# detects this status and surfaces the banner instead of throwing.
- render json: { status: status }, status: :unprocessable_entity
- end
-
- def permission_request_throttled?
- last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
- last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
- end
-
- # Treat transport errors as a falsy return so we render 422 rather than 500.
- def send_permission_request_safely
- provider_service.send_call_permission_request(
- @conversation.contact.phone_number.delete('+'),
- *permission_request_body_args
- )
- rescue StandardError => e
- Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
- nil
- end
-
- # Pass the inbox-level override only when present so the provider falls back
- # to the i18n default for inboxes that haven't customized the prompt.
- def permission_request_body_args
- custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
- custom_body ? [custom_body] : []
- end
-
- def emit_permission_requested_activity
- content = I18n.t(
- 'conversations.activity.whatsapp_call.permission_requested',
- contact_name: @conversation.contact.name
- )
- ::Conversations::ActivityMessageJob.perform_later(
- @conversation,
- { account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content }
- )
- end
-
- # Stash the outbound wamid so the reply webhook can match context.id back here.
- def record_permission_request_wamid(sent)
- attrs = (@conversation.additional_attributes || {}).merge(
- 'call_permission_requested_at' => Time.current.iso8601,
- 'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
- )
- @conversation.update!(additional_attributes: attrs)
+ render json: { status: status, conversation_id: @conversation.display_id }, status: :unprocessable_entity
end
def render_call_error(error)
diff --git a/enterprise/app/services/whatsapp/call_conversation_builder.rb b/enterprise/app/services/whatsapp/call_conversation_builder.rb
new file mode 100644
index 000000000..665f2fb20
--- /dev/null
+++ b/enterprise/app/services/whatsapp/call_conversation_builder.rb
@@ -0,0 +1,32 @@
+class Whatsapp::CallConversationBuilder
+ pattr_initialize [:inbox!, :contact!, :user!]
+
+ # Mirrors the continuity rule in Whatsapp::IncomingMessageBaseService#set_conversation.
+ # Locked inboxes hold a contact to one thread, so the caller is refused rather than given a second one.
+ def existing_conversation
+ return contact_conversations.first if inbox.lock_to_single_conversation
+
+ # Only threads the caller can open, else a newest-but-hidden thread would block the call.
+ Conversations::PermissionFilterService.new(
+ contact_conversations.where.not(status: :resolved), user, inbox.account
+ ).perform.first
+ end
+
+ def contact_conversations
+ inbox.conversations.where(contact_id: contact.id).order(last_activity_at: :desc)
+ end
+
+ # Unsaved, so callers can authorize the thread a call would open before dialing.
+ def new_conversation
+ inbox.account.conversations.new(inbox: inbox, contact: contact, assignee_id: user.id, status: :open)
+ end
+
+ # Locked so two agents calling the same fresh contact can't open two threads.
+ def perform!
+ contact_inbox = ContactInboxBuilder.new(contact: contact, inbox: inbox).perform
+
+ contact_inbox.with_lock do
+ existing_conversation || new_conversation.tap { |conversation| conversation.update!(contact_inbox: contact_inbox) }
+ end
+ end
+end
diff --git a/enterprise/app/services/whatsapp/call_permission_request_service.rb b/enterprise/app/services/whatsapp/call_permission_request_service.rb
new file mode 100644
index 000000000..65c4f1506
--- /dev/null
+++ b/enterprise/app/services/whatsapp/call_permission_request_service.rb
@@ -0,0 +1,63 @@
+# Meta error 138006 means the contact hasn't opted in to calls yet; send the opt-in template.
+class Whatsapp::CallPermissionRequestService
+ THROTTLE = 5.minutes
+
+ pattr_initialize [:conversation!]
+
+ # Locked so two agents calling the same contact can't both send the template.
+ def perform
+ conversation.with_lock do
+ next 'permission_pending' if throttled?
+
+ sent = send_request_safely
+ next 'failed' if sent.blank?
+
+ record_wamid(sent)
+ emit_activity
+ 'permission_requested'
+ end
+ end
+
+ private
+
+ def throttled?
+ last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
+ last_requested.present? && Time.zone.parse(last_requested) > THROTTLE.ago
+ end
+
+ # Treat transport errors as a falsy return so the caller renders 422 rather than 500.
+ def send_request_safely
+ provider_service.send_call_permission_request(conversation.contact.phone_number.delete('+'), *body_args)
+ rescue StandardError => e
+ Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
+ nil
+ end
+
+ # Pass the inbox-level override only when present so the provider falls back
+ # to the i18n default for inboxes that haven't customized the prompt.
+ def body_args
+ custom_body = conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
+ custom_body ? [custom_body] : []
+ end
+
+ def emit_activity
+ content = I18n.t('conversations.activity.whatsapp_call.permission_requested', contact_name: conversation.contact.name)
+ ::Conversations::ActivityMessageJob.perform_later(
+ conversation,
+ { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, content: content }
+ )
+ end
+
+ # Stash the outbound wamid so the reply webhook can match context.id back here.
+ def record_wamid(sent)
+ attrs = (conversation.additional_attributes || {}).merge(
+ 'call_permission_requested_at' => Time.current.iso8601,
+ 'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
+ )
+ conversation.update!(additional_attributes: attrs)
+ end
+
+ def provider_service
+ @provider_service ||= conversation.inbox.channel.provider_service
+ end
+end
diff --git a/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder b/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder
index bdb1fa204..920c5bb7d 100644
--- a/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/whatsapp_calls/initiate.json.jbuilder
@@ -2,4 +2,5 @@ json.status 'calling'
json.call_id @call.provider_call_id
json.id @call.id
json.message_id @message.id
+json.conversation_id @conversation.display_id
json.provider 'whatsapp'
From 89b83c65c843e87018fa2e6d190cf3fbc65c880e Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 21 Jul 2026 19:34:57 +0530
Subject: [PATCH 141/143] fix: close message generation popover when its
trigger scrolls away (#15114)
---
.../NewConversation/ComposeConversation.vue | 1 +
.../components-next/popover/Popover.vue | 30 ++++++++++++++++++-
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
index 02a00c703..2446e0e2b 100644
--- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
+ :close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue
index 5b67e572f..9d369133f 100644
--- a/app/javascript/dashboard/components-next/popover/Popover.vue
+++ b/app/javascript/dashboard/components-next/popover/Popover.vue
@@ -1,7 +1,11 @@