From ffa124d0d5475abca5c3872f1eec841d11177320 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 9 Sep 2025 21:38:37 -0700 Subject: [PATCH 01/72] fix: Use .find_by instead .where().first (#12402) While investigating a customer-reported issue, I found that some emails were appearing late in Chatwoot. The root cause was query timeouts. It only happened for emails with an in_reply_to header. In these cases, Chatwoot first checks if a message exists with message_id = in_reply_to. If not, it falls back to checking conversations where additional_attributes->>'in_reply_to' = ?. We were using: ```rb @inbox.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first ``` This looked harmless, but .first caused timeouts. Without .first, the query ran fine. The issue was the generated SQL: ```sql SELECT * FROM conversations WHERE inbox_id = $1 AND (additional_attributes->>'in_reply_to' = '') ORDER BY id ASC LIMIT $2; ``` The ORDER BY id forced a full scan, even with <10k records. The fix was to replace .first with .find_by: ```rb @inbox.conversations.find_by("additional_attributes->>'in_reply_to' = ?", in_reply_to) ``` This generates: ```sql SELECT * FROM conversations WHERE inbox_id = $1 AND (additional_attributes->>'in_reply_to' = '') LIMIT $2; ``` This avoids the scan and runs quickly without needing an index. By the way, Cursor and Claude failed [here](https://github.com/chatwoot/chatwoot/pull/12401), it just kept on adding the index without figuring out the root cause. Co-authored-by: Muhsin Keloth --- app/mailboxes/imap/imap_mailbox.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/mailboxes/imap/imap_mailbox.rb b/app/mailboxes/imap/imap_mailbox.rb index bd591b05a..5fea49722 100644 --- a/app/mailboxes/imap/imap_mailbox.rb +++ b/app/mailboxes/imap/imap_mailbox.rb @@ -42,7 +42,7 @@ class Imap::ImapMailbox message = @inbox.messages.find_by(source_id: in_reply_to) if message.nil? - @inbox.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first + @inbox.conversations.find_by("additional_attributes->>'in_reply_to' = ?", in_reply_to) else @inbox.conversations.find(message.conversation_id) end From 55633ab063b4e267f11a99e88dcd08ac9f7328d9 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 10 Sep 2025 12:07:21 +0530 Subject: [PATCH 02/72] feat: Agent assignment policy index page with CRUD actions (#12373) # Pull Request Template ## Description This PR incudes new Agent assignment policy index page with CRUD actions. Fixes https://linear.app/chatwoot/issue/CW-5570/feat-assignment-policy-index-page-with-actions ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom https://www.loom.com/share/17ab5ceca4854f179628a3b53f347e5a?sid=cb64e881-57fd-4ae1-921b-7648653cca33 ### Screenshots **Light mode** image **Dark mode** image image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../dashboard/api/assignmentPolicies.js | 36 +++ .../api/specs/assignmentPolicies.spec.js | 70 +++++ .../AssignmentPolicyCard.story.vue | 104 +++++++ .../AssignmentPolicyCard.vue | 133 +++++++++ .../components/CardPopover.vue | 103 +++++++ .../components/story/CardPopover.story.vue | 45 +++ app/javascript/dashboard/helper/commons.js | 15 + .../dashboard/helper/specs/commons.spec.js | 49 ++++ .../dashboard/i18n/locale/en/settings.json | 25 ++ .../dashboard/settings/SettingsLayout.vue | 4 +- .../settings/assignmentPolicy/Index.vue | 2 +- .../assignmentPolicy.routes.js | 10 + .../pages/AgentAssignmentIndexPage.vue | 118 ++++++++ .../components/ConfirmDeletePolicyDialog.vue | 50 ++++ app/javascript/dashboard/store/index.js | 2 + .../store/modules/assignmentPolicies.js | 156 ++++++++++ .../specs/assignmentPolicies/actions.spec.js | 214 ++++++++++++++ .../specs/assignmentPolicies/fixtures.js | 57 ++++ .../specs/assignmentPolicies/getters.spec.js | 49 ++++ .../assignmentPolicies/mutations.spec.js | 267 ++++++++++++++++++ .../dashboard/store/mutation-types.js | 10 + .../_assignment_policy.json.jbuilder | 1 + 22 files changed, 1517 insertions(+), 3 deletions(-) create mode 100644 app/javascript/dashboard/api/assignmentPolicies.js create mode 100644 app/javascript/dashboard/api/specs/assignmentPolicies.spec.js create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue create mode 100644 app/javascript/dashboard/store/modules/assignmentPolicies.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js diff --git a/app/javascript/dashboard/api/assignmentPolicies.js b/app/javascript/dashboard/api/assignmentPolicies.js new file mode 100644 index 000000000..e6baca97a --- /dev/null +++ b/app/javascript/dashboard/api/assignmentPolicies.js @@ -0,0 +1,36 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AssignmentPolicies extends ApiClient { + constructor() { + super('assignment_policies', { accountScoped: true }); + } + + getInboxes(policyId) { + return axios.get(`${this.url}/${policyId}/inboxes`); + } + + setInboxPolicy(inboxId, policyId) { + return axios.post( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`, + { + assignment_policy_id: policyId, + } + ); + } + + getInboxPolicy(inboxId) { + return axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } + + removeInboxPolicy(inboxId) { + return axios.delete( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } +} + +export default new AssignmentPolicies(); diff --git a/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js new file mode 100644 index 000000000..8d0aea7d0 --- /dev/null +++ b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js @@ -0,0 +1,70 @@ +import assignmentPolicies from '../assignmentPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AssignmentPoliciesAPI', () => { + it('creates correct instance', () => { + expect(assignmentPolicies).toBeInstanceOf(ApiClient); + expect(assignmentPolicies).toHaveProperty('get'); + expect(assignmentPolicies).toHaveProperty('show'); + expect(assignmentPolicies).toHaveProperty('create'); + expect(assignmentPolicies).toHaveProperty('update'); + expect(assignmentPolicies).toHaveProperty('delete'); + expect(assignmentPolicies).toHaveProperty('getInboxes'); + expect(assignmentPolicies).toHaveProperty('setInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('getInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('removeInboxPolicy'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getInboxes', () => { + assignmentPolicies.getInboxes(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/assignment_policies/123/inboxes' + ); + }); + + it('#setInboxPolicy', () => { + assignmentPolicies.setInboxPolicy(456, 123); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy', + { + assignment_policy_id: 123, + } + ); + }); + + it('#getInboxPolicy', () => { + assignmentPolicies.getInboxPolicy(456); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + + it('#removeInboxPolicy', () => { + assignmentPolicies.removeInboxPolicy(456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue new file mode 100644 index 000000000..cd6f1d49b --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -0,0 +1,104 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue new file mode 100644 index 000000000..fe9965777 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -0,0 +1,133 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue new file mode 100644 index 000000000..013e6d5fe --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue new file mode 100644 index 000000000..d010c16f9 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/javascript/dashboard/helper/commons.js b/app/javascript/dashboard/helper/commons.js index b12d1aa3d..3be7538bb 100644 --- a/app/javascript/dashboard/helper/commons.js +++ b/app/javascript/dashboard/helper/commons.js @@ -96,3 +96,18 @@ export const sanitizeVariableSearchKey = (searchKey = '') => { .replace(/,/g, '') // remove commas .trim(); }; + +/** + * Convert underscore-separated string to title case. + * Eg. "round_robin" => "Round Robin" + * @param {string} str + * @returns {string} + */ +export const formatToTitleCase = str => { + return ( + str + ?.replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + .trim() || '' + ); +}; diff --git a/app/javascript/dashboard/helper/specs/commons.spec.js b/app/javascript/dashboard/helper/specs/commons.spec.js index 466cdcf45..d892d3a94 100644 --- a/app/javascript/dashboard/helper/specs/commons.spec.js +++ b/app/javascript/dashboard/helper/specs/commons.spec.js @@ -5,6 +5,7 @@ import { convertToCategorySlug, convertToPortalSlug, sanitizeVariableSearchKey, + formatToTitleCase, } from '../commons'; describe('#getTypingUsersText', () => { @@ -142,3 +143,51 @@ describe('sanitizeVariableSearchKey', () => { expect(sanitizeVariableSearchKey()).toBe(''); }); }); + +describe('formatToTitleCase', () => { + it('converts underscore-separated string to title case', () => { + expect(formatToTitleCase('round_robin')).toBe('Round Robin'); + }); + + it('converts single word to title case', () => { + expect(formatToTitleCase('priority')).toBe('Priority'); + }); + + it('converts multiple underscores to title case', () => { + expect(formatToTitleCase('auto_assignment_policy')).toBe( + 'Auto Assignment Policy' + ); + }); + + it('handles already capitalized words', () => { + expect(formatToTitleCase('HIGH_PRIORITY')).toBe('HIGH PRIORITY'); + }); + + it('handles mixed case with underscores', () => { + expect(formatToTitleCase('first_Name_last')).toBe('First Name Last'); + }); + + it('handles empty string', () => { + expect(formatToTitleCase('')).toBe(''); + }); + + it('handles null input', () => { + expect(formatToTitleCase(null)).toBe(''); + }); + + it('handles undefined input', () => { + expect(formatToTitleCase(undefined)).toBe(''); + }); + + it('handles string without underscores', () => { + expect(formatToTitleCase('hello')).toBe('Hello'); + }); + + it('handles string with numbers', () => { + expect(formatToTitleCase('priority_1_high')).toBe('Priority 1 High'); + }); + + it('handles leading and trailing underscores', () => { + expect(formatToTitleCase('_leading_trailing_')).toBe('Leading Trailing'); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 2e24bace4..c0367ea9f 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -451,6 +451,31 @@ "Add agents to a policy - one policy per agent" ] } + }, + "AGENT_ASSIGNMENT_POLICY": { + "INDEX": { + "HEADER": { + "TITLE": "Assignment policy", + "CREATE_POLICY": "New policy" + }, + "CARD": { + "ORDER": "Order", + "PRIORITY": "Priority", + "ACTIVE": "Active", + "INACTIVE": "Inactive", + "POPOVER": "Added inboxes", + "EDIT": "Edit" + }, + "NO_RECORDS_FOUND": "No assignment policies found" + }, + "DELETE_POLICY": { + "TITLE": "Delete policy", + "DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.", + "CONFIRM_BUTTON_LABEL": "Delete", + "CANCEL_BUTTON_LABEL": "Cancel", + "SUCCESS_MESSAGE": "Assignment policy deleted successfully", + "ERROR_MESSAGE": "Failed to delete assignment policy" + } } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue index 44bad28c1..9e34cb384 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -23,7 +23,7 @@ defineProps({
-
+
@@ -37,6 +37,6 @@ defineProps({ -
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue index 6b0f88033..b41d9990a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue @@ -11,7 +11,7 @@ const { t } = useI18n(); const agentAssignments = computed(() => [ { - key: 'assignment_policy', + key: 'agent_assignment_policy_index', title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'), description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'), features: [ diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js index 2d62674b0..6b4c35da3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js @@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import AssignmentPolicyIndex from './Index.vue'; +import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue'; export default { routes: [ @@ -24,6 +25,15 @@ export default { permissions: ['administrator'], }, }, + { + path: 'assignment', + name: 'agent_assignment_policy_index', + component: AgentAssignmentIndex, + meta: { + featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, + permissions: ['administrator'], + }, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue new file mode 100644 index 000000000..e931d6bbd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue @@ -0,0 +1,118 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue new file mode 100644 index 000000000..8cf13bcd5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 5a020dda6..291031ffd 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -3,6 +3,7 @@ import { createStore } from 'vuex'; import accounts from './modules/accounts'; import agentBots from './modules/agentBots'; import agents from './modules/agents'; +import assignmentPolicies from './modules/assignmentPolicies'; import articles from './modules/helpCenterArticles'; import attributes from './modules/attributes'; import auditlogs from './modules/auditlogs'; @@ -63,6 +64,7 @@ export default createStore({ accounts, agentBots, agents, + assignmentPolicies, articles, attributes, auditlogs, diff --git a/app/javascript/dashboard/store/modules/assignmentPolicies.js b/app/javascript/dashboard/store/modules/assignmentPolicies.js new file mode 100644 index 000000000..80c903c4e --- /dev/null +++ b/app/javascript/dashboard/store/modules/assignmentPolicies.js @@ -0,0 +1,156 @@ +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import types from '../mutation-types'; +import AssignmentPoliciesAPI from '../../api/assignmentPolicies'; +import { throwErrorMessage } from '../utils/api'; +import camelcaseKeys from 'camelcase-keys'; + +export const state = { + records: [], + uiFlags: { + isFetching: false, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }, + inboxUiFlags: { + isFetching: false, + }, +}; + +export const getters = { + getAssignmentPolicies(_state) { + return _state.records; + }, + getUIFlags(_state) { + return _state.uiFlags; + }, + getInboxUiFlags(_state) { + return _state.inboxUiFlags; + }, + getAssignmentPolicyById: _state => id => { + return _state.records.find(record => record.id === Number(id)) || {}; + }, +}; + +export const actions = { + get: async function get({ commit }) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }); + try { + const response = await AssignmentPoliciesAPI.get(); + commit(types.SET_ASSIGNMENT_POLICIES, camelcaseKeys(response.data)); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }); + } + }, + + show: async function show({ commit }, policyId) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }); + try { + const response = await AssignmentPoliciesAPI.show(policyId); + const policy = camelcaseKeys(response.data); + commit(types.EDIT_ASSIGNMENT_POLICY, policy); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }); + } + }, + + create: async function create({ commit }, policyObj) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }); + try { + const response = await AssignmentPoliciesAPI.create(policyObj); + commit(types.ADD_ASSIGNMENT_POLICY, camelcaseKeys(response.data)); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }); + } + }, + + update: async function update({ commit }, { id, ...policyParams }) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }); + try { + const response = await AssignmentPoliciesAPI.update(id, policyParams); + commit(types.EDIT_ASSIGNMENT_POLICY, camelcaseKeys(response.data)); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }); + } + }, + + delete: async function deletePolicy({ commit }, policyId) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }); + try { + await AssignmentPoliciesAPI.delete(policyId); + commit(types.DELETE_ASSIGNMENT_POLICY, policyId); + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }); + } + }, + + getInboxes: async function getInboxes({ commit }, policyId) { + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }); + try { + const response = await AssignmentPoliciesAPI.getInboxes(policyId); + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES, { + policyId, + inboxes: camelcaseKeys(response.data.inboxes), + }); + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { + isFetching: false, + }); + } + }, +}; + +export const mutations = { + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG](_state, data) { + _state.uiFlags = { + ..._state.uiFlags, + ...data, + }; + }, + + [types.SET_ASSIGNMENT_POLICIES]: MutationHelpers.set, + [types.ADD_ASSIGNMENT_POLICY]: MutationHelpers.create, + [types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.update, + [types.DELETE_ASSIGNMENT_POLICY]: MutationHelpers.destroy, + + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](_state, data) { + _state.inboxUiFlags = { + ..._state.inboxUiFlags, + ...data, + }; + }, + + [types.SET_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxes }) { + const policy = _state.records.find(p => p.id === policyId); + if (policy) { + policy.inboxes = inboxes; + } + }, +}; + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +}; diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js new file mode 100644 index 000000000..5358144e8 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js @@ -0,0 +1,214 @@ +import axios from 'axios'; +import { actions } from '../../assignmentPolicies'; +import types from '../../../mutation-types'; +import assignmentPoliciesList, { camelCaseFixtures } from './fixtures'; +import camelcaseKeys from 'camelcase-keys'; + +const commit = vi.fn(); + +global.axios = axios; +vi.mock('axios'); +vi.mock('camelcase-keys'); +vi.mock('../../../utils/api'); + +describe('#actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('#get', () => { + it('sends correct actions if API is success', async () => { + axios.get.mockResolvedValue({ data: assignmentPoliciesList }); + camelcaseKeys.mockReturnValue(camelCaseFixtures); + + await actions.get({ commit }); + + expect(camelcaseKeys).toHaveBeenCalledWith(assignmentPoliciesList); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }], + [types.SET_ASSIGNMENT_POLICIES, camelCaseFixtures], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + + await actions.get({ commit }); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }], + ]); + }); + }); + + describe('#show', () => { + it('sends correct actions if API is success', async () => { + const policyData = assignmentPoliciesList[0]; + const camelCasedPolicy = camelCaseFixtures[0]; + + axios.get.mockResolvedValue({ data: policyData }); + camelcaseKeys.mockReturnValue(camelCasedPolicy); + + await actions.show({ commit }, 1); + + expect(camelcaseKeys).toHaveBeenCalledWith(policyData); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }], + [types.EDIT_ASSIGNMENT_POLICY, camelCasedPolicy], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Not found' }); + + await actions.show({ commit }, 1); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }], + ]); + }); + }); + + describe('#create', () => { + it('sends correct actions if API is success', async () => { + const newPolicy = assignmentPoliciesList[0]; + const camelCasedData = camelCaseFixtures[0]; + + axios.post.mockResolvedValue({ data: newPolicy }); + camelcaseKeys.mockReturnValue(camelCasedData); + + const result = await actions.create({ commit }, newPolicy); + + expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }], + [types.ADD_ASSIGNMENT_POLICY, camelCasedData], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }], + ]); + expect(result).toEqual(newPolicy); + }); + + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue(new Error('Validation error')); + + await expect(actions.create({ commit }, {})).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }], + ]); + }); + }); + + describe('#update', () => { + it('sends correct actions if API is success', async () => { + const updateParams = { id: 1, name: 'Updated Policy' }; + const responseData = { + ...assignmentPoliciesList[0], + name: 'Updated Policy', + }; + const camelCasedData = { + ...camelCaseFixtures[0], + name: 'Updated Policy', + }; + + axios.patch.mockResolvedValue({ data: responseData }); + camelcaseKeys.mockReturnValue(camelCasedData); + + const result = await actions.update({ commit }, updateParams); + + expect(camelcaseKeys).toHaveBeenCalledWith(responseData); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }], + [types.EDIT_ASSIGNMENT_POLICY, camelCasedData], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }], + ]); + expect(result).toEqual(responseData); + }); + + it('sends correct actions if API is error', async () => { + axios.patch.mockRejectedValue(new Error('Validation error')); + + await expect( + actions.update({ commit }, { id: 1, name: 'Test' }) + ).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }], + ]); + }); + }); + + describe('#delete', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + axios.delete.mockResolvedValue({}); + + await actions.delete({ commit }, policyId); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }], + [types.DELETE_ASSIGNMENT_POLICY, policyId], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue(new Error('Not found')); + + await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + + describe('#getInboxes', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + const inboxData = { + inboxes: [ + { id: 1, name: 'Support' }, + { id: 2, name: 'Sales' }, + ], + }; + const camelCasedInboxes = [ + { id: 1, name: 'Support' }, + { id: 2, name: 'Sales' }, + ]; + + axios.get.mockResolvedValue({ data: inboxData }); + camelcaseKeys.mockReturnValue(camelCasedInboxes); + + await actions.getInboxes({ commit }, policyId); + + expect(camelcaseKeys).toHaveBeenCalledWith(inboxData.inboxes); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }], + [ + types.SET_ASSIGNMENT_POLICIES_INBOXES, + { policyId, inboxes: camelCasedInboxes }, + ], + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: false }], + ]); + }); + + it('sends correct actions if API fails', async () => { + axios.get.mockRejectedValue(new Error('API Error')); + + await expect(actions.getInboxes({ commit }, 1)).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }], + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: false }], + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js new file mode 100644 index 000000000..1b5ed25af --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js @@ -0,0 +1,57 @@ +export default [ + { + id: 1, + name: 'Round Robin Policy', + description: 'Distributes conversations evenly among agents', + assignment_order: 'round_robin', + conversation_priority: 'earliest_created', + fair_distribution_limit: 100, + fair_distribution_window: 3600, + enabled: true, + assigned_inbox_count: 3, + created_at: 1704110400, + updated_at: 1704110400, + }, + { + id: 2, + name: 'Balanced Policy', + description: 'Assigns conversations based on agent capacity', + assignment_order: 'balanced', + conversation_priority: 'longest_waiting', + fair_distribution_limit: 50, + fair_distribution_window: 1800, + enabled: false, + assigned_inbox_count: 1, + created_at: 1704114000, + updated_at: 1704114000, + }, +]; + +export const camelCaseFixtures = [ + { + id: 1, + name: 'Round Robin Policy', + description: 'Distributes conversations evenly among agents', + assignmentOrder: 'round_robin', + conversationPriority: 'earliest_created', + fairDistributionLimit: 100, + fairDistributionWindow: 3600, + enabled: true, + assignedInboxCount: 3, + createdAt: 1704110400, + updatedAt: 1704110400, + }, + { + id: 2, + name: 'Balanced Policy', + description: 'Assigns conversations based on agent capacity', + assignmentOrder: 'balanced', + conversationPriority: 'longest_waiting', + fairDistributionLimit: 50, + fairDistributionWindow: 1800, + enabled: false, + assignedInboxCount: 1, + createdAt: 1704114000, + updatedAt: 1704114000, + }, +]; diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js new file mode 100644 index 000000000..7e0e2041c --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js @@ -0,0 +1,49 @@ +import { getters } from '../../assignmentPolicies'; +import assignmentPoliciesList from './fixtures'; + +describe('#getters', () => { + it('getAssignmentPolicies', () => { + const state = { records: assignmentPoliciesList }; + expect(getters.getAssignmentPolicies(state)).toEqual( + assignmentPoliciesList + ); + }); + + it('getUIFlags', () => { + const state = { + uiFlags: { + isFetching: true, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }, + }; + expect(getters.getUIFlags(state)).toEqual({ + isFetching: true, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }); + }); + + it('getInboxUiFlags', () => { + const state = { + inboxUiFlags: { + isFetching: false, + }, + }; + expect(getters.getInboxUiFlags(state)).toEqual({ + isFetching: false, + }); + }); + + it('getAssignmentPolicyById', () => { + const state = { records: assignmentPoliciesList }; + expect(getters.getAssignmentPolicyById(state)(1)).toEqual( + assignmentPoliciesList[0] + ); + expect(getters.getAssignmentPolicyById(state)(3)).toEqual({}); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js new file mode 100644 index 000000000..58d5527ca --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js @@ -0,0 +1,267 @@ +import { mutations } from '../../assignmentPolicies'; +import types from '../../../mutation-types'; +import assignmentPoliciesList from './fixtures'; + +describe('#mutations', () => { + describe('#SET_ASSIGNMENT_POLICIES_UI_FLAG', () => { + it('sets single ui flag', () => { + const state = { + uiFlags: { + isFetching: false, + isCreating: false, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.uiFlags).toEqual({ + isFetching: true, + isCreating: false, + }); + }); + + it('sets multiple ui flags', () => { + const state = { + uiFlags: { + isFetching: false, + isCreating: false, + isUpdating: false, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](state, { + isFetching: true, + isCreating: true, + }); + + expect(state.uiFlags).toEqual({ + isFetching: true, + isCreating: true, + isUpdating: false, + }); + }); + }); + + describe('#SET_ASSIGNMENT_POLICIES', () => { + it('sets assignment policies records', () => { + const state = { records: [] }; + + mutations[types.SET_ASSIGNMENT_POLICIES](state, assignmentPoliciesList); + + expect(state.records).toEqual(assignmentPoliciesList); + }); + + it('replaces existing records', () => { + const state = { records: [{ id: 999, name: 'Old Policy' }] }; + + mutations[types.SET_ASSIGNMENT_POLICIES](state, assignmentPoliciesList); + + expect(state.records).toEqual(assignmentPoliciesList); + }); + }); + + describe('#ADD_ASSIGNMENT_POLICY', () => { + it('adds new policy to empty records', () => { + const state = { records: [] }; + + mutations[types.ADD_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]); + + expect(state.records).toEqual([assignmentPoliciesList[0]]); + }); + + it('adds new policy to existing records', () => { + const state = { records: [assignmentPoliciesList[0]] }; + + mutations[types.ADD_ASSIGNMENT_POLICY](state, assignmentPoliciesList[1]); + + expect(state.records).toEqual([ + assignmentPoliciesList[0], + assignmentPoliciesList[1], + ]); + }); + }); + + describe('#EDIT_ASSIGNMENT_POLICY', () => { + it('updates existing policy by id', () => { + const state = { + records: [ + { ...assignmentPoliciesList[0] }, + { ...assignmentPoliciesList[1] }, + ], + }; + + const updatedPolicy = { + ...assignmentPoliciesList[0], + name: 'Updated Policy Name', + description: 'Updated Description', + }; + + mutations[types.EDIT_ASSIGNMENT_POLICY](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + expect(state.records[1]).toEqual(assignmentPoliciesList[1]); + }); + + it('updates policy with camelCase properties', () => { + const camelCasePolicy = { + id: 1, + name: 'Camel Case Policy', + assignmentOrder: 'round_robin', + conversationPriority: 'earliest_created', + }; + + const state = { + records: [camelCasePolicy], + }; + + const updatedPolicy = { + ...camelCasePolicy, + name: 'Updated Camel Case', + assignmentOrder: 'balanced', + }; + + mutations[types.EDIT_ASSIGNMENT_POLICY](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + }); + + it('does nothing if policy id not found', () => { + const state = { + records: [assignmentPoliciesList[0]], + }; + + const nonExistentPolicy = { + id: 999, + name: 'Non-existent', + }; + + const originalRecords = [...state.records]; + mutations[types.EDIT_ASSIGNMENT_POLICY](state, nonExistentPolicy); + + expect(state.records).toEqual(originalRecords); + }); + }); + + describe('#DELETE_ASSIGNMENT_POLICY', () => { + it('deletes policy by id', () => { + const state = { + records: [assignmentPoliciesList[0], assignmentPoliciesList[1]], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICY](state, 1); + + expect(state.records).toEqual([assignmentPoliciesList[1]]); + }); + + it('does nothing if id not found', () => { + const state = { + records: [assignmentPoliciesList[0]], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICY](state, 999); + + expect(state.records).toEqual([assignmentPoliciesList[0]]); + }); + + it('handles empty records', () => { + const state = { records: [] }; + + mutations[types.DELETE_ASSIGNMENT_POLICY](state, 1); + + expect(state.records).toEqual([]); + }); + }); + + describe('#SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', () => { + it('sets inbox ui flags', () => { + const state = { + inboxUiFlags: { + isFetching: false, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.inboxUiFlags).toEqual({ + isFetching: true, + }); + }); + + it('merges with existing flags', () => { + const state = { + inboxUiFlags: { + isFetching: false, + isLoading: true, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.inboxUiFlags).toEqual({ + isFetching: true, + isLoading: true, + }); + }); + }); + + describe('#SET_ASSIGNMENT_POLICIES_INBOXES', () => { + it('sets inboxes for existing policy', () => { + const mockInboxes = [ + { id: 1, name: 'Support Inbox' }, + { id: 2, name: 'Sales Inbox' }, + ]; + + const state = { + records: [ + { id: 1, name: 'Policy 1', inboxes: [] }, + { id: 2, name: 'Policy 2', inboxes: [] }, + ], + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxes: mockInboxes, + }); + + expect(state.records[0].inboxes).toEqual(mockInboxes); + expect(state.records[1].inboxes).toEqual([]); + }); + + it('replaces existing inboxes', () => { + const oldInboxes = [{ id: 99, name: 'Old Inbox' }]; + const newInboxes = [{ id: 1, name: 'New Inbox' }]; + + const state = { + records: [{ id: 1, name: 'Policy 1', inboxes: oldInboxes }], + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxes: newInboxes, + }); + + expect(state.records[0].inboxes).toEqual(newInboxes); + }); + + it('does nothing if policy not found', () => { + const state = { + records: [{ id: 1, name: 'Policy 1', inboxes: [] }], + }; + + const originalState = JSON.parse(JSON.stringify(state)); + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 999, + inboxes: [{ id: 1, name: 'Test' }], + }); + + expect(state).toEqual(originalState); + }); + }); +}); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index a63fec2d1..a6fbefa17 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -348,4 +348,14 @@ export default { SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC', TOGGLE_TEAM_CONVERSATION_METRIC_LOADING: 'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING', + + // Assignment Policies + SET_ASSIGNMENT_POLICIES_UI_FLAG: 'SET_ASSIGNMENT_POLICIES_UI_FLAG', + SET_ASSIGNMENT_POLICIES: 'SET_ASSIGNMENT_POLICIES', + ADD_ASSIGNMENT_POLICY: 'ADD_ASSIGNMENT_POLICY', + EDIT_ASSIGNMENT_POLICY: 'EDIT_ASSIGNMENT_POLICY', + DELETE_ASSIGNMENT_POLICY: 'DELETE_ASSIGNMENT_POLICY', + SET_ASSIGNMENT_POLICIES_INBOXES: 'SET_ASSIGNMENT_POLICIES_INBOXES', + SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG: + 'SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', }; diff --git a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder index b48307a94..cf09a2949 100644 --- a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder +++ b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder @@ -6,5 +6,6 @@ json.conversation_priority assignment_policy.conversation_priority json.fair_distribution_limit assignment_policy.fair_distribution_limit json.fair_distribution_window assignment_policy.fair_distribution_window json.enabled assignment_policy.enabled +json.assigned_inbox_count assignment_policy.inboxes.count json.created_at assignment_policy.created_at.to_i json.updated_at assignment_policy.updated_at.to_i From 7554156abe41bb8f1e0af43c2c2d942435db6948 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 10 Sep 2025 14:12:22 +0530 Subject: [PATCH 03/72] chore: Account switching issue in newly added accounts (#12403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system determines a user’s active account by checking the `active_at` field in the `account_users` table and selecting the most recently active account: ```ruby def active_account_user account_users.order(active_at: :desc)&.first end ``` This works fine when all accounts have a valid active_at timestamp. **Problem** When a user is added to a new account, the `active_at` value is NULL (because the account has never been explicitly activated). Ordering by active_at DESC produces inconsistent results across databases, since handling of NULL values differs (sometimes treated as high, sometimes low). As a result: - Mobile apps (critical impact): `/profile` returns the wrong account. The UI keeps showing the old account even after switching, and restarting does not fix it. - Web app (accidentally works): Appears correct because the active account is inferred from the browser URL, but the backend API is still wrong. **Root Cause** - The ordering logic did not account for NULL `active_at`. - New accounts without active_at sometimes get incorrectly prioritized as the “active” account. **Solution** Explicitly ensure that accounts with NULL active_at are sorted after accounts with real timestamps by using NULLS LAST: ```ruby def active_account_user account_users.order(Arel.sql('active_at DESC NULLS LAST, id DESC'))&.first end ``` - Accounts with actual `active_at` values will always be prioritized. - New accounts (with NULL active_at) will be placed at the bottom until the user explicitly activates them. - Adding id DESC as a secondary ordering ensures consistent tie-breaking when multiple accounts have the same `active_at`. --- app/models/concerns/user_attribute_helpers.rb | 2 +- spec/models/user_spec.rb | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/user_attribute_helpers.rb b/app/models/concerns/user_attribute_helpers.rb index 32ff026ac..442d166ef 100644 --- a/app/models/concerns/user_attribute_helpers.rb +++ b/app/models/concerns/user_attribute_helpers.rb @@ -18,7 +18,7 @@ module UserAttributeHelpers end def active_account_user - account_users.order(active_at: :desc)&.first + account_users.order(Arel.sql('active_at DESC NULLS LAST'))&.first end def current_account_user diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c740c67fb..845ec0625 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -110,4 +110,41 @@ RSpec.describe User do expect(new_user.email).to eq('test123@test.com') end end + + describe '#active_account_user' do + let(:user) { create(:user) } + let(:account1) { create(:account) } + let(:account2) { create(:account) } + let(:account3) { create(:account) } + + before do + # Create account_users with different active_at values + create(:account_user, user: user, account: account1, active_at: 2.days.ago) + create(:account_user, user: user, account: account2, active_at: 1.day.ago) + create(:account_user, user: user, account: account3, active_at: nil) # New account with NULL active_at + end + + it 'returns the account_user with the most recent active_at, prioritizing timestamps over NULL values' do + # Should return account2 (most recent timestamp) even though account3 was created last with NULL active_at + expect(user.active_account_user.account_id).to eq(account2.id) + end + + it 'returns NULL active_at account only when no other accounts have active_at' do + # Remove active_at from all accounts + user.account_users.each { |au| au.update!(active_at: nil) } + + # Should return one of the accounts (behavior is undefined but consistent) + expect(user.active_account_user).to be_present + end + + context 'when multiple accounts have NULL active_at' do + before do + create(:account_user, user: user, account: create(:account), active_at: nil) + end + + it 'still prioritizes accounts with timestamps' do + expect(user.active_account_user.account_id).to eq(account2.id) + end + end + end end From aba4e8bc53e4fcb9a3f277b4cb43f75cf45c5994 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 10 Sep 2025 17:33:24 +0530 Subject: [PATCH 04/72] docs: Update conversation toggle API with snooze & pending (#12406) ## Changes - Update conversation toggle API with documentation of additional statuses and options which are supported - snoozed , snooze_until & pending fixes: https://github.com/chatwoot/chatwoot/issues/12137 --- .../conversation/toggle_status.yml | 32 ++++++++++++++-- swagger/swagger.json | 37 +++++++++++++++++-- swagger/tag_groups/application_swagger.json | 37 +++++++++++++++++-- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/swagger/paths/application/conversation/toggle_status.yml b/swagger/paths/application/conversation/toggle_status.yml index 9c6f9ee6a..80cf47c65 100644 --- a/swagger/paths/application/conversation/toggle_status.yml +++ b/swagger/paths/application/conversation/toggle_status.yml @@ -2,7 +2,13 @@ tags: - Conversations operationId: toggle-status-of-a-conversation summary: Toggle Status -description: Toggles the status of the conversation between open and resolved +description: |- + Toggle the status of a conversation. Pass `status` to explicitly set the + conversation state. Use `snoozed` along with `snoozed_until` to snooze a + conversation until a specific time. If `snoozed_until` is omitted, the + conversation is snoozed until the next reply from the contact. Regardless + of the value provided, snoozed conversations always reopen on the next + reply from the contact. security: - userApiKey: [] - agentBotApiKey: [] @@ -17,16 +23,36 @@ requestBody: properties: status: type: string - enum: ['open', 'resolved', 'pending'] + enum: ['open', 'resolved', 'pending', 'snoozed'] description: The status of the conversation example: open + snoozed_until: + type: number + description: When status is `snoozed`, schedule the reopen time as a Unix timestamp in seconds. + If not provided, the conversation is snoozed until the next + customer reply. The conversation always reopens when the + customer replies. + example: 1757506877 responses: '200': description: Success content: application/json: schema: - $ref: '#/components/schemas/conversation_status_toggle' + type: object + properties: + meta: + type: object + payload: + type: object + properties: + success: + type: boolean + current_status: + type: string + enum: ['open', 'resolved', 'pending', 'snoozed'] + conversation_id: + type: number '404': description: Conversation not found content: diff --git a/swagger/swagger.json b/swagger/swagger.json index aa7455e7f..39022bf84 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -4673,7 +4673,7 @@ ], "operationId": "toggle-status-of-a-conversation", "summary": "Toggle Status", - "description": "Toggles the status of the conversation between open and resolved", + "description": "Toggle the status of a conversation. Pass `status` to explicitly set the\nconversation state. Use `snoozed` along with `snoozed_until` to snooze a\nconversation until a specific time. If `snoozed_until` is omitted, the\nconversation is snoozed until the next reply from the contact. Regardless\nof the value provided, snoozed conversations always reopen on the next\nreply from the contact.", "security": [ { "userApiKey": [] @@ -4697,10 +4697,16 @@ "enum": [ "open", "resolved", - "pending" + "pending", + "snoozed" ], "description": "The status of the conversation", "example": "open" + }, + "snoozed_until": { + "type": "number", + "description": "When status is `snoozed`, schedule the reopen time as a Unix timestamp in seconds. If not provided, the conversation is snoozed until the next customer reply. The conversation always reopens when the customer replies.", + "example": 1757506877 } } } @@ -4713,7 +4719,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/conversation_status_toggle" + "type": "object", + "properties": { + "meta": { + "type": "object" + }, + "payload": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "current_status": { + "type": "string", + "enum": [ + "open", + "resolved", + "pending", + "snoozed" + ] + }, + "conversation_id": { + "type": "number" + } + } + } + } } } } diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index a36443f81..b26b913bc 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -3070,7 +3070,7 @@ ], "operationId": "toggle-status-of-a-conversation", "summary": "Toggle Status", - "description": "Toggles the status of the conversation between open and resolved", + "description": "Toggle the status of a conversation. Pass `status` to explicitly set the\nconversation state. Use `snoozed` along with `snoozed_until` to snooze a\nconversation until a specific time. If `snoozed_until` is omitted, the\nconversation is snoozed until the next reply from the contact. Regardless\nof the value provided, snoozed conversations always reopen on the next\nreply from the contact.", "security": [ { "userApiKey": [] @@ -3094,10 +3094,16 @@ "enum": [ "open", "resolved", - "pending" + "pending", + "snoozed" ], "description": "The status of the conversation", "example": "open" + }, + "snoozed_until": { + "type": "number", + "description": "When status is `snoozed`, schedule the reopen time as a Unix timestamp in seconds. If not provided, the conversation is snoozed until the next customer reply. The conversation always reopens when the customer replies.", + "example": 1757506877 } } } @@ -3110,7 +3116,32 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/conversation_status_toggle" + "type": "object", + "properties": { + "meta": { + "type": "object" + }, + "payload": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "current_status": { + "type": "string", + "enum": [ + "open", + "resolved", + "pending", + "snoozed" + ] + }, + "conversation_id": { + "type": "number" + } + } + } + } } } } From 257df305899fb185869bdcb8e709ca7ce41953f7 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 10 Sep 2025 20:02:11 +0530 Subject: [PATCH 05/72] feat: Agent assignment policy Create/Edit pages (#12400) --- .../components/AddDataDropdown.vue | 119 ++++++++ .../AssignmentPolicy/components/BaseInfo.vue | 126 +++++++++ .../AssignmentPolicy/components/DataTable.vue | 80 ++++++ .../components/FairDistribution.vue | 86 ++++++ .../AssignmentPolicy/components/RadioCard.vue | 60 +++++ .../story/AddDataDropdown.story.vue | 58 ++++ .../components/story/BaseInfo.story.vue | 33 +++ .../components/story/DataTable.story.vue | 62 +++++ .../story/FairDistribution.story.vue | 25 ++ .../components/story/RadioCard.story.vue | 61 +++++ .../components-next/input/DurationInput.vue | 4 +- .../dashboard/components-next/input/Input.vue | 6 + .../dashboard/i18n/locale/en/settings.json | 94 +++++++ .../assignmentPolicy.routes.js | 20 ++ .../settings/assignmentPolicy/constants.js | 17 ++ .../pages/AgentAssignmentCreatePage.vue | 74 +++++ .../pages/AgentAssignmentEditPage.vue | 197 ++++++++++++++ .../pages/AgentAssignmentIndexPage.vue | 12 +- .../components/AgentAssignmentPolicyForm.vue | 254 ++++++++++++++++++ .../pages/components/ConfirmInboxDialog.vue | 59 ++++ .../store/modules/assignmentPolicies.js | 86 +++++- .../dashboard/store/modules/inboxes.js | 3 + .../specs/assignmentPolicies/actions.spec.js | 114 +++++++- .../specs/assignmentPolicies/getters.spec.js | 2 + .../assignmentPolicies/mutations.spec.js | 118 ++++++++ .../dashboard/store/mutation-types.js | 3 + 26 files changed, 1765 insertions(+), 8 deletions(-) create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentCreatePage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue new file mode 100644 index 000000000..c664d8929 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue @@ -0,0 +1,119 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue new file mode 100644 index 000000000..6b3a25e48 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue @@ -0,0 +1,126 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue new file mode 100644 index 000000000..986c69a1f --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue new file mode 100644 index 000000000..b54ec9ee5 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue new file mode 100644 index 000000000..3d0c8a8b3 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/RadioCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue new file mode 100644 index 000000000..2ac4d8854 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue @@ -0,0 +1,58 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue new file mode 100644 index 000000000..a3bfe9bee --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/BaseInfo.story.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue new file mode 100644 index 000000000..912b1fdfc --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue new file mode 100644 index 000000000..edec5fc92 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/FairDistribution.story.vue @@ -0,0 +1,25 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue new file mode 100644 index 000000000..df1f8655c --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/javascript/dashboard/components-next/input/DurationInput.vue b/app/javascript/dashboard/components-next/input/DurationInput.vue index 802e1d500..7a9fbc12d 100644 --- a/app/javascript/dashboard/components-next/input/DurationInput.vue +++ b/app/javascript/dashboard/components-next/input/DurationInput.vue @@ -78,6 +78,8 @@ watch(unit, () => { - + diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index 6288f7452..f20c43449 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -15,6 +15,7 @@ const props = defineProps({ validator: value => ['info', 'error', 'success'].includes(value), }, min: { type: String, default: '' }, + max: { type: String, default: '' }, autofocus: { type: Boolean, default: false }, }); @@ -108,6 +109,11 @@ onMounted(() => { :placeholder="placeholder" :disabled="disabled" :min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined" + :max=" + ['date', 'datetime-local', 'time', 'number'].includes(type) + ? max + : undefined + " class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out" @input="handleInput" @focus="handleFocus" diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index c0367ea9f..fd7247396 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -468,6 +468,100 @@ }, "NO_RECORDS_FOUND": "No assignment policies found" }, + "CREATE": { + "HEADER": { + "TITLE": "Create assignment policy" + }, + "CREATE_BUTTON": "Create policy", + "API": { + "SUCCESS_MESSAGE": "Assignment policy created successfully", + "ERROR_MESSAGE": "Failed to create assignment policy" + } + }, + "EDIT": { + "HEADER": { + "TITLE": "Edit assignment policy" + }, + "EDIT_BUTTON": "Update policy", + "CONFIRM_ADD_INBOX_DIALOG": { + "TITLE": "Add inbox", + "DESCRIPTION": "{inboxName} inbox is already linked to another policy. Are you sure you want to link it to this policy? It will be unlinked from the other policy.", + "CONFIRM_BUTTON_LABEL": "Continue", + "CANCEL_BUTTON_LABEL": "Cancel" + }, + "API": { + "SUCCESS_MESSAGE": "Assignment policy updated successfully", + "ERROR_MESSAGE": "Failed to update assignment policy" + }, + "INBOX_API": { + "ADD": { + "SUCCESS_MESSAGE": "Inbox added to policy successfully", + "ERROR_MESSAGE": "Failed to add inbox to policy" + }, + "REMOVE": { + "SUCCESS_MESSAGE": "Inbox removed from policy successfully", + "ERROR_MESSAGE": "Failed to remove inbox from policy" + } + } + }, + "FORM": { + "NAME": { + "LABEL": "Policy name:", + "PLACEHOLDER": "Enter policy name" + }, + "DESCRIPTION": { + "LABEL": "Description:", + "PLACEHOLDER": "Enter description" + }, + "STATUS": { + "LABEL": "Status:", + "PLACEHOLDER": "Select status", + "ACTIVE": "Policy is active", + "INACTIVE": "Policy is inactive" + }, + "ASSIGNMENT_ORDER": { + "LABEL": "Assignment order", + "ROUND_ROBIN": { + "LABEL": "Round robin", + "DESCRIPTION": "Assign conversations evenly among agents." + }, + "BALANCED": { + "LABEL": "Balanced", + "DESCRIPTION": "Assign conversations based on available capacity." + } + }, + "ASSIGNMENT_PRIORITY": { + "LABEL": "Assignment priority", + "EARLIEST_CREATED": { + "LABEL": "Earliest created", + "DESCRIPTION": "The conversation that was created first gets assigned first." + }, + "LONGEST_WAITING": { + "LABEL": "Longest waiting", + "DESCRIPTION": "The conversation waiting the longest gets assigned first." + } + }, + "FAIR_DISTRIBUTION": { + "LABEL": "Fair distribution policy", + "DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.", + "INPUT_MAX": "Assign max", + "DURATION": "Conversations per agent in every" + }, + "INBOXES": { + "LABEL": "Added inboxes", + "DESCRIPTION": "Add inboxes for which this policy will be applicable.", + "ADD_BUTTON": "Add inbox", + "DROPDOWN": { + "SEARCH_PLACEHOLDER": "Search and select inboxes to add", + "ADD_BUTTON": "Add" + }, + "EMPTY_STATE": "No inboxes added to this policy, add an inbox to get started", + "API": { + "SUCCESS_MESSAGE": "Inbox successfully added to policy", + "ERROR_MESSAGE": "Failed to add inbox to policy" + } + } + }, "DELETE_POLICY": { "TITLE": "Delete policy", "DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js index 6b4c35da3..37934f505 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js @@ -3,6 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import AssignmentPolicyIndex from './Index.vue'; import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue'; +import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue'; +import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue'; export default { routes: [ @@ -34,6 +36,24 @@ export default { permissions: ['administrator'], }, }, + { + path: 'assignment/create', + name: 'agent_assignment_policy_create', + component: AgentAssignmentCreate, + meta: { + featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, + permissions: ['administrator'], + }, + }, + { + path: 'assignment/edit/:id', + name: 'agent_assignment_policy_edit', + component: AgentAssignmentEdit, + meta: { + featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, + permissions: ['administrator'], + }, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js new file mode 100644 index 000000000..350faa60c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js @@ -0,0 +1,17 @@ +// Assignment order types +export const ROUND_ROBIN = 'round_robin'; +export const BALANCED = 'balanced'; + +// Assignment priority types +export const EARLIEST_CREATED = 'earliest_created'; +export const LONGEST_WAITING = 'longest_waiting'; + +// Default values for fair distribution +export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100; +export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600; + +// Options groupings +export const OPTIONS = { + ORDER: [ROUND_ROBIN, BALANCED], + PRIORITY: [EARLIEST_CREATED, LONGEST_WAITING], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentCreatePage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentCreatePage.vue new file mode 100644 index 000000000..3c68d9585 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentCreatePage.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue new file mode 100644 index 000000000..c54f912d4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue @@ -0,0 +1,197 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue index e931d6bbd..be5297a16 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue @@ -44,7 +44,16 @@ const handleBreadcrumbClick = item => { const onClickCreatePolicy = () => { router.push({ - name: 'assignment_policy_create', + name: 'agent_assignment_policy_create', + }); +}; + +const onClickEditPolicy = id => { + router.push({ + name: 'agent_assignment_policy_edit', + params: { + id, + }, }); }; @@ -106,6 +115,7 @@ onMounted(() => { v-bind="policy" :is-fetching-inboxes="inboxUiFlags.isFetching" @fetch-inboxes="handleFetchInboxes" + @edit="onClickEditPolicy" @delete="handleDelete" /> diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue new file mode 100644 index 000000000..d00ead2a8 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue @@ -0,0 +1,254 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue new file mode 100644 index 000000000..af49a29af --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue @@ -0,0 +1,59 @@ + + + diff --git a/app/javascript/dashboard/store/modules/assignmentPolicies.js b/app/javascript/dashboard/store/modules/assignmentPolicies.js index 80c903c4e..9ea2f49c3 100644 --- a/app/javascript/dashboard/store/modules/assignmentPolicies.js +++ b/app/javascript/dashboard/store/modules/assignmentPolicies.js @@ -3,6 +3,7 @@ import types from '../mutation-types'; import AssignmentPoliciesAPI from '../../api/assignmentPolicies'; import { throwErrorMessage } from '../utils/api'; import camelcaseKeys from 'camelcase-keys'; +import snakecaseKeys from 'snakecase-keys'; export const state = { records: [], @@ -15,6 +16,7 @@ export const state = { }, inboxUiFlags: { isFetching: false, + isDeleting: false, }, }; @@ -51,7 +53,7 @@ export const actions = { try { const response = await AssignmentPoliciesAPI.show(policyId); const policy = camelcaseKeys(response.data); - commit(types.EDIT_ASSIGNMENT_POLICY, policy); + commit(types.SET_ASSIGNMENT_POLICY, policy); } catch (error) { throwErrorMessage(error); } finally { @@ -62,7 +64,9 @@ export const actions = { create: async function create({ commit }, policyObj) { commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }); try { - const response = await AssignmentPoliciesAPI.create(policyObj); + const response = await AssignmentPoliciesAPI.create( + snakecaseKeys(policyObj) + ); commit(types.ADD_ASSIGNMENT_POLICY, camelcaseKeys(response.data)); return response.data; } catch (error) { @@ -76,7 +80,10 @@ export const actions = { update: async function update({ commit }, { id, ...policyParams }) { commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }); try { - const response = await AssignmentPoliciesAPI.update(id, policyParams); + const response = await AssignmentPoliciesAPI.update( + id, + snakecaseKeys(policyParams) + ); commit(types.EDIT_ASSIGNMENT_POLICY, camelcaseKeys(response.data)); return response.data; } catch (error) { @@ -117,6 +124,68 @@ export const actions = { }); } }, + + setInboxPolicy: async function setInboxPolicy( + { commit }, + { inboxId, policyId } + ) { + try { + const response = await AssignmentPoliciesAPI.setInboxPolicy( + inboxId, + policyId + ); + commit( + types.ADD_ASSIGNMENT_POLICIES_INBOXES, + camelcaseKeys(response.data) + ); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } + }, + + getInboxPolicy: async function getInboxPolicy(_, { inboxId }) { + try { + const response = await AssignmentPoliciesAPI.getInboxPolicy(inboxId); + return camelcaseKeys(response.data); + } catch (error) { + throwErrorMessage(error); + throw error; + } + }, + + updateInboxPolicy: async function updateInboxPolicy({ commit }, { policy }) { + try { + commit(types.EDIT_ASSIGNMENT_POLICY, policy); + } catch (error) { + throwErrorMessage(error); + throw error; + } + }, + + removeInboxPolicy: async function removeInboxPolicy( + { commit }, + { policyId, inboxId } + ) { + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { + isDeleting: true, + }); + try { + await AssignmentPoliciesAPI.removeInboxPolicy(inboxId); + commit(types.DELETE_ASSIGNMENT_POLICIES_INBOXES, { + policyId, + inboxId, + }); + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { + isDeleting: false, + }); + } + }, }; export const mutations = { @@ -128,8 +197,9 @@ export const mutations = { }, [types.SET_ASSIGNMENT_POLICIES]: MutationHelpers.set, + [types.SET_ASSIGNMENT_POLICY]: MutationHelpers.setSingleRecord, [types.ADD_ASSIGNMENT_POLICY]: MutationHelpers.create, - [types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.update, + [types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.updateAttributes, [types.DELETE_ASSIGNMENT_POLICY]: MutationHelpers.destroy, [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](_state, data) { @@ -138,13 +208,19 @@ export const mutations = { ...data, }; }, - [types.SET_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxes }) { const policy = _state.records.find(p => p.id === policyId); if (policy) { policy.inboxes = inboxes; } }, + [types.DELETE_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxId }) { + const policy = _state.records.find(p => p.id === policyId); + if (policy) { + policy.inboxes = policy?.inboxes?.filter(inbox => inbox.id !== inboxId); + } + }, + [types.ADD_ASSIGNMENT_POLICIES_INBOXES]: MutationHelpers.updateAttributes, }; export default { diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index c4789a7a9..9886be679 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -29,6 +29,9 @@ export const getters = { getInboxes($state) { return $state.records; }, + getAllInboxes($state) { + return camelcaseKeys($state.records, { deep: true }); + }, getWhatsAppTemplates: $state => inboxId => { const [inbox] = $state.records.filter( record => record.id === Number(inboxId) diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js index 5358144e8..1398f5959 100644 --- a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js @@ -3,12 +3,14 @@ import { actions } from '../../assignmentPolicies'; import types from '../../../mutation-types'; import assignmentPoliciesList, { camelCaseFixtures } from './fixtures'; import camelcaseKeys from 'camelcase-keys'; +import snakecaseKeys from 'snakecase-keys'; const commit = vi.fn(); global.axios = axios; vi.mock('axios'); vi.mock('camelcase-keys'); +vi.mock('snakecase-keys'); vi.mock('../../../utils/api'); describe('#actions', () => { @@ -56,7 +58,7 @@ describe('#actions', () => { expect(camelcaseKeys).toHaveBeenCalledWith(policyData); expect(commit.mock.calls).toEqual([ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }], - [types.EDIT_ASSIGNMENT_POLICY, camelCasedPolicy], + [types.SET_ASSIGNMENT_POLICY, camelCasedPolicy], [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }], ]); }); @@ -77,12 +79,15 @@ describe('#actions', () => { it('sends correct actions if API is success', async () => { const newPolicy = assignmentPoliciesList[0]; const camelCasedData = camelCaseFixtures[0]; + const snakeCasedPolicy = { assignment_order: 'round_robin' }; axios.post.mockResolvedValue({ data: newPolicy }); camelcaseKeys.mockReturnValue(camelCasedData); + snakecaseKeys.mockReturnValue(snakeCasedPolicy); const result = await actions.create({ commit }, newPolicy); + expect(snakecaseKeys).toHaveBeenCalledWith(newPolicy); expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy); expect(commit.mock.calls).toEqual([ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }], @@ -115,12 +120,15 @@ describe('#actions', () => { ...camelCaseFixtures[0], name: 'Updated Policy', }; + const snakeCasedParams = { name: 'Updated Policy' }; axios.patch.mockResolvedValue({ data: responseData }); camelcaseKeys.mockReturnValue(camelCasedData); + snakecaseKeys.mockReturnValue(snakeCasedParams); const result = await actions.update({ commit }, updateParams); + expect(snakecaseKeys).toHaveBeenCalledWith({ name: 'Updated Policy' }); expect(camelcaseKeys).toHaveBeenCalledWith(responseData); expect(commit.mock.calls).toEqual([ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }], @@ -211,4 +219,108 @@ describe('#actions', () => { ]); }); }); + + describe('#setInboxPolicy', () => { + it('sends correct actions if API is success', async () => { + const responseData = { success: true, policy_id: 2 }; + const camelCasedData = { success: true, policyId: 2 }; + + axios.post.mockResolvedValue({ data: responseData }); + camelcaseKeys.mockReturnValue(camelCasedData); + + const result = await actions.setInboxPolicy( + { commit }, + { inboxId: 1, policyId: 2 } + ); + + expect(camelcaseKeys).toHaveBeenCalledWith(responseData); + expect(commit.mock.calls).toEqual([ + [types.ADD_ASSIGNMENT_POLICIES_INBOXES, camelCasedData], + ]); + expect(result).toEqual(responseData); + }); + + it('throws error if API fails', async () => { + axios.post.mockRejectedValue(new Error('API Error')); + + await expect( + actions.setInboxPolicy({ commit }, { inboxId: 1, policyId: 2 }) + ).rejects.toThrow(Error); + }); + }); + + describe('#getInboxPolicy', () => { + it('returns camelCased response data if API is success', async () => { + const responseData = { policy_id: 1, name: 'Round Robin' }; + const camelCasedData = { policyId: 1, name: 'Round Robin' }; + + axios.get.mockResolvedValue({ data: responseData }); + camelcaseKeys.mockReturnValue(camelCasedData); + + const result = await actions.getInboxPolicy({}, { inboxId: 1 }); + + expect(camelcaseKeys).toHaveBeenCalledWith(responseData); + expect(result).toEqual(camelCasedData); + }); + + it('throws error if API fails', async () => { + axios.get.mockRejectedValue(new Error('Not found')); + + await expect( + actions.getInboxPolicy({}, { inboxId: 999 }) + ).rejects.toThrow(Error); + }); + }); + + describe('#updateInboxPolicy', () => { + it('commits EDIT_ASSIGNMENT_POLICY mutation', async () => { + const policy = { id: 1, name: 'Updated Policy' }; + + await actions.updateInboxPolicy({ commit }, { policy }); + + expect(commit.mock.calls).toEqual([ + [types.EDIT_ASSIGNMENT_POLICY, policy], + ]); + }); + + it('throws error if commit fails', async () => { + commit.mockImplementation(() => { + throw new Error('Commit failed'); + }); + + await expect( + actions.updateInboxPolicy({ commit }, { policy: {} }) + ).rejects.toThrow(Error); + }); + }); + + describe('#removeInboxPolicy', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + const inboxId = 2; + + axios.delete.mockResolvedValue({}); + + await actions.removeInboxPolicy({ commit }, { policyId, inboxId }); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: true }], + [types.DELETE_ASSIGNMENT_POLICIES_INBOXES, { policyId, inboxId }], + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: false }], + ]); + }); + + it('sends correct actions if API fails', async () => { + axios.delete.mockRejectedValue(new Error('Not found')); + + await expect( + actions.removeInboxPolicy({ commit }, { policyId: 1, inboxId: 999 }) + ).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: true }], + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: false }], + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js index 7e0e2041c..4fd1e7ad7 100644 --- a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js @@ -32,10 +32,12 @@ describe('#getters', () => { const state = { inboxUiFlags: { isFetching: false, + isDeleting: false, }, }; expect(getters.getInboxUiFlags(state)).toEqual({ isFetching: false, + isDeleting: false, }); }); diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js index 58d5527ca..b3c029c57 100644 --- a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js @@ -62,6 +62,24 @@ describe('#mutations', () => { }); }); + describe('#SET_ASSIGNMENT_POLICY', () => { + it('sets single assignment policy record', () => { + const state = { records: [] }; + + mutations[types.SET_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]); + + expect(state.records).toEqual([assignmentPoliciesList[0]]); + }); + + it('replaces existing record', () => { + const state = { records: [{ id: 1, name: 'Old Policy' }] }; + + mutations[types.SET_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]); + + expect(state.records).toEqual([assignmentPoliciesList[0]]); + }); + }); + describe('#ADD_ASSIGNMENT_POLICY', () => { it('adds new policy to empty records', () => { const state = { records: [] }; @@ -264,4 +282,104 @@ describe('#mutations', () => { expect(state).toEqual(originalState); }); }); + + describe('#DELETE_ASSIGNMENT_POLICIES_INBOXES', () => { + it('removes inbox from policy', () => { + const mockInboxes = [ + { id: 1, name: 'Support Inbox' }, + { id: 2, name: 'Sales Inbox' }, + { id: 3, name: 'Marketing Inbox' }, + ]; + + const state = { + records: [ + { id: 1, name: 'Policy 1', inboxes: mockInboxes }, + { id: 2, name: 'Policy 2', inboxes: [] }, + ], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxId: 2, + }); + + expect(state.records[0].inboxes).toEqual([ + { id: 1, name: 'Support Inbox' }, + { id: 3, name: 'Marketing Inbox' }, + ]); + expect(state.records[1].inboxes).toEqual([]); + }); + + it('does nothing if policy not found', () => { + const state = { + records: [ + { id: 1, name: 'Policy 1', inboxes: [{ id: 1, name: 'Test' }] }, + ], + }; + + const originalState = JSON.parse(JSON.stringify(state)); + + mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 999, + inboxId: 1, + }); + + expect(state).toEqual(originalState); + }); + + it('does nothing if inbox not found in policy', () => { + const mockInboxes = [{ id: 1, name: 'Support Inbox' }]; + + const state = { + records: [{ id: 1, name: 'Policy 1', inboxes: mockInboxes }], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxId: 999, + }); + + expect(state.records[0].inboxes).toEqual(mockInboxes); + }); + + it('handles policy with no inboxes', () => { + const state = { + records: [{ id: 1, name: 'Policy 1' }], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxId: 1, + }); + + expect(state.records[0]).toEqual({ id: 1, name: 'Policy 1' }); + }); + }); + + describe('#ADD_ASSIGNMENT_POLICIES_INBOXES', () => { + it('updates policy attributes using MutationHelpers.updateAttributes', () => { + const state = { + records: [ + { id: 1, name: 'Policy 1', assignedInboxCount: 2 }, + { id: 2, name: 'Policy 2', assignedInboxCount: 1 }, + ], + }; + + const updatedPolicy = { + id: 1, + name: 'Policy 1', + assignedInboxCount: 3, + inboxes: [{ id: 1, name: 'New Inbox' }], + }; + + mutations[types.ADD_ASSIGNMENT_POLICIES_INBOXES](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + expect(state.records[1]).toEqual({ + id: 2, + name: 'Policy 2', + assignedInboxCount: 1, + }); + }); + }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index a6fbefa17..3b5ba16f2 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -352,10 +352,13 @@ export default { // Assignment Policies SET_ASSIGNMENT_POLICIES_UI_FLAG: 'SET_ASSIGNMENT_POLICIES_UI_FLAG', SET_ASSIGNMENT_POLICIES: 'SET_ASSIGNMENT_POLICIES', + SET_ASSIGNMENT_POLICY: 'SET_ASSIGNMENT_POLICY', ADD_ASSIGNMENT_POLICY: 'ADD_ASSIGNMENT_POLICY', EDIT_ASSIGNMENT_POLICY: 'EDIT_ASSIGNMENT_POLICY', DELETE_ASSIGNMENT_POLICY: 'DELETE_ASSIGNMENT_POLICY', SET_ASSIGNMENT_POLICIES_INBOXES: 'SET_ASSIGNMENT_POLICIES_INBOXES', SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG: 'SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', + DELETE_ASSIGNMENT_POLICIES_INBOXES: 'DELETE_ASSIGNMENT_POLICIES_INBOXES', + ADD_ASSIGNMENT_POLICIES_INBOXES: 'ADD_ASSIGNMENT_POLICIES_INBOXES', }; From 79b93bed77d028f631a71b9a3be3f491d758367d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 20:02:27 +0530 Subject: [PATCH 06/72] feat: SAML authentication controllers [CW-2958] (#12319) --- Gemfile | 2 + Gemfile.lock | 10 +- .../omniauth_callbacks_controller.rb | 8 +- .../devise_overrides/passwords_controller.rb | 2 + app/models/user.rb | 2 +- config/application.rb | 4 + config/initializers/omniauth.rb | 4 + config/locales/en.yml | 3 + enterprise/app/builders/saml_user_builder.rb | 101 +++++++++ .../omniauth_callbacks_controller.rb | 64 ++++++ .../devise_overrides/passwords_controller.rb | 15 ++ .../devise_overrides/sessions_controller.rb | 14 ++ .../app/helpers/saml_authentication_helper.rb | 12 + .../config/initializers/omniauth_saml.rb | 43 ++++ .../builders/saml_user_builder_spec.rb | 214 ++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 61 +++++ .../passwords_controller_spec.rb | 36 +++ .../session_controller_spec.rb | 85 +++++-- 18 files changed, 653 insertions(+), 27 deletions(-) create mode 100644 enterprise/app/builders/saml_user_builder.rb create mode 100644 enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb create mode 100644 enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb create mode 100644 enterprise/app/helpers/saml_authentication_helper.rb create mode 100644 enterprise/config/initializers/omniauth_saml.rb create mode 100644 spec/enterprise/builders/saml_user_builder_spec.rb create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb diff --git a/Gemfile b/Gemfile index 03d0b62b9..927a853a0 100644 --- a/Gemfile +++ b/Gemfile @@ -81,6 +81,7 @@ gem 'devise_token_auth', '>= 1.2.3' # authorization gem 'jwt' gem 'pundit' + # super admin gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.3' @@ -171,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' +gem 'omniauth-saml' gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' diff --git a/Gemfile.lock b/Gemfile.lock index e8bccf54e..2cce9f322 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -589,8 +589,9 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.2) + omniauth (2.1.3) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-google-oauth2 (1.1.3) @@ -604,6 +605,9 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth-saml (2.2.4) + omniauth (~> 2.1) + ruby-saml (~> 1.18) opensearch-ruby (3.4.0) faraday (>= 1.0, < 3) multi_json (>= 1.0) @@ -773,6 +777,9 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-saml (1.18.1) + nokogiri (>= 1.13.10) + rexml ruby-vips (2.1.4) ffi (~> 1.12) ruby2_keywords (0.0.5) @@ -1047,6 +1054,7 @@ DEPENDENCIES omniauth-google-oauth2 (>= 1.1.3) omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) + omniauth-saml opensearch-ruby pg pg_search diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index db312e94f..fd3dba87c 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName - # find the user with their email instead of UID and token - @resource = resource_class.where( - email: auth_hash['info']['email'] - ).first + email = auth_hash.dig('info', 'email') + @resource = resource_class.from_email(email) end def validate_signup_email_is_business_domain? @@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa 'user' end end + +DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController') diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 17dd32086..00976c3cd 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController }, status: status end end + +DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController') diff --git a/app/models/user.rb b/app/models/user.rb index d1907362a..e0440a67c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -58,7 +58,7 @@ class User < ApplicationRecord :validatable, :confirmable, :password_has_required_content, - :omniauthable, omniauth_providers: [:google_oauth2] + :omniauthable, omniauth_providers: [:google_oauth2, :saml] # TODO: remove in a future version once online status is moved to account users # remove the column availability from users diff --git a/config/application.rb b/config/application.rb index 3eca267f0..7fd1b94ba 100644 --- a/config/application.rb +++ b/config/application.rb @@ -47,6 +47,10 @@ module Chatwoot # Add enterprise views to the view paths config.paths['app/views'].unshift('enterprise/app/views') + # Load enterprise initializers alongside standard initializers + enterprise_initializers = Rails.root.join('enterprise/config/initializers') + Dir[enterprise_initializers.join('**/*.rb')].each { |f| require f } if enterprise_initializers.exist? + # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index d92d9b040..54aa6ded8 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,3 +1,7 @@ +# OmniAuth configuration +# Sets the full host URL for callbacks and proper redirect handling +OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + Rails.application.config.middleware.use OmniAuth::Builder do provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), { provider_ignores_state: true diff --git a/config/locales/en.yml b/config/locales/en.yml index c9614d3a6..68f0b72fd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -39,6 +39,9 @@ en: messages: reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. reset_password_failure: Uh ho! We could not find any user with the specified email. + reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator. + login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider. + saml_not_available: SAML authentication is not available in this installation. inbox_deletetion_response: Your inbox deletion request will be processed in some time. errors: diff --git a/enterprise/app/builders/saml_user_builder.rb b/enterprise/app/builders/saml_user_builder.rb new file mode 100644 index 000000000..bd0059d07 --- /dev/null +++ b/enterprise/app/builders/saml_user_builder.rb @@ -0,0 +1,101 @@ +class SamlUserBuilder + def initialize(auth_hash, account_id) + @auth_hash = auth_hash + @account_id = account_id + @saml_settings = AccountSamlSettings.find_by(account_id: account_id) + end + + def perform + @user = find_or_create_user + add_user_to_account if @user.persisted? + @user + end + + private + + def find_or_create_user + user = User.from_email(auth_attribute('email')) + + if user + convert_existing_user_to_saml(user) + return user + end + + create_user + end + + def convert_existing_user_to_saml(user) + return if user.provider == 'saml' + + user.update!(provider: 'saml') + end + + def create_user + full_name = [auth_attribute('first_name'), auth_attribute('last_name')].compact.join(' ') + fallback_name = auth_attribute('name') || auth_attribute('email').split('@').first + + User.create( + email: auth_attribute('email'), + name: (full_name.presence || fallback_name), + display_name: auth_attribute('first_name'), + provider: 'saml', + uid: uid, + password: SecureRandom.hex(32), + confirmed_at: Time.current + ) + end + + def add_user_to_account + account = Account.find_by(id: @account_id) + return unless account + + # Create account_user if not exists + account_user = AccountUser.find_or_create_by( + user: @user, + account: account + ) + + # Set default role as agent if not set + account_user.update(role: 'agent') if account_user.role.blank? + + # Handle role mappings if configured + apply_role_mappings(account_user, account) + end + + def apply_role_mappings(account_user, account) + matching_mapping = find_matching_role_mapping(account) + return unless matching_mapping + + if matching_mapping['role'] + account_user.update(role: matching_mapping['role']) + elsif matching_mapping['custom_role_id'] + account_user.update(custom_role_id: matching_mapping['custom_role_id']) + end + end + + def find_matching_role_mapping(_account) + return if @saml_settings&.role_mappings.blank? + + saml_groups.each do |group| + mapping = @saml_settings.role_mappings[group] + return mapping if mapping.present? + end + nil + end + + def auth_attribute(key, fallback = nil) + @auth_hash.dig('info', key) || fallback + end + + def uid + @auth_hash['uid'] + end + + def saml_groups + # Groups can come from different attributes depending on IdP + @auth_hash.dig('extra', 'raw_info', 'groups') || + @auth_hash.dig('extra', 'raw_info', 'Group') || + @auth_hash.dig('extra', 'raw_info', 'memberOf') || + [] + end +end diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb new file mode 100644 index 000000000..973f26650 --- /dev/null +++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb @@ -0,0 +1,64 @@ +module Enterprise::DeviseOverrides::OmniauthCallbacksController + def saml + # Call parent's omniauth_success which handles the auth + omniauth_success + end + + def redirect_callbacks + # derive target redirect route from 'resource_class' param, which was set + # before authentication. + devise_mapping = get_devise_mapping + redirect_route = get_redirect_route(devise_mapping) + + # preserve omniauth info for success route. ignore 'extra' in twitter + # auth response to avoid CookieOverflow. + session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra') + session['dta.omniauth.params'] = request.env['omniauth.params'] + + # For SAML, use 303 See Other to convert POST to GET and preserve session + if params[:provider] == 'saml' + redirect_to redirect_route, { status: 303 }.merge(redirect_options) + else + super + end + end + + def omniauth_success + case auth_hash&.dig('provider') + when 'saml' + handle_saml_auth + else + super + end + end + + private + + def handle_saml_auth + account_id = extract_saml_account_id + return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id) + + @resource = SamlUserBuilder.new(auth_hash, account_id).perform + + if @resource.persisted? + sign_in_user + else + redirect_to login_page_url(error: 'saml-authentication-failed') + end + end + + def extract_saml_account_id + params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id') + end + + def saml_enabled_for_account?(account_id) + return false if account_id.blank? + + account = Account.find_by(id: account_id) + + return false if account.nil? + return false unless account.feature_enabled?('saml') + + AccountSamlSettings.find_by(account_id: account_id).present? + end +end diff --git a/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb new file mode 100644 index 000000000..bd4daee2a --- /dev/null +++ b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb @@ -0,0 +1,15 @@ +module Enterprise::DeviseOverrides::PasswordsController + include SamlAuthenticationHelper + + def create + if saml_user_attempting_password_auth?(params[:email]) + render json: { + success: false, + errors: [I18n.t('messages.reset_password_saml_user')] + }, status: :forbidden + return + end + + super + end +end diff --git a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb index e11e3fff9..ea456bdd1 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb @@ -1,4 +1,18 @@ module Enterprise::DeviseOverrides::SessionsController + include SamlAuthenticationHelper + + def create + if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token]) + render json: { + success: false, + errors: [I18n.t('messages.login_saml_user')] + }, status: :unauthorized + return + end + + super + end + def render_create_success create_audit_event('sign_in') super diff --git a/enterprise/app/helpers/saml_authentication_helper.rb b/enterprise/app/helpers/saml_authentication_helper.rb new file mode 100644 index 000000000..9adcb22c3 --- /dev/null +++ b/enterprise/app/helpers/saml_authentication_helper.rb @@ -0,0 +1,12 @@ +module SamlAuthenticationHelper + def saml_user_attempting_password_auth?(email, sso_auth_token: nil) + return false if email.blank? + + user = User.from_email(email) + return false unless user&.provider == 'saml' + + return false if sso_auth_token.present? && user.valid_sso_auth_token?(sso_auth_token) + + true + end +end diff --git a/enterprise/config/initializers/omniauth_saml.rb b/enterprise/config/initializers/omniauth_saml.rb new file mode 100644 index 000000000..f73e3a109 --- /dev/null +++ b/enterprise/config/initializers/omniauth_saml.rb @@ -0,0 +1,43 @@ +# Enterprise Edition SAML SSO Provider +# This initializer adds SAML authentication support for Enterprise customers + +# SAML setup proc for multi-tenant configuration +SAML_SETUP_PROC = proc do |env| + request = ActionDispatch::Request.new(env) + + # Extract account_id from various sources + account_id = request.params['account_id'] || + request.session[:saml_account_id] || + env['omniauth.params']&.dig('account_id') + + if account_id + # Store in session and omniauth params for callback + request.session[:saml_account_id] = account_id + env['omniauth.params'] ||= {} + env['omniauth.params']['account_id'] = account_id + + # Find SAML settings for this account + settings = AccountSamlSettings.find_by(account_id: account_id) + + if settings + # Configure the strategy options dynamically + env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}" + env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id + env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id + env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url + env['omniauth.strategy'].options[:idp_cert] = settings.certificate + env['omniauth.strategy'].options[:name_identifier_format] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + else + # Set a dummy certificate to avoid the error + env['omniauth.strategy'].options[:idp_cert] = 'DUMMY' + end + else + # Set a dummy certificate to avoid the error + env['omniauth.strategy'].options[:idp_cert] = 'DUMMY' + end +end + +Rails.application.config.middleware.use OmniAuth::Builder do + # SAML provider with setup phase for multi-tenant configuration + provider :saml, setup: SAML_SETUP_PROC +end diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb new file mode 100644 index 000000000..63fd65fd3 --- /dev/null +++ b/spec/enterprise/builders/saml_user_builder_spec.rb @@ -0,0 +1,214 @@ +require 'rails_helper' + +RSpec.describe SamlUserBuilder do + let(:email) { 'saml.user@example.com' } + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email, + 'name' => 'SAML User', + 'first_name' => 'SAML', + 'last_name' => 'User' + }, + 'extra' => { + 'raw_info' => { + 'groups' => %w[Administrators Users] + } + } + } + end + let(:account) { create(:account) } + let(:builder) { described_class.new(auth_hash, account.id) } + + describe '#perform' do + context 'when user does not exist' do + it 'creates a new user' do + expect { builder.perform }.to change(User, :count).by(1) + end + + it 'creates user with correct attributes' do + user = builder.perform + + expect(user.email).to eq(email) + expect(user.name).to eq('SAML User') + expect(user.display_name).to eq('SAML') + expect(user.provider).to eq('saml') + expect(user.uid).to eq(email) # User model sets uid to email in before_validation callback + expect(user.confirmed_at).to be_present + end + + it 'creates user with a random password' do + user = builder.perform + expect(user.encrypted_password).to be_present + end + + it 'adds user to the account' do + user = builder.perform + expect(user.accounts).to include(account) + end + + it 'sets default role as agent' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.role).to eq('agent') + end + + context 'when name is not provided' do + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email + } + } + end + + it 'derives name from email' do + user = builder.perform + expect(user.name).to eq('saml.user') + end + end + end + + context 'when user already exists' do + let!(:existing_user) { create(:user, email: email) } + + it 'does not create a new user' do + expect { builder.perform }.not_to change(User, :count) + end + + it 'returns the existing user' do + user = builder.perform + expect(user).to eq(existing_user) + end + + it 'adds existing user to the account if not already added' do + user = builder.perform + expect(user.accounts).to include(account) + end + + it 'converts existing user to SAML' do + expect(existing_user.provider).not_to eq('saml') + + builder.perform + + expect(existing_user.reload.provider).to eq('saml') + end + + it 'does not change provider if user is already SAML' do + existing_user.update!(provider: 'saml') + + expect { builder.perform }.not_to(change { existing_user.reload.provider }) + end + + it 'does not duplicate account association' do + existing_user.account_users.create!(account: account, role: 'agent') + + expect { builder.perform }.not_to change(AccountUser, :count) + end + end + + context 'with role mappings' do + let(:saml_settings) do + create(:account_saml_settings, + account: account, + role_mappings: { + 'Administrators' => { 'role' => 'administrator' }, + 'Agents' => { 'role' => 'agent' } + }) + end + + before { saml_settings } + + it 'applies administrator role based on SAML groups' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.role).to eq('administrator') + end + + context 'with custom role mapping' do + let!(:custom_role) { create(:custom_role, account: account) } + let(:saml_settings) do + create(:account_saml_settings, + account: account, + role_mappings: { + 'Administrators' => { 'custom_role_id' => custom_role.id } + }) + end + + before { saml_settings } + + it 'applies custom role based on SAML groups' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.custom_role_id).to eq(custom_role.id) + end + end + + context 'when user is not in any mapped groups' do + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email, + 'name' => 'SAML User' + }, + 'extra' => { + 'raw_info' => { + 'groups' => ['UnmappedGroup'] + } + } + } + end + + it 'keeps default agent role' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.role).to eq('agent') + end + end + end + + context 'with different group attribute names' do + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email, + 'name' => 'SAML User' + }, + 'extra' => { + 'raw_info' => { + 'memberOf' => ['CN=Administrators,OU=Groups,DC=example,DC=com'] + } + } + } + end + + it 'reads groups from memberOf attribute' do + builder_instance = described_class.new(auth_hash, account_id: account.id) + allow(builder_instance).to receive(:saml_groups).and_return(['CN=Administrators,OU=Groups,DC=example,DC=com']) + user = builder_instance.perform + expect(user).to be_persisted + end + end + + context 'when there are errors' do + it 'returns unsaved user object when user creation fails' do + allow(User).to receive(:create).and_return(User.new(email: email)) + user = builder.perform + expect(user.persisted?).to be false + end + + it 'does not create account association for failed user' do + allow(User).to receive(:create).and_return(User.new(email: email)) + expect { builder.perform }.not_to change(AccountUser, :count) + end + end + end +end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb new file mode 100644 index 000000000..af67e3009 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do + let!(:account) { create(:account) } + let(:saml_settings) { create(:account_saml_settings, account: account) } + + def set_saml_config(email = 'test@example.com') + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:saml] = OmniAuth::AuthHash.new( + provider: 'saml', + uid: '123545', + info: { + name: 'Test User', + email: email + } + ) + end + + before do + allow(ChatwootApp).to receive(:enterprise?).and_return(true) + account.enable_features!('saml') + saml_settings + end + + describe '#saml callback' do + it 'creates new user and logs them in' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + set_saml_config('new_user@example.com') + + get "/omniauth/saml/callback?account_id=#{account.id}" + + # expect a 302 redirect to auth/saml/callback + expect(response).to redirect_to('http://www.example.com/auth/saml/callback') + follow_redirect! + + # expect redirect to login with SSO token + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + + # verify user was created + user = User.from_email('new_user@example.com') + expect(user).to be_present + expect(user.provider).to eq('saml') + end + end + + it 'logs in existing user' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + create(:user, email: 'existing@example.com', account: account) + set_saml_config('existing@example.com') + + get "/omniauth/saml/callback?account_id=#{account.id}" + + # expect a 302 redirect to auth/saml/callback + expect(response).to redirect_to('http://www.example.com/auth/saml/callback') + follow_redirect! + + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + end + end + end +end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb new file mode 100644 index 000000000..23b498be8 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Passwords Controller', type: :request do + let!(:account) { create(:account) } + + describe 'POST /auth/password' do + context 'with SAML user email' do + let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } + + it 'prevents password reset and returns forbidden with custom error message' do + params = { email: saml_user.email, redirect_url: 'http://test.host' } + + post user_password_path, params: params, as: :json + + expect(response).to have_http_status(:forbidden) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(false) + expect(json_response['errors']).to include(I18n.t('messages.reset_password_saml_user')) + end + end + + context 'with non-SAML user email' do + let!(:regular_user) { create(:user, email: 'regular@example.com', provider: 'email', account: account) } + + it 'allows password reset for non-SAML users' do + params = { email: regular_user.email, redirect_url: 'http://test.host' } + + post user_password_path, params: params, as: :json + + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['message']).to be_present + end + end + end +end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb index 89f8794ee..08c69c840 100644 --- a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb @@ -5,32 +5,75 @@ RSpec.describe 'Enterprise Audit API', type: :request do let!(:user) { create(:user, password: 'Password1!', account: account) } describe 'POST /sign_in' do - it 'creates a sign_in audit event wwith valid credentials' do - params = { email: user.email, password: 'Password1!' } + context 'with SAML user attempting password login' do + let(:saml_settings) { create(:account_saml_settings, account: account) } + let(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } - expect do - post new_user_session_url, - params: params, - as: :json - end.to change(Enterprise::AuditLog, :count).by(1) + before do + saml_settings + saml_user + end - expect(response).to have_http_status(:success) - expect(response.body).to include(user.email) + it 'prevents login and returns SAML authentication error' do + params = { email: saml_user.email, password: 'Password1!' } - # Check if the sign_in event is created - user.reload - expect(user.audits.last.action).to eq('sign_in') - expect(user.audits.last.associated_id).to eq(account.id) - expect(user.audits.last.associated_type).to eq('Account') + post new_user_session_url, params: params, as: :json + + expect(response).to have_http_status(:unauthorized) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(false) + expect(json_response['errors']).to include(I18n.t('messages.login_saml_user')) + end + + it 'allows login with valid SSO token' do + valid_token = saml_user.generate_sso_auth_token + params = { email: saml_user.email, sso_auth_token: valid_token, password: 'Password1!' } + + expect do + post new_user_session_url, params: params, as: :json + end.to change(Enterprise::AuditLog, :count).by(1) + + expect(response).to have_http_status(:success) + expect(response.body).to include(saml_user.email) + end end - it 'will not create a sign_in audit event with invalid credentials' do - params = { email: user.email, password: 'invalid' } - expect do - post new_user_session_url, - params: params, - as: :json - end.not_to change(Enterprise::AuditLog, :count) + context 'with regular user credentials' do + it 'creates a sign_in audit event wwith valid credentials' do + params = { email: user.email, password: 'Password1!' } + + expect do + post new_user_session_url, + params: params, + as: :json + end.to change(Enterprise::AuditLog, :count).by(1) + + expect(response).to have_http_status(:success) + expect(response.body).to include(user.email) + + # Check if the sign_in event is created + user.reload + expect(user.audits.last.action).to eq('sign_in') + expect(user.audits.last.associated_id).to eq(account.id) + expect(user.audits.last.associated_type).to eq('Account') + end + + it 'will not create a sign_in audit event with invalid credentials' do + params = { email: user.email, password: 'invalid' } + expect do + post new_user_session_url, + params: params, + as: :json + end.not_to change(Enterprise::AuditLog, :count) + end + end + + context 'with blank email' do + it 'skips SAML check and processes normally' do + params = { email: '', password: 'Password1!' } + post new_user_session_url, params: params, as: :json + expect(response).to have_http_status(:unauthorized) + end end end From 81b401c998d69a04d2f30b2f6d7d75b1233414ea Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 10 Sep 2025 20:08:06 +0530 Subject: [PATCH 07/72] fix: Add URL validation and rate limiting for contact avatar sync (#11979) - Implement 1-minute rate limiting for contacts to prevent bombardment - Add URL hash comparison to sync only when avatar URL changes --- app/helpers/portal_helper.rb | 3 +- app/jobs/avatar/avatar_from_url_job.rb | 78 +++++++++++-- spec/jobs/avatar/avatar_from_url_job_spec.rb | 115 ++++++++++++++++--- 3 files changed, 168 insertions(+), 28 deletions(-) diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index e98f0a72d..15de0fbd7 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -1,4 +1,5 @@ module PortalHelper + include UrlHelper def set_og_image_url(portal_name, title) cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL'] return if cdn_url.blank? @@ -79,7 +80,7 @@ module PortalHelper query_params = Rack::Utils.parse_query(url.query) query_params['utm_medium'] = 'helpcenter' query_params['utm_campaign'] = 'branding' - query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp) + query_params['utm_source'] = URI.parse(referer).host if url_valid?(referer) url.query = query_params.to_query url.to_s diff --git a/app/jobs/avatar/avatar_from_url_job.rb b/app/jobs/avatar/avatar_from_url_job.rb index 9996cf3eb..0ab7ebea8 100644 --- a/app/jobs/avatar/avatar_from_url_job.rb +++ b/app/jobs/avatar/avatar_from_url_job.rb @@ -1,27 +1,83 @@ +# Downloads and attaches avatar images from a URL. +# Notes: +# - For contact objects, we use `additional_attributes` to rate limit the +# job and track state. +# - We save the hash of the synced URL to retrigger downloads only when +# there is a change in the underlying asset. +# - A 1 minute rate limit window is enforced via `last_avatar_sync_at`. class Avatar::AvatarFromUrlJob < ApplicationJob + include UrlHelper queue_as :purgable + MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024 + RATE_LIMIT_WINDOW = 1.minute + def perform(avatarable, avatar_url) return unless avatarable.respond_to?(:avatar) + return unless url_valid?(avatar_url) - avatar_file = Down.download( - avatar_url, - max_size: 15 * 1024 * 1024 + return unless should_sync_avatar?(avatarable, avatar_url) + + avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE) + raise Down::Error, 'Invalid file' unless valid_file?(avatar_file) + + avatarable.avatar.attach( + io: avatar_file, + filename: avatar_file.original_filename, + content_type: avatar_file.content_type ) - if valid_image?(avatar_file) - avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, - content_type: avatar_file.content_type) - end + rescue Down::NotFound, Down::Error => e - Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}" + Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}" + ensure + update_avatar_sync_attributes(avatarable, avatar_url) end private - def valid_image?(file) - return false if file.original_filename.blank? + def should_sync_avatar?(avatarable, avatar_url) + # Only Contacts are rate-limited and hash-gated. + return true unless avatarable.is_a?(Contact) - # TODO: check if the file is an actual image + attrs = avatarable.additional_attributes || {} + + return false if within_rate_limit?(attrs) + return false if duplicate_url?(attrs, avatar_url) + + true + end + + def within_rate_limit?(attrs) + ts = attrs['last_avatar_sync_at'] + return false if ts.blank? + + Time.zone.parse(ts) > RATE_LIMIT_WINDOW.ago + end + + def duplicate_url?(attrs, avatar_url) + stored_hash = attrs['avatar_url_hash'] + stored_hash.present? && stored_hash == generate_url_hash(avatar_url) + end + + def generate_url_hash(url) + Digest::SHA256.hexdigest(url) + end + + def update_avatar_sync_attributes(avatarable, avatar_url) + # Only Contacts have sync attributes persisted + return unless avatarable.is_a?(Contact) + return if avatar_url.blank? + + additional_attributes = avatarable.additional_attributes || {} + additional_attributes['last_avatar_sync_at'] = Time.current.iso8601 + additional_attributes['avatar_url_hash'] = generate_url_hash(avatar_url) + + # Persist without triggering validations that may fail due to avatar file checks + avatarable.update_columns(additional_attributes: additional_attributes) # rubocop:disable Rails/SkipsModelValidations + end + + def valid_file?(file) + return false if file.original_filename.blank? true end diff --git a/spec/jobs/avatar/avatar_from_url_job_spec.rb b/spec/jobs/avatar/avatar_from_url_job_spec.rb index 2e6d89804..8db3769ad 100644 --- a/spec/jobs/avatar/avatar_from_url_job_spec.rb +++ b/spec/jobs/avatar/avatar_from_url_job_spec.rb @@ -1,36 +1,119 @@ require 'rails_helper' RSpec.describe Avatar::AvatarFromUrlJob do - let(:avatarable) { create(:contact) } - let(:avatar_url) { 'https://example.com/avatar.png' } + let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') } + let(:valid_url) { 'https://example.com/avatar.png' } it 'enqueues the job' do - expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class) - .on_queue('purgable') + contact = create(:contact) + expect { described_class.perform_later(contact, 'https://example.com/avatar.png') } + .to have_enqueued_job(described_class).on_queue('purgable') end - it 'will attach avatar from url' do - expect(avatarable.avatar).not_to be_attached - expect(Down).to receive(:download).with(avatar_url, - max_size: 15 * 1024 * 1024).and_return(fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), - 'image/png')) - described_class.perform_now(avatarable, avatar_url) - expect(avatarable.avatar).to be_attached + context 'with rate-limited avatarable (Contact)' do + let(:avatarable) { create(:contact) } + + it 'attaches and updates sync attributes' do + expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file) + described_class.perform_now(avatarable, valid_url) + avatarable.reload + expect(avatarable.avatar).to be_attached + expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) + expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present + end + + it 'returns early when rate limited' do + ts = 30.seconds.ago.iso8601 + avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts }) + expect(Down).not_to receive(:download) + described_class.perform_now(avatarable, valid_url) + avatarable.reload + expect(avatarable.avatar).not_to be_attached + expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present + expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at'])) + .to be > Time.zone.parse(ts) + expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) + end + + it 'returns early when hash unchanged' do + avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) }) + expect(Down).not_to receive(:download) + described_class.perform_now(avatarable, valid_url) + expect(avatarable.avatar).not_to be_attached + avatarable.reload + expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present + expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) + end + + it 'updates sync attributes even when URL is invalid' do + invalid_url = 'invalid_url' + expect(Down).not_to receive(:download) + described_class.perform_now(avatarable, invalid_url) + avatarable.reload + expect(avatarable.avatar).not_to be_attached + expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present + expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(invalid_url)) + end + + it 'updates sync attributes when file download is valid but content type is unsupported' do + temp_file = Tempfile.new(['invalid', '.xml']) + temp_file.write('content') + temp_file.rewind + + uploaded = ActionDispatch::Http::UploadedFile.new( + tempfile: temp_file, + filename: 'invalid.xml', + type: 'application/xml' + ) + + expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(uploaded) + + described_class.perform_now(avatarable, valid_url) + avatarable.reload + + expect(avatarable.avatar).not_to be_attached + expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present + expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) + + temp_file.close + temp_file.unlink + end + end + + context 'with regular avatarable' do + let(:avatarable) { create(:agent_bot) } + + it 'downloads and attaches avatar' do + expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file) + described_class.perform_now(avatarable, valid_url) + expect(avatarable.avatar).to be_attached + end end # ref: https://github.com/chatwoot/chatwoot/issues/10449 - it 'will not throw error if the avatar url is not valid and the file does not have a filename' do - # Create a temporary file with no filename and content type application/xml + it 'does not raise error when downloaded file has no filename (invalid content)' do + contact = create(:contact) temp_file = Tempfile.new(['invalid', '.xml']) temp_file.write('content') temp_file.rewind - expect(Down).to receive(:download).with(avatar_url, max_size: 15 * 1024 * 1024) + expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE) .and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml')) - expect { described_class.perform_now(avatarable, avatar_url) }.not_to raise_error + expect { described_class.perform_now(contact, valid_url) }.not_to raise_error temp_file.close - temp_file.unlink # deletes the temp file + temp_file.unlink + end + + it 'skips sync attribute updates when URL is nil' do + contact = create(:contact) + expect(Down).not_to receive(:download) + + expect { described_class.perform_now(contact, nil) }.not_to raise_error + + contact.reload + expect(contact.additional_attributes['last_avatar_sync_at']).to be_nil + expect(contact.additional_attributes['avatar_url_hash']).to be_nil end end From 06fa541f193ccf033034232dafdc2c24058492cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 12:34:24 -0700 Subject: [PATCH 08/72] chore(deps-dev): bump vite from 5.4.19 to 5.4.20 (#12407) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 5.4.20.
Release notes

Sourced from vite's releases.

v5.4.20

Please refer to CHANGELOG.md for details.

Changelog

Sourced from vite's changelog.

5.4.20 (2025-09-08)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.19&new-version=5.4.20)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 4 +- pnpm-lock.yaml | 324 ++++++++++++++++++++++++++----------------------- 2 files changed, 172 insertions(+), 156 deletions(-) diff --git a/package.json b/package.json index af88229ba..3972a328e 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "prosemirror-model": "^1.22.3", "size-limit": "^8.2.4", "tailwindcss": "^3.4.13", - "vite": "^5.4.19", + "vite": "^5.4.20", "vite-plugin-ruby": "^5.0.0", "vitest": "3.0.5" }, @@ -155,7 +155,7 @@ "pnpm": { "overrides": { "vite-node": "2.0.1", - "vite": "5.4.19", + "vite": "5.4.20", "vitest": "3.0.5" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c8c06b1b..71279b7e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: vite-node: 2.0.1 - vite: 5.4.19 + vite: 5.4.20 vitest: 3.0.5 importers: @@ -69,7 +69,7 @@ importers: version: 8.20.5(vue@3.5.12(typescript@5.6.2)) '@vitejs/plugin-vue': specifier: ^5.1.4 - version: 5.1.4(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2)) + version: 5.1.4(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2)) '@vue/compiler-sfc': specifier: ^3.5.8 version: 3.5.8 @@ -241,7 +241,7 @@ importers: version: 1.8.1(tailwindcss@3.4.13) '@histoire/plugin-vue': specifier: 0.17.15 - version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2)) + version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2)) '@iconify-json/logos': specifier: ^1.2.3 version: 1.2.3 @@ -304,7 +304,7 @@ importers: version: 6.0.0 histoire: specifier: 0.17.15 - version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) husky: specifier: ^7.0.0 version: 7.0.4 @@ -333,11 +333,11 @@ importers: specifier: ^3.4.13 version: 3.4.13 vite: - specifier: 5.4.19 - version: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + specifier: 5.4.20 + version: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vite-plugin-ruby: specifier: ^5.0.0 - version: 5.0.0(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + version: 5.0.0(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) vitest: specifier: 3.0.5 version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0) @@ -863,7 +863,7 @@ packages: '@histoire/shared@0.17.17': resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==} peerDependencies: - vite: 5.4.19 + vite: 5.4.20 '@histoire/vendors@0.17.17': resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==} @@ -943,8 +943,8 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.12': - resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} @@ -966,20 +966,20 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.10': - resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.4': - resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} '@kurkle/color@0.3.2': resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} @@ -1047,103 +1047,108 @@ packages: '@rails/ujs@7.1.400': resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==} - '@rollup/rollup-android-arm-eabi@4.40.2': - resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==} + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.40.2': - resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==} + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.40.2': - resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==} + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.40.2': - resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==} + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.40.2': - resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==} + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.40.2': - resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==} + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.40.2': - resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.40.2': - resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.40.2': - resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.40.2': - resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.40.2': - resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': - resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.40.2': - resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.40.2': - resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.40.2': - resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.40.2': - resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} + '@rollup/rollup-linux-x64-gnu@4.50.1': + resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.40.2': - resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.40.2': - resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} + '@rollup/rollup-openharmony-arm64@4.50.1': + resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.40.2': - resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==} + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.40.2': - resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==} + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} cpu: [x64] os: [win32] @@ -1224,8 +1229,8 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} - '@types/estree@1.0.7': - resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/flexsearch@0.7.6': resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==} @@ -1278,7 +1283,7 @@ packages: resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: - vite: 5.4.19 + vite: 5.4.20 vue: ^3.2.25 '@vitest/coverage-v8@3.0.5': @@ -1297,7 +1302,7 @@ packages: resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==} peerDependencies: msw: ^2.4.9 - vite: 5.4.19 + vite: 5.4.20 peerDependenciesMeta: msw: optional: true @@ -2595,7 +2600,7 @@ packages: resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==} hasBin: true peerDependencies: - vite: 5.4.19 + vite: 5.4.20 hotkeys-js@3.8.7: resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==} @@ -3214,6 +3219,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3680,8 +3690,8 @@ packages: resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} posthog-js@1.260.3: @@ -3862,8 +3872,8 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rollup@4.40.2: - resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==} + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4364,10 +4374,10 @@ packages: vite-plugin-ruby@5.0.0: resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==} peerDependencies: - vite: 5.4.19 + vite: 5.4.20 - vite@5.4.19: - resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + vite@5.4.20: + resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -5188,10 +5198,10 @@ snapshots: highlight.js: 11.10.0 vue: 3.5.12(typescript@5.6.2) - '@histoire/app@0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': + '@histoire/app@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': dependencies: - '@histoire/controls': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) - '@histoire/shared': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) '@histoire/vendors': 0.17.17 '@types/flexsearch': 0.7.6 flexsearch: 0.7.21 @@ -5199,7 +5209,7 @@ snapshots: transitivePeerDependencies: - vite - '@histoire/controls@0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': + '@histoire/controls@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': dependencies: '@codemirror/commands': 6.7.0 '@codemirror/lang-json': 6.0.1 @@ -5208,26 +5218,26 @@ snapshots: '@codemirror/state': 6.4.1 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.34.1 - '@histoire/shared': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) '@histoire/vendors': 0.17.17 transitivePeerDependencies: - vite - '@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))': + '@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))': dependencies: - '@histoire/controls': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) - '@histoire/shared': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) '@histoire/vendors': 0.17.17 change-case: 4.1.2 globby: 13.2.2 - histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) launch-editor: 2.9.1 pathe: 1.1.2 vue: 3.5.12(typescript@5.6.2) transitivePeerDependencies: - vite - '@histoire/shared@0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': + '@histoire/shared@0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': dependencies: '@histoire/vendors': 0.17.17 '@types/fs-extra': 9.0.13 @@ -5235,7 +5245,7 @@ snapshots: chokidar: 3.6.0 pathe: 1.1.2 picocolors: 1.1.0 - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) '@histoire/vendors@0.17.17': {} @@ -5349,10 +5359,10 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@jridgewell/gen-mapping@0.3.12': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 optional: true '@jridgewell/gen-mapping@0.3.5': @@ -5374,15 +5384,15 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.10': + '@jridgewell/source-map@0.3.11': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 optional: true '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.4': + '@jridgewell/sourcemap-codec@1.5.5': optional: true '@jridgewell/trace-mapping@0.3.25': @@ -5390,10 +5400,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.29': + '@jridgewell/trace-mapping@0.3.30': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 optional: true '@kurkle/color@0.3.2': {} @@ -5456,64 +5466,67 @@ snapshots: '@rails/ujs@7.1.400': {} - '@rollup/rollup-android-arm-eabi@4.40.2': + '@rollup/rollup-android-arm-eabi@4.50.1': optional: true - '@rollup/rollup-android-arm64@4.40.2': + '@rollup/rollup-android-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-arm64@4.40.2': + '@rollup/rollup-darwin-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-x64@4.40.2': + '@rollup/rollup-darwin-x64@4.50.1': optional: true - '@rollup/rollup-freebsd-arm64@4.40.2': + '@rollup/rollup-freebsd-arm64@4.50.1': optional: true - '@rollup/rollup-freebsd-x64@4.40.2': + '@rollup/rollup-freebsd-x64@4.50.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.40.2': + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.40.2': + '@rollup/rollup-linux-arm-musleabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.40.2': + '@rollup/rollup-linux-arm64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.40.2': + '@rollup/rollup-linux-arm64-musl@4.50.1': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.40.2': + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': + '@rollup/rollup-linux-ppc64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.40.2': + '@rollup/rollup-linux-riscv64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.40.2': + '@rollup/rollup-linux-riscv64-musl@4.50.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.40.2': + '@rollup/rollup-linux-s390x-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.40.2': + '@rollup/rollup-linux-x64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-musl@4.40.2': + '@rollup/rollup-linux-x64-musl@4.50.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.40.2': + '@rollup/rollup-openharmony-arm64@4.50.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.40.2': + '@rollup/rollup-win32-arm64-msvc@4.50.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.40.2': + '@rollup/rollup-win32-ia32-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true '@rtsao/scc@1.1.0': {} @@ -5606,7 +5619,7 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@types/estree@1.0.7': {} + '@types/estree@1.0.8': {} '@types/flexsearch@0.7.6': {} @@ -5664,9 +5677,9 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-vue@5.1.4(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))': + '@vitejs/plugin-vue@5.1.4(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))': dependencies: - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vue: 3.5.12(typescript@5.6.2) '@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))': @@ -5694,13 +5707,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.5(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': + '@vitest/mocker@3.0.5(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))': dependencies: '@vitest/spy': 3.0.5 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) '@vitest/pretty-format@3.0.5': dependencies: @@ -5775,7 +5788,7 @@ snapshots: '@vue/shared': 3.5.12 estree-walker: 2.0.2 magic-string: 0.30.17 - postcss: 8.5.3 + postcss: 8.5.6 source-map-js: 1.2.1 '@vue/compiler-sfc@3.5.13': @@ -5787,7 +5800,7 @@ snapshots: '@vue/shared': 3.5.13 estree-walker: 2.0.2 magic-string: 0.30.17 - postcss: 8.5.3 + postcss: 8.5.6 source-map-js: 1.2.1 '@vue/compiler-sfc@3.5.8': @@ -6948,7 +6961,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 esutils@2.0.3: {} @@ -7272,12 +7285,12 @@ snapshots: highlight.js@11.10.0: {} - histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): + histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): dependencies: '@akryum/tinypool': 0.3.1 - '@histoire/app': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) - '@histoire/controls': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) - '@histoire/shared': 0.17.17(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/app': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/controls': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@histoire/shared': 0.17.17(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) '@histoire/vendors': 0.17.17 '@types/flexsearch': 0.7.6 '@types/markdown-it': 12.2.3 @@ -7304,7 +7317,7 @@ snapshots: sade: 1.8.1 shiki-es: 0.2.0 sirv: 2.0.4 - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: - '@types/node' @@ -7983,6 +7996,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + nanoid@3.3.8: {} nanospinner@1.1.0: @@ -8470,9 +8485,9 @@ snapshots: picocolors: 1.1.0 source-map-js: 1.2.1 - postcss@8.5.3: + postcss@8.5.6: dependencies: - nanoid: 3.3.8 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8667,30 +8682,31 @@ snapshots: dependencies: glob: 7.2.3 - rollup@4.40.2: + rollup@4.50.1: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.40.2 - '@rollup/rollup-android-arm64': 4.40.2 - '@rollup/rollup-darwin-arm64': 4.40.2 - '@rollup/rollup-darwin-x64': 4.40.2 - '@rollup/rollup-freebsd-arm64': 4.40.2 - '@rollup/rollup-freebsd-x64': 4.40.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.40.2 - '@rollup/rollup-linux-arm-musleabihf': 4.40.2 - '@rollup/rollup-linux-arm64-gnu': 4.40.2 - '@rollup/rollup-linux-arm64-musl': 4.40.2 - '@rollup/rollup-linux-loongarch64-gnu': 4.40.2 - '@rollup/rollup-linux-powerpc64le-gnu': 4.40.2 - '@rollup/rollup-linux-riscv64-gnu': 4.40.2 - '@rollup/rollup-linux-riscv64-musl': 4.40.2 - '@rollup/rollup-linux-s390x-gnu': 4.40.2 - '@rollup/rollup-linux-x64-gnu': 4.40.2 - '@rollup/rollup-linux-x64-musl': 4.40.2 - '@rollup/rollup-win32-arm64-msvc': 4.40.2 - '@rollup/rollup-win32-ia32-msvc': 4.40.2 - '@rollup/rollup-win32-x64-msvc': 4.40.2 + '@rollup/rollup-android-arm-eabi': 4.50.1 + '@rollup/rollup-android-arm64': 4.50.1 + '@rollup/rollup-darwin-arm64': 4.50.1 + '@rollup/rollup-darwin-x64': 4.50.1 + '@rollup/rollup-freebsd-arm64': 4.50.1 + '@rollup/rollup-freebsd-x64': 4.50.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 + '@rollup/rollup-linux-arm-musleabihf': 4.50.1 + '@rollup/rollup-linux-arm64-gnu': 4.50.1 + '@rollup/rollup-linux-arm64-musl': 4.50.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 + '@rollup/rollup-linux-ppc64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-musl': 4.50.1 + '@rollup/rollup-linux-s390x-gnu': 4.50.1 + '@rollup/rollup-linux-x64-gnu': 4.50.1 + '@rollup/rollup-linux-x64-musl': 4.50.1 + '@rollup/rollup-openharmony-arm64': 4.50.1 + '@rollup/rollup-win32-arm64-msvc': 4.50.1 + '@rollup/rollup-win32-ia32-msvc': 4.50.1 + '@rollup/rollup-win32-x64-msvc': 4.50.1 fsevents: 2.3.3 rope-sequence@1.3.2: {} @@ -9027,7 +9043,7 @@ snapshots: terser@5.33.0: dependencies: - '@jridgewell/source-map': 0.3.10 + '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -9269,7 +9285,7 @@ snapshots: debug: 4.4.0 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: - '@types/node' - less @@ -9281,19 +9297,19 @@ snapshots: - supports-color - terser - vite-plugin-ruby@5.0.0(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): + vite-plugin-ruby@5.0.0(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)): dependencies: debug: 4.3.5 fast-glob: 3.3.2 - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) transitivePeerDependencies: - supports-color - vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0): + vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0): dependencies: esbuild: 0.21.5 - postcss: 8.5.3 - rollup: 4.40.2 + postcss: 8.5.6 + rollup: 4.50.1 optionalDependencies: '@types/node': 22.7.0 fsevents: 2.3.3 @@ -9303,7 +9319,7 @@ snapshots: vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0): dependencies: '@vitest/expect': 3.0.5 - '@vitest/mocker': 3.0.5(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) + '@vitest/mocker': 3.0.5(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)) '@vitest/pretty-format': 3.0.5 '@vitest/runner': 3.0.5 '@vitest/snapshot': 3.0.5 @@ -9319,7 +9335,7 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) + vite: 5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0) why-is-node-running: 2.3.0 optionalDependencies: From ff5d3045416df8472d45cf4d41ce3074bfbfc490 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 11 Sep 2025 00:35:54 -0700 Subject: [PATCH 09/72] chore(dev): Update histoire config for deployment (#12374) - Adds `.netlify` and `.histoire` directories to `.gitignore` to prevent deployment artifacts from being committed. - Adds `collectMaxThreads: 4` to `histoire.config.ts` to optimize resource usage during Netlify deployment. Fixes #CW-5579 Co-authored-by: Cursor Agent --- .gitignore | 4 ++++ histoire.config.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index bb0df62a8..7ca033f87 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,7 @@ yarn-debug.log* .claude/settings.local.json .cursor CLAUDE.local.md + +# Histoire deployment +.netlify +.histoire diff --git a/histoire.config.ts b/histoire.config.ts index 15c5e7b64..41b6f3c13 100644 --- a/histoire.config.ts +++ b/histoire.config.ts @@ -4,6 +4,7 @@ import { HstVue } from '@histoire/plugin-vue'; export default defineConfig({ setupFile: './histoire.setup.ts', plugins: [HstVue()], + collectMaxThreads: 4, vite: { server: { port: 6179, From 052b328a1fce25fb73beb0d0cd060ac143de253a Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 11 Sep 2025 00:39:07 -0700 Subject: [PATCH 10/72] fix: Use translations for name when sending emails (#12411) Fix missing translation. --- app/mailers/conversation_reply_mailer.rb | 2 +- config/locales/en.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 360b227cb..a82c65440 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -101,7 +101,7 @@ class ConversationReplyMailer < ApplicationMailer end def custom_sender_name - current_message&.sender&.available_name || @agent&.available_name || 'Notifications' + current_message&.sender&.available_name || @agent&.available_name || I18n.t('conversations.reply.email.header.notifications') end def business_name diff --git a/config/locales/en.yml b/config/locales/en.yml index 68f0b72fd..e958c63b9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -228,6 +228,7 @@ en: reply: email: header: + notifications: 'Notifications' from_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>' reply_with_name: '%{assignee_name} from %{inbox_name} ' friendly_name: '%{sender_name} from %{business_name} <%{from_email}>' From e5b8dc251f08bc991a189cd81075d909275173e5 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 11 Sep 2025 19:32:33 +0700 Subject: [PATCH 11/72] fix: Assignment V2 controller fix (#12415) --- .../users_controller.rb | 4 +-- .../agent_capacity_policies_controller.rb | 2 +- .../users_controller_spec.rb | 20 ++++++++++++ ...agent_capacity_policies_controller_spec.rb | 31 ++++++++++++++++++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies/users_controller.rb b/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies/users_controller.rb index a49b4f00f..f8b085732 100644 --- a/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies/users_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies/users_controller.rb @@ -4,8 +4,8 @@ class Api::V1::Accounts::AgentCapacityPolicies::UsersController < Api::V1::Accou before_action :fetch_user, only: [:destroy] def index - @users = Current.account.users.joins(:account_users) - .where(account_users: { agent_capacity_policy_id: @agent_capacity_policy.id }) + @users = User.joins(:account_users) + .where(account_users: { account_id: Current.account.id, agent_capacity_policy_id: @agent_capacity_policy.id }) end def create diff --git a/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb b/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb index d6d166ee5..9ece78762 100644 --- a/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb @@ -27,7 +27,7 @@ class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::En params.require(:agent_capacity_policy).permit( :name, :description, - exclusion_rules: [:overall_capacity, { hours: [], days: [] }] + exclusion_rules: [:exclude_older_than_hours, { excluded_labels: [] }] ) end diff --git a/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies/users_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies/users_controller_spec.rb index be25151ae..9ed837107 100644 --- a/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies/users_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies/users_controller_spec.rb @@ -19,6 +19,26 @@ RSpec.describe 'Agent Capacity Policy Users API', type: :request do expect(response).to have_http_status(:success) expect(response.parsed_body.first['id']).to eq(user.id) end + + it 'returns each user only once without duplicates' do + # Assign multiple users to the same policy + user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy) + agent.account_users.first.update!(agent_capacity_policy: agent_capacity_policy) + + get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + # Check that we have exactly 2 users + expect(response.parsed_body.length).to eq(2) + + # Check that each user appears only once + user_ids = response.parsed_body.map { |u| u['id'] } + expect(user_ids).to contain_exactly(user.id, agent.id) + expect(user_ids.uniq).to eq(user_ids) # No duplicates + end end end diff --git a/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies_controller_spec.rb index d6b171fe8..691891027 100644 --- a/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/agent_capacity_policies_controller_spec.rb @@ -103,7 +103,10 @@ RSpec.describe 'Agent Capacity Policies API', type: :request do agent_capacity_policy: { name: 'Test Policy', description: 'Test Description', - exclusion_rules: { overall_capacity: 10 } + exclusion_rules: { + excluded_labels: %w[urgent spam], + exclude_older_than_hours: 24 + } } } @@ -115,6 +118,10 @@ RSpec.describe 'Agent Capacity Policies API', type: :request do expect(response).to have_http_status(:success) expect(response.parsed_body['name']).to eq('Test Policy') expect(response.parsed_body['description']).to eq('Test Description') + expect(response.parsed_body['exclusion_rules']).to eq({ + 'excluded_labels' => %w[urgent spam], + 'exclude_older_than_hours' => 24 + }) end it 'returns validation errors for invalid data' do @@ -165,6 +172,28 @@ RSpec.describe 'Agent Capacity Policies API', type: :request do expect(response).to have_http_status(:success) expect(response.parsed_body['name']).to eq('Updated Policy') end + + it 'updates exclusion rules when administrator' do + params = { + agent_capacity_policy: { + exclusion_rules: { + excluded_labels: %w[vip priority], + exclude_older_than_hours: 48 + } + } + } + + put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}", + params: params, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['exclusion_rules']).to eq({ + 'excluded_labels' => %w[vip priority], + 'exclude_older_than_hours' => 48 + }) + end end end From 55315089cf0d5ed8d1bd067c53d110fa80696a45 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 11 Sep 2025 18:43:36 +0530 Subject: [PATCH 12/72] fix(delete_object_job): pre-purge heavy associations before destroy to prevent timeout (#12408) Deleting large Accounts/Inboxes with object.destroy! can time out and create heavy destroy_async fan-out; this change adds a simple pre-purge that batch-destroys heavy associations first . ``` Account: conversations, contacts Inbox: conversations, contact_inboxes ``` We use find_in_batches(5000), then proceeds with destroy!, reducing DB pressure and race conditions while preserving callbacks and leaving the behavior for non heavy models unchanged. The change is also done in a way to easily add additional objects or relations to the list. fixes: https://linear.app/chatwoot/issue/CW-3106/inbox-deletion-process-update-the-flow --- app/jobs/delete_object_job.rb | 28 +++++++++++ spec/jobs/delete_object_job_spec.rb | 78 ++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/app/jobs/delete_object_job.rb b/app/jobs/delete_object_job.rb index 49a7e4752..756d0feb1 100644 --- a/app/jobs/delete_object_job.rb +++ b/app/jobs/delete_object_job.rb @@ -1,12 +1,40 @@ class DeleteObjectJob < ApplicationJob queue_as :low + BATCH_SIZE = 5_000 + HEAVY_ASSOCIATIONS = { + Account => %i[conversations contacts inboxes reporting_events], + Inbox => %i[conversations contact_inboxes reporting_events] + }.freeze + def perform(object, user = nil, ip = nil) + # Pre-purge heavy associations for large objects to avoid + # timeouts & race conditions due to destroy_async fan-out. + purge_heavy_associations(object) object.destroy! process_post_deletion_tasks(object, user, ip) end def process_post_deletion_tasks(object, user, ip); end + + private + + def purge_heavy_associations(object) + klass = HEAVY_ASSOCIATIONS.keys.find { |k| object.is_a?(k) } + return unless klass + + HEAVY_ASSOCIATIONS[klass].each do |assoc| + next unless object.respond_to?(assoc) + + batch_destroy(object.public_send(assoc)) + end + end + + def batch_destroy(relation) + relation.find_in_batches(batch_size: BATCH_SIZE) do |batch| + batch.each(&:destroy!) + end + end end DeleteObjectJob.prepend_mod_with('DeleteObjectJob') diff --git a/spec/jobs/delete_object_job_spec.rb b/spec/jobs/delete_object_job_spec.rb index 854ff1da2..cb01ceb34 100644 --- a/spec/jobs/delete_object_job_spec.rb +++ b/spec/jobs/delete_object_job_spec.rb @@ -1,20 +1,74 @@ require 'rails_helper' -RSpec.describe DeleteObjectJob do - subject(:job) { described_class.perform_later(account) } +RSpec.describe DeleteObjectJob, type: :job do + describe '#perform' do + context 'when object is heavy (Inbox)' do + let!(:account) { create(:account) } + let!(:inbox) { create(:inbox, account: account) } - let(:account) { create(:account) } + before do + create_list(:conversation, 3, account: account, inbox: inbox) + ReportingEvent.create!(account: account, inbox: inbox, name: 'inbox_metric', value: 1.0) + end - it 'enqueues the job' do - expect { job }.to have_enqueued_job(described_class) - .with(account) - .on_queue('low') - end + it 'enqueues on the low queue' do + expect { described_class.perform_later(inbox) } + .to have_enqueued_job(described_class).with(inbox).on_queue('low') + end - context 'when an object is passed to the job' do - it 'is deleted' do - described_class.perform_now(account) - expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound) + it 'pre-deletes heavy associations and then destroys the object' do + conv_ids = inbox.conversations.pluck(:id) + ci_ids = inbox.contact_inboxes.pluck(:id) + contact_ids = inbox.contacts.pluck(:id) + re_ids = inbox.reporting_events.pluck(:id) + + described_class.perform_now(inbox) + + expect(Conversation.where(id: conv_ids)).to be_empty + expect(ContactInbox.where(id: ci_ids)).to be_empty + expect(ReportingEvent.where(id: re_ids)).to be_empty + # Contacts should not be deleted for inbox destroy + expect(Contact.where(id: contact_ids)).not_to be_empty + expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when object is heavy (Account)' do + let!(:account) { create(:account) } + let!(:inbox1) { create(:inbox, account: account) } + let!(:inbox2) { create(:inbox, account: account) } + + before do + create_list(:conversation, 2, account: account, inbox: inbox1) + create_list(:conversation, 1, account: account, inbox: inbox2) + ReportingEvent.create!(account: account, name: 'acct_metric', value: 2.5) + ReportingEvent.create!(account: account, inbox: inbox1, name: 'acct_inbox_metric', value: 3.5) + end + + it 'pre-deletes conversations, contacts, inboxes and reporting events and then destroys the account' do + conv_ids = account.conversations.pluck(:id) + contact_ids = account.contacts.pluck(:id) + inbox_ids = account.inboxes.pluck(:id) + re_ids = account.reporting_events.pluck(:id) + + described_class.perform_now(account) + + expect(Conversation.where(id: conv_ids)).to be_empty + expect(Contact.where(id: contact_ids)).to be_empty + expect(Inbox.where(id: inbox_ids)).to be_empty + expect(ReportingEvent.where(id: re_ids)).to be_empty + expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when object is regular (Label)' do + it 'just destroys the object' do + label = create(:label) + + described_class.perform_now(label) + + expect { label.reload }.to raise_error(ActiveRecord::RecordNotFound) + end end end end From de5fb7a405ab39e7d81d6371d0ac3204c97db6cd Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 11 Sep 2025 22:25:26 +0530 Subject: [PATCH 13/72] chore: remove unused telegram bot model (#12417) ## Summary - remove unused TelegramBot model and its association - drop obsolete telegram_bots table --- app/models/account.rb | 1 - app/models/telegram_bot.rb | 17 ----------------- db/migrate/20250826000000_drop_telegram_bots.rb | 10 ++++++++++ db/schema.rb | 10 +--------- spec/models/account_spec.rb | 1 - 5 files changed, 11 insertions(+), 28 deletions(-) delete mode 100644 app/models/telegram_bot.rb create mode 100644 db/migrate/20250826000000_drop_telegram_bots.rb diff --git a/app/models/account.rb b/app/models/account.rb index b84d7f526..7efb13bb5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -89,7 +89,6 @@ class Account < ApplicationRecord has_many :portals, dependent: :destroy_async, class_name: '::Portal' has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms' has_many :teams, dependent: :destroy_async - has_many :telegram_bots, dependent: :destroy_async has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram' has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms' has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile' diff --git a/app/models/telegram_bot.rb b/app/models/telegram_bot.rb deleted file mode 100644 index 725250053..000000000 --- a/app/models/telegram_bot.rb +++ /dev/null @@ -1,17 +0,0 @@ -# == Schema Information -# -# Table name: telegram_bots -# -# id :integer not null, primary key -# auth_key :string -# name :string -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer -# - -class TelegramBot < ApplicationRecord - belongs_to :account - has_one :inbox, as: :channel, dependent: :destroy_async - validates :auth_key, uniqueness: { scope: :account_id } -end diff --git a/db/migrate/20250826000000_drop_telegram_bots.rb b/db/migrate/20250826000000_drop_telegram_bots.rb new file mode 100644 index 000000000..5625e41e9 --- /dev/null +++ b/db/migrate/20250826000000_drop_telegram_bots.rb @@ -0,0 +1,10 @@ +class DropTelegramBots < ActiveRecord::Migration[7.1] + def change + drop_table :telegram_bots do |t| + t.string :name + t.string :auth_key + t.integer :account_id + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e320cf2b6..b762891a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_08_25_070005) do +ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1146,14 +1146,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_25_070005) do t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true end - create_table "telegram_bots", id: :serial, force: :cascade do |t| - t.string "name" - t.string "auth_key" - t.integer "account_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - end - create_table "users", id: :serial, force: :cascade do |t| t.string "provider", default: "email", null: false t.string "uid", default: "", null: false diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 51ab792cf..842aaf732 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -8,7 +8,6 @@ RSpec.describe Account do it { is_expected.to have_many(:inboxes).dependent(:destroy_async) } it { is_expected.to have_many(:conversations).dependent(:destroy_async) } it { is_expected.to have_many(:contacts).dependent(:destroy_async) } - it { is_expected.to have_many(:telegram_bots).dependent(:destroy_async) } it { is_expected.to have_many(:canned_responses).dependent(:destroy_async) } it { is_expected.to have_many(:facebook_pages).class_name('::Channel::FacebookPage').dependent(:destroy_async) } it { is_expected.to have_many(:web_widgets).class_name('::Channel::WebWidget').dependent(:destroy_async) } From 16b98b6017cc9cf350338abe4d903b389a21854c Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 11 Sep 2025 22:27:38 -0700 Subject: [PATCH 14/72] fix: Add account_id to the query to force using a better index (#12420) I've added the account_id filter to the `get_agent_ids_over_assignment_limit` method. This optimization will help the query leverage the existing composite index `conv_acid_inbid_stat_asgnid_idx (account_id, inbox_id, status, assignee_id)` for better performance. **Before:** ```sql HashAggregate (cost=224238.12..224256.27 rows=484 width=4) Group Key: assignee_id Filter: (count(*) >= 10) -> Index Scan using index_conversations_on_inbox_id on conversations (cost=0.44..223963.67 rows=54891 width=4) Index Cond: (inbox_id = ???) Filter: (status = 0) ``` **After:** ```sql GroupAggregate (cost=0.44..5688.30 rows=476 width=4) Group Key: assignee_id Filter: (count(*) >= 10) -> Index Only Scan using conv_acid_inbid_stat_asgnid_idx on conversations (cost=0.44..5640.81 rows=5928 width=4) Index Cond: ((account_id = ??) AND (inbox_id = ??) AND (status = 0)) ``` --- enterprise/app/models/enterprise/inbox.rb | 8 +++++++- spec/enterprise/models/inbox_spec.rb | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/enterprise/app/models/enterprise/inbox.rb b/enterprise/app/models/enterprise/inbox.rb index 0ae21ce00..2462122f7 100644 --- a/enterprise/app/models/enterprise/inbox.rb +++ b/enterprise/app/models/enterprise/inbox.rb @@ -22,7 +22,13 @@ module Enterprise::Inbox end def get_agent_ids_over_assignment_limit(limit) - conversations.open.select(:assignee_id).group(:assignee_id).having("count(*) >= #{limit.to_i}").filter_map(&:assignee_id) + conversations + .open + .where(account_id: account_id) + .select(:assignee_id) + .group(:assignee_id) + .having("count(*) >= #{limit.to_i}") + .filter_map(&:assignee_id) end def ensure_valid_max_assignment_limit diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index 3e3e060d8..b0e063600 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -15,8 +15,8 @@ RSpec.describe Inbox do create(:conversation, inbox: inbox, assignee: inbox_member_1.user) # to test conversations in other inboxes won't impact create_list(:conversation, 3, assignee: inbox_member_1.user) - create_list(:conversation, 2, inbox: inbox, assignee: inbox_member_2.user) - create_list(:conversation, 3, inbox: inbox, assignee: inbox_member_3.user) + create_list(:conversation, 2, inbox: inbox, account: inbox.account, assignee: inbox_member_2.user) + create_list(:conversation, 3, inbox: inbox, account: inbox.account, assignee: inbox_member_3.user) end it 'validated max_assignment_limit' do @@ -29,7 +29,7 @@ RSpec.describe Inbox do it 'returns member ids with assignment capacity with inbox max_assignment_limit is configured' do # agent 1 has 1 conversations, agent 2 has 2 conversations, agent 3 has 3 conversations and agent 4 with none inbox.update(auto_assignment_config: { max_assignment_limit: 2 }) - expect(inbox.member_ids_with_assignment_capacity).to contain_exactly(inbox_member_1.user_id, inbox_member_4.user_id) + expect(inbox.member_ids_with_assignment_capacity).to eq([inbox_member_1.user_id, inbox_member_4.user_id]) end it 'returns all member ids when inbox max_assignment_limit is not configured' do From 59ba91473a66718195690af31f8cf689dbe15ad6 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:22:42 +0530 Subject: [PATCH 15/72] feat: Agent capacity policy index page with CRUD actions (#12409) --- .../dashboard/api/agentCapacityPolicies.js | 43 +++ .../api/specs/agentCapacityPolicies.spec.js | 98 ++++++ .../AgentCapacityPolicyCard.story.vue | 116 +++++++ .../AgentCapacityPolicyCard.vue | 86 +++++ .../components/CardPopover.vue | 52 ++- .../components/FairDistribution.vue | 6 +- .../components/story/CardPopover.story.vue | 44 +++ .../dashboard/i18n/locale/en/settings.json | 27 +- .../settings/assignmentPolicy/Index.vue | 2 +- .../assignmentPolicy.routes.js | 10 + .../pages/AgentCapacityIndexPage.vue | 126 ++++++++ .../components/ConfirmDeletePolicyDialog.vue | 14 +- app/javascript/dashboard/store/index.js | 2 + .../store/modules/agentCapacityPolicies.js | 168 ++++++++++ .../agentCapacityPolicies/actions.spec.js | 227 +++++++++++++ .../specs/agentCapacityPolicies/fixtures.js | 77 +++++ .../agentCapacityPolicies/getters.spec.js | 51 +++ .../agentCapacityPolicies/mutations.spec.js | 303 ++++++++++++++++++ .../dashboard/store/mutation-types.js | 11 + .../_agent_capacity_policy.json.jbuilder | 1 + 20 files changed, 1429 insertions(+), 35 deletions(-) create mode 100644 app/javascript/dashboard/api/agentCapacityPolicies.js create mode 100644 app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityIndexPage.vue create mode 100644 app/javascript/dashboard/store/modules/agentCapacityPolicies.js create mode 100644 app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js create mode 100644 app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js diff --git a/app/javascript/dashboard/api/agentCapacityPolicies.js b/app/javascript/dashboard/api/agentCapacityPolicies.js new file mode 100644 index 000000000..7792ce469 --- /dev/null +++ b/app/javascript/dashboard/api/agentCapacityPolicies.js @@ -0,0 +1,43 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AgentCapacityPolicies extends ApiClient { + constructor() { + super('agent_capacity_policies', { accountScoped: true }); + } + + getUsers(policyId) { + return axios.get(`${this.url}/${policyId}/users`); + } + + addUser(policyId, userData) { + return axios.post(`${this.url}/${policyId}/users`, { + user_id: userData.id, + capacity: userData.capacity, + }); + } + + removeUser(policyId, userId) { + return axios.delete(`${this.url}/${policyId}/users/${userId}`); + } + + createInboxLimit(policyId, limitData) { + return axios.post(`${this.url}/${policyId}/inbox_limits`, { + inbox_id: limitData.inboxId, + conversation_limit: limitData.conversationLimit, + }); + } + + updateInboxLimit(policyId, limitId, limitData) { + return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, { + conversation_limit: limitData.conversationLimit, + }); + } + + deleteInboxLimit(policyId, limitId) { + return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`); + } +} + +export default new AgentCapacityPolicies(); diff --git a/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js new file mode 100644 index 000000000..43932aa71 --- /dev/null +++ b/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js @@ -0,0 +1,98 @@ +import agentCapacityPolicies from '../agentCapacityPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AgentCapacityPoliciesAPI', () => { + it('creates correct instance', () => { + expect(agentCapacityPolicies).toBeInstanceOf(ApiClient); + expect(agentCapacityPolicies).toHaveProperty('get'); + expect(agentCapacityPolicies).toHaveProperty('show'); + expect(agentCapacityPolicies).toHaveProperty('create'); + expect(agentCapacityPolicies).toHaveProperty('update'); + expect(agentCapacityPolicies).toHaveProperty('delete'); + expect(agentCapacityPolicies).toHaveProperty('getUsers'); + expect(agentCapacityPolicies).toHaveProperty('addUser'); + expect(agentCapacityPolicies).toHaveProperty('removeUser'); + expect(agentCapacityPolicies).toHaveProperty('createInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + put: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getUsers', () => { + agentCapacityPolicies.getUsers(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users' + ); + }); + + it('#addUser', () => { + const userData = { id: 456, capacity: 20 }; + agentCapacityPolicies.addUser(123, userData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users', + { + user_id: 456, + capacity: 20, + } + ); + }); + + it('#removeUser', () => { + agentCapacityPolicies.removeUser(123, 456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users/456' + ); + }); + + it('#createInboxLimit', () => { + const limitData = { inboxId: 1, conversationLimit: 10 }; + agentCapacityPolicies.createInboxLimit(123, limitData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits', + { + inbox_id: 1, + conversation_limit: 10, + } + ); + }); + + it('#updateInboxLimit', () => { + const limitData = { conversationLimit: 15 }; + agentCapacityPolicies.updateInboxLimit(123, 789, limitData); + expect(axiosMock.put).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789', + { + conversation_limit: 15, + } + ); + }); + + it('#deleteInboxLimit', () => { + agentCapacityPolicies.deleteInboxLimit(123, 789); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue new file mode 100644 index 000000000..10f301230 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.story.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue new file mode 100644 index 000000000..3c749e751 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AgentCapacityPolicyCard/AgentCapacityPolicyCard.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue index 013e6d5fe..50d7794c9 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue @@ -1,6 +1,8 @@ diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index fd7247396..dd2fdc904 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -563,13 +563,32 @@ } }, "DELETE_POLICY": { - "TITLE": "Delete policy", - "DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.", - "CONFIRM_BUTTON_LABEL": "Delete", - "CANCEL_BUTTON_LABEL": "Cancel", "SUCCESS_MESSAGE": "Assignment policy deleted successfully", "ERROR_MESSAGE": "Failed to delete assignment policy" } + }, + "AGENT_CAPACITY_POLICY": { + "INDEX": { + "HEADER": { + "TITLE": "Agent capacity", + "CREATE_POLICY": "New policy" + }, + "CARD": { + "POPOVER": "Added agents", + "EDIT": "Edit" + }, + "NO_RECORDS_FOUND": "No agent capacity policies found" + }, + "DELETE_POLICY": { + "SUCCESS_MESSAGE": "Agent capacity policy deleted successfully", + "ERROR_MESSAGE": "Failed to delete agent capacity policy" + } + }, + "DELETE_POLICY": { + "TITLE": "Delete policy", + "DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.", + "CONFIRM_BUTTON_LABEL": "Delete", + "CANCEL_BUTTON_LABEL": "Cancel" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue index b41d9990a..ab9fcf17e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue @@ -30,7 +30,7 @@ const agentAssignments = computed(() => [ ], }, { - key: 'agent_capacity_policy', + key: 'agent_capacity_policy_index', title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'), description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'), features: [ diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js index 37934f505..f09cc1485 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js @@ -5,6 +5,7 @@ import AssignmentPolicyIndex from './Index.vue'; import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue'; import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue'; import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue'; +import AgentCapacityIndex from './pages/AgentCapacityIndexPage.vue'; export default { routes: [ @@ -54,6 +55,15 @@ export default { permissions: ['administrator'], }, }, + { + path: 'capacity', + name: 'agent_capacity_policy_index', + component: AgentCapacityIndex, + meta: { + featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, + permissions: ['administrator'], + }, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityIndexPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityIndexPage.vue new file mode 100644 index 000000000..fb94e5fb4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityIndexPage.vue @@ -0,0 +1,126 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue index 8cf13bcd5..2dd57f713 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue @@ -31,19 +31,13 @@ defineExpose({ openDialog, closeDialog }); diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 291031ffd..16bcab3f9 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -2,6 +2,7 @@ import { createStore } from 'vuex'; import accounts from './modules/accounts'; import agentBots from './modules/agentBots'; +import agentCapacityPolicies from './modules/agentCapacityPolicies'; import agents from './modules/agents'; import assignmentPolicies from './modules/assignmentPolicies'; import articles from './modules/helpCenterArticles'; @@ -63,6 +64,7 @@ export default createStore({ modules: { accounts, agentBots, + agentCapacityPolicies, agents, assignmentPolicies, articles, diff --git a/app/javascript/dashboard/store/modules/agentCapacityPolicies.js b/app/javascript/dashboard/store/modules/agentCapacityPolicies.js new file mode 100644 index 000000000..e1e766f2c --- /dev/null +++ b/app/javascript/dashboard/store/modules/agentCapacityPolicies.js @@ -0,0 +1,168 @@ +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import types from '../mutation-types'; +import AgentCapacityPoliciesAPI from '../../api/agentCapacityPolicies'; +import { throwErrorMessage } from '../utils/api'; +import camelcaseKeys from 'camelcase-keys'; +import snakecaseKeys from 'snakecase-keys'; + +export const state = { + records: [], + uiFlags: { + isFetching: false, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }, + usersUiFlags: { + isFetching: false, + isDeleting: false, + }, +}; + +export const getters = { + getAgentCapacityPolicies(_state) { + return _state.records; + }, + getUIFlags(_state) { + return _state.uiFlags; + }, + getUsersUIFlags(_state) { + return _state.usersUiFlags; + }, + getAgentCapacityPolicyById: _state => id => { + return _state.records.find(record => record.id === Number(id)) || {}; + }, +}; + +export const actions = { + get: async function get({ commit }) { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true }); + try { + const response = await AgentCapacityPoliciesAPI.get(); + commit(types.SET_AGENT_CAPACITY_POLICIES, camelcaseKeys(response.data)); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false }); + } + }, + + show: async function show({ commit }, policyId) { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true }); + try { + const response = await AgentCapacityPoliciesAPI.show(policyId); + const policy = camelcaseKeys(response.data); + commit(types.SET_AGENT_CAPACITY_POLICY, policy); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { + isFetchingItem: false, + }); + } + }, + + create: async function create({ commit }, policyObj) { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true }); + try { + const response = await AgentCapacityPoliciesAPI.create( + snakecaseKeys(policyObj) + ); + commit(types.ADD_AGENT_CAPACITY_POLICY, camelcaseKeys(response.data)); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false }); + } + }, + + update: async function update({ commit }, { id, ...policyParams }) { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true }); + try { + const response = await AgentCapacityPoliciesAPI.update( + id, + snakecaseKeys(policyParams) + ); + commit(types.EDIT_AGENT_CAPACITY_POLICY, camelcaseKeys(response.data)); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false }); + } + }, + + delete: async function deletePolicy({ commit }, policyId) { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true }); + try { + await AgentCapacityPoliciesAPI.delete(policyId); + commit(types.DELETE_AGENT_CAPACITY_POLICY, policyId); + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false }); + } + }, + + getUsers: async function getUsers({ commit }, policyId) { + commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { + isFetching: true, + }); + try { + const response = await AgentCapacityPoliciesAPI.getUsers(policyId); + commit(types.SET_AGENT_CAPACITY_POLICIES_USERS, { + policyId, + users: camelcaseKeys(response.data), + }); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { + isFetching: false, + }); + } + }, +}; + +export const mutations = { + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](_state, data) { + _state.uiFlags = { + ..._state.uiFlags, + ...data, + }; + }, + + [types.SET_AGENT_CAPACITY_POLICIES]: MutationHelpers.set, + [types.SET_AGENT_CAPACITY_POLICY]: MutationHelpers.setSingleRecord, + [types.ADD_AGENT_CAPACITY_POLICY]: MutationHelpers.create, + [types.EDIT_AGENT_CAPACITY_POLICY]: MutationHelpers.updateAttributes, + [types.DELETE_AGENT_CAPACITY_POLICY]: MutationHelpers.destroy, + + [types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](_state, data) { + _state.usersUiFlags = { + ..._state.usersUiFlags, + ...data, + }; + }, + [types.SET_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, users }) { + const policy = _state.records.find(p => p.id === policyId); + if (policy) { + policy.users = users; + } + }, +}; + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +}; diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js new file mode 100644 index 000000000..0b5ece591 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js @@ -0,0 +1,227 @@ +import axios from 'axios'; +import { actions } from '../../agentCapacityPolicies'; +import types from '../../../mutation-types'; +import agentCapacityPoliciesList, { camelCaseFixtures } from './fixtures'; +import camelcaseKeys from 'camelcase-keys'; +import snakecaseKeys from 'snakecase-keys'; + +const commit = vi.fn(); + +global.axios = axios; +vi.mock('axios'); +vi.mock('camelcase-keys'); +vi.mock('snakecase-keys'); +vi.mock('../../../utils/api'); + +describe('#actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('#get', () => { + it('sends correct actions if API is success', async () => { + axios.get.mockResolvedValue({ data: agentCapacityPoliciesList }); + camelcaseKeys.mockReturnValue(camelCaseFixtures); + + await actions.get({ commit }); + + expect(camelcaseKeys).toHaveBeenCalledWith(agentCapacityPoliciesList); + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true }], + [types.SET_AGENT_CAPACITY_POLICIES, camelCaseFixtures], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + + await actions.get({ commit }); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true }], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false }], + ]); + }); + }); + + describe('#show', () => { + it('sends correct actions if API is success', async () => { + const policyData = agentCapacityPoliciesList[0]; + const camelCasedPolicy = camelCaseFixtures[0]; + + axios.get.mockResolvedValue({ data: policyData }); + camelcaseKeys.mockReturnValue(camelCasedPolicy); + + await actions.show({ commit }, 1); + + expect(camelcaseKeys).toHaveBeenCalledWith(policyData); + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true }], + [types.SET_AGENT_CAPACITY_POLICY, camelCasedPolicy], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Not found' }); + + await actions.show({ commit }, 1); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true }], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: false }], + ]); + }); + }); + + describe('#create', () => { + it('sends correct actions if API is success', async () => { + const newPolicy = agentCapacityPoliciesList[0]; + const camelCasedData = camelCaseFixtures[0]; + const snakeCasedPolicy = { default_capacity: 10 }; + + axios.post.mockResolvedValue({ data: newPolicy }); + camelcaseKeys.mockReturnValue(camelCasedData); + snakecaseKeys.mockReturnValue(snakeCasedPolicy); + + const result = await actions.create({ commit }, newPolicy); + + expect(snakecaseKeys).toHaveBeenCalledWith(newPolicy); + expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy); + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true }], + [types.ADD_AGENT_CAPACITY_POLICY, camelCasedData], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false }], + ]); + expect(result).toEqual(newPolicy); + }); + + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue(new Error('Validation error')); + + await expect(actions.create({ commit }, {})).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true }], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false }], + ]); + }); + }); + + describe('#update', () => { + it('sends correct actions if API is success', async () => { + const updateParams = { id: 1, name: 'Updated Policy' }; + const responseData = { + ...agentCapacityPoliciesList[0], + name: 'Updated Policy', + }; + const camelCasedData = { + ...camelCaseFixtures[0], + name: 'Updated Policy', + }; + const snakeCasedParams = { name: 'Updated Policy' }; + + axios.patch.mockResolvedValue({ data: responseData }); + camelcaseKeys.mockReturnValue(camelCasedData); + snakecaseKeys.mockReturnValue(snakeCasedParams); + + const result = await actions.update({ commit }, updateParams); + + expect(snakecaseKeys).toHaveBeenCalledWith({ name: 'Updated Policy' }); + expect(camelcaseKeys).toHaveBeenCalledWith(responseData); + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true }], + [types.EDIT_AGENT_CAPACITY_POLICY, camelCasedData], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false }], + ]); + expect(result).toEqual(responseData); + }); + + it('sends correct actions if API is error', async () => { + axios.patch.mockRejectedValue(new Error('Validation error')); + + await expect( + actions.update({ commit }, { id: 1, name: 'Test' }) + ).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true }], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false }], + ]); + }); + }); + + describe('#delete', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + axios.delete.mockResolvedValue({}); + + await actions.delete({ commit }, policyId); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true }], + [types.DELETE_AGENT_CAPACITY_POLICY, policyId], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue(new Error('Not found')); + + await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true }], + [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + + describe('#getUsers', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + const userData = [ + { id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 }, + { id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 }, + ]; + const camelCasedUsers = [ + { id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 }, + { id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 }, + ]; + + axios.get.mockResolvedValue({ data: userData }); + camelcaseKeys.mockReturnValue(camelCasedUsers); + + const result = await actions.getUsers({ commit }, policyId); + + expect(camelcaseKeys).toHaveBeenCalledWith(userData); + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { isFetching: true }], + [ + types.SET_AGENT_CAPACITY_POLICIES_USERS, + { policyId, users: camelCasedUsers }, + ], + [ + types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, + { isFetching: false }, + ], + ]); + expect(result).toEqual(userData); + }); + + it('sends correct actions if API fails', async () => { + axios.get.mockRejectedValue(new Error('API Error')); + + await expect(actions.getUsers({ commit }, 1)).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { isFetching: true }], + [ + types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, + { isFetching: false }, + ], + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js new file mode 100644 index 000000000..594d5848c --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js @@ -0,0 +1,77 @@ +export default [ + { + id: 1, + name: 'Standard Capacity Policy', + description: 'Default capacity policy for agents', + default_capacity: 10, + enabled: true, + account_id: 1, + assigned_agent_count: 3, + created_at: '2024-01-01T10:00:00.000Z', + updated_at: '2024-01-01T10:00:00.000Z', + users: [], + }, + { + id: 2, + name: 'High Capacity Policy', + description: 'High capacity policy for senior agents', + default_capacity: 20, + enabled: true, + account_id: 1, + assigned_agent_count: 5, + created_at: '2024-01-01T11:00:00.000Z', + updated_at: '2024-01-01T11:00:00.000Z', + users: [], + }, + { + id: 3, + name: 'Disabled Policy', + description: 'Disabled capacity policy', + default_capacity: 5, + enabled: false, + account_id: 1, + assigned_agent_count: 0, + created_at: '2024-01-01T12:00:00.000Z', + updated_at: '2024-01-01T12:00:00.000Z', + users: [], + }, +]; + +export const camelCaseFixtures = [ + { + id: 1, + name: 'Standard Capacity Policy', + description: 'Default capacity policy for agents', + defaultCapacity: 10, + enabled: true, + accountId: 1, + assignedAgentCount: 3, + createdAt: '2024-01-01T10:00:00.000Z', + updatedAt: '2024-01-01T10:00:00.000Z', + users: [], + }, + { + id: 2, + name: 'High Capacity Policy', + description: 'High capacity policy for senior agents', + defaultCapacity: 20, + enabled: true, + accountId: 1, + assignedAgentCount: 5, + createdAt: '2024-01-01T11:00:00.000Z', + updatedAt: '2024-01-01T11:00:00.000Z', + users: [], + }, + { + id: 3, + name: 'Disabled Policy', + description: 'Disabled capacity policy', + defaultCapacity: 5, + enabled: false, + accountId: 1, + assignedAgentCount: 0, + createdAt: '2024-01-01T12:00:00.000Z', + updatedAt: '2024-01-01T12:00:00.000Z', + users: [], + }, +]; diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js new file mode 100644 index 000000000..2acd00ad4 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js @@ -0,0 +1,51 @@ +import { getters } from '../../agentCapacityPolicies'; +import agentCapacityPoliciesList from './fixtures'; + +describe('#getters', () => { + it('getAgentCapacityPolicies', () => { + const state = { records: agentCapacityPoliciesList }; + expect(getters.getAgentCapacityPolicies(state)).toEqual( + agentCapacityPoliciesList + ); + }); + + it('getUIFlags', () => { + const state = { + uiFlags: { + isFetching: true, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }, + }; + expect(getters.getUIFlags(state)).toEqual({ + isFetching: true, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }); + }); + + it('getUsersUIFlags', () => { + const state = { + usersUiFlags: { + isFetching: false, + isDeleting: false, + }, + }; + expect(getters.getUsersUIFlags(state)).toEqual({ + isFetching: false, + isDeleting: false, + }); + }); + + it('getAgentCapacityPolicyById', () => { + const state = { records: agentCapacityPoliciesList }; + expect(getters.getAgentCapacityPolicyById(state)(1)).toEqual( + agentCapacityPoliciesList[0] + ); + expect(getters.getAgentCapacityPolicyById(state)(4)).toEqual({}); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js new file mode 100644 index 000000000..0ab033953 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js @@ -0,0 +1,303 @@ +import { mutations } from '../../agentCapacityPolicies'; +import types from '../../../mutation-types'; +import agentCapacityPoliciesList from './fixtures'; + +describe('#mutations', () => { + describe('#SET_AGENT_CAPACITY_POLICIES_UI_FLAG', () => { + it('sets single ui flag', () => { + const state = { + uiFlags: { + isFetching: false, + isCreating: false, + }, + }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.uiFlags).toEqual({ + isFetching: true, + isCreating: false, + }); + }); + + it('sets multiple ui flags', () => { + const state = { + uiFlags: { + isFetching: false, + isCreating: false, + isUpdating: false, + }, + }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](state, { + isFetching: true, + isCreating: true, + }); + + expect(state.uiFlags).toEqual({ + isFetching: true, + isCreating: true, + isUpdating: false, + }); + }); + }); + + describe('#SET_AGENT_CAPACITY_POLICIES', () => { + it('sets agent capacity policies records', () => { + const state = { records: [] }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES]( + state, + agentCapacityPoliciesList + ); + + expect(state.records).toEqual(agentCapacityPoliciesList); + }); + + it('replaces existing records', () => { + const state = { records: [{ id: 999, name: 'Old Policy' }] }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES]( + state, + agentCapacityPoliciesList + ); + + expect(state.records).toEqual(agentCapacityPoliciesList); + }); + }); + + describe('#SET_AGENT_CAPACITY_POLICY', () => { + it('sets single agent capacity policy record', () => { + const state = { records: [] }; + + mutations[types.SET_AGENT_CAPACITY_POLICY]( + state, + agentCapacityPoliciesList[0] + ); + + expect(state.records).toEqual([agentCapacityPoliciesList[0]]); + }); + + it('replaces existing record', () => { + const state = { records: [{ id: 1, name: 'Old Policy' }] }; + + mutations[types.SET_AGENT_CAPACITY_POLICY]( + state, + agentCapacityPoliciesList[0] + ); + + expect(state.records).toEqual([agentCapacityPoliciesList[0]]); + }); + }); + + describe('#ADD_AGENT_CAPACITY_POLICY', () => { + it('adds new policy to empty records', () => { + const state = { records: [] }; + + mutations[types.ADD_AGENT_CAPACITY_POLICY]( + state, + agentCapacityPoliciesList[0] + ); + + expect(state.records).toEqual([agentCapacityPoliciesList[0]]); + }); + + it('adds new policy to existing records', () => { + const state = { records: [agentCapacityPoliciesList[0]] }; + + mutations[types.ADD_AGENT_CAPACITY_POLICY]( + state, + agentCapacityPoliciesList[1] + ); + + expect(state.records).toEqual([ + agentCapacityPoliciesList[0], + agentCapacityPoliciesList[1], + ]); + }); + }); + + describe('#EDIT_AGENT_CAPACITY_POLICY', () => { + it('updates existing policy by id', () => { + const state = { + records: [ + { ...agentCapacityPoliciesList[0] }, + { ...agentCapacityPoliciesList[1] }, + ], + }; + + const updatedPolicy = { + ...agentCapacityPoliciesList[0], + name: 'Updated Policy Name', + description: 'Updated Description', + }; + + mutations[types.EDIT_AGENT_CAPACITY_POLICY](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + expect(state.records[1]).toEqual(agentCapacityPoliciesList[1]); + }); + + it('updates policy with camelCase properties', () => { + const camelCasePolicy = { + id: 1, + name: 'Camel Case Policy', + defaultCapacity: 15, + enabled: true, + }; + + const state = { + records: [camelCasePolicy], + }; + + const updatedPolicy = { + ...camelCasePolicy, + name: 'Updated Camel Case', + defaultCapacity: 25, + }; + + mutations[types.EDIT_AGENT_CAPACITY_POLICY](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + }); + + it('does nothing if policy id not found', () => { + const state = { + records: [agentCapacityPoliciesList[0]], + }; + + const nonExistentPolicy = { + id: 999, + name: 'Non-existent', + }; + + const originalRecords = [...state.records]; + mutations[types.EDIT_AGENT_CAPACITY_POLICY](state, nonExistentPolicy); + + expect(state.records).toEqual(originalRecords); + }); + }); + + describe('#DELETE_AGENT_CAPACITY_POLICY', () => { + it('deletes policy by id', () => { + const state = { + records: [agentCapacityPoliciesList[0], agentCapacityPoliciesList[1]], + }; + + mutations[types.DELETE_AGENT_CAPACITY_POLICY](state, 1); + + expect(state.records).toEqual([agentCapacityPoliciesList[1]]); + }); + + it('does nothing if id not found', () => { + const state = { + records: [agentCapacityPoliciesList[0]], + }; + + mutations[types.DELETE_AGENT_CAPACITY_POLICY](state, 999); + + expect(state.records).toEqual([agentCapacityPoliciesList[0]]); + }); + + it('handles empty records', () => { + const state = { records: [] }; + + mutations[types.DELETE_AGENT_CAPACITY_POLICY](state, 1); + + expect(state.records).toEqual([]); + }); + }); + + describe('#SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG', () => { + it('sets users ui flags', () => { + const state = { + usersUiFlags: { + isFetching: false, + }, + }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.usersUiFlags).toEqual({ + isFetching: true, + }); + }); + + it('merges with existing flags', () => { + const state = { + usersUiFlags: { + isFetching: false, + isDeleting: true, + }, + }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.usersUiFlags).toEqual({ + isFetching: true, + isDeleting: true, + }); + }); + }); + + describe('#SET_AGENT_CAPACITY_POLICIES_USERS', () => { + it('sets users for existing policy', () => { + const mockUsers = [ + { id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 }, + { id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 }, + ]; + + const state = { + records: [ + { id: 1, name: 'Policy 1', users: [] }, + { id: 2, name: 'Policy 2', users: [] }, + ], + }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, { + policyId: 1, + users: mockUsers, + }); + + expect(state.records[0].users).toEqual(mockUsers); + expect(state.records[1].users).toEqual([]); + }); + + it('replaces existing users', () => { + const oldUsers = [{ id: 99, name: 'Old Agent', capacity: 5 }]; + const newUsers = [{ id: 1, name: 'New Agent', capacity: 15 }]; + + const state = { + records: [{ id: 1, name: 'Policy 1', users: oldUsers }], + }; + + mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, { + policyId: 1, + users: newUsers, + }); + + expect(state.records[0].users).toEqual(newUsers); + }); + + it('does nothing if policy not found', () => { + const state = { + records: [{ id: 1, name: 'Policy 1', users: [] }], + }; + + const originalState = JSON.parse(JSON.stringify(state)); + + mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, { + policyId: 999, + users: [{ id: 1, name: 'Test' }], + }); + + expect(state).toEqual(originalState); + }); + }); +}); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 3b5ba16f2..2a5948d14 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -361,4 +361,15 @@ export default { 'SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', DELETE_ASSIGNMENT_POLICIES_INBOXES: 'DELETE_ASSIGNMENT_POLICIES_INBOXES', ADD_ASSIGNMENT_POLICIES_INBOXES: 'ADD_ASSIGNMENT_POLICIES_INBOXES', + + // Agent Capacity Policies + SET_AGENT_CAPACITY_POLICIES_UI_FLAG: 'SET_AGENT_CAPACITY_POLICIES_UI_FLAG', + SET_AGENT_CAPACITY_POLICIES: 'SET_AGENT_CAPACITY_POLICIES', + SET_AGENT_CAPACITY_POLICY: 'SET_AGENT_CAPACITY_POLICY', + ADD_AGENT_CAPACITY_POLICY: 'ADD_AGENT_CAPACITY_POLICY', + EDIT_AGENT_CAPACITY_POLICY: 'EDIT_AGENT_CAPACITY_POLICY', + DELETE_AGENT_CAPACITY_POLICY: 'DELETE_AGENT_CAPACITY_POLICY', + SET_AGENT_CAPACITY_POLICIES_USERS: 'SET_AGENT_CAPACITY_POLICIES_USERS', + SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG: + 'SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG', }; diff --git a/enterprise/app/views/api/v1/models/_agent_capacity_policy.json.jbuilder b/enterprise/app/views/api/v1/models/_agent_capacity_policy.json.jbuilder index 8f7a41aa1..2051cc1c1 100644 --- a/enterprise/app/views/api/v1/models/_agent_capacity_policy.json.jbuilder +++ b/enterprise/app/views/api/v1/models/_agent_capacity_policy.json.jbuilder @@ -5,6 +5,7 @@ json.exclusion_rules agent_capacity_policy.exclusion_rules json.created_at agent_capacity_policy.created_at.to_i json.updated_at agent_capacity_policy.updated_at.to_i json.account_id agent_capacity_policy.account_id +json.assigned_agent_count agent_capacity_policy.account_users.count json.inbox_capacity_limits agent_capacity_policy.inbox_capacity_limits do |limit| json.id limit.id From 18a8e3db473213fbdf4f2a3fbca7e12eb9efad66 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 12 Sep 2025 17:42:07 +0530 Subject: [PATCH 16/72] chore: Update Tehran timezone from GMT+04:30 to GMT+03:30 (#12427) --- .../routes/dashboard/settings/inbox/helpers/timezones.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json index b238add21..810415ffb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/helpers/timezones.json @@ -86,7 +86,7 @@ "Riyadh (GMT+03:00)": "Asia/Riyadh", "Nairobi (GMT+03:00)": "Africa/Nairobi", "Baghdad (GMT+03:00)": "Asia/Baghdad", - "Tehran (GMT+04:30)": "Asia/Tehran", + "Tehran (GMT+03:30)": "Asia/Tehran", "Abu Dhabi (GMT+04:00)": "Asia/Muscat", "Muscat (GMT+04:00)": "Asia/Muscat", "Baku (GMT+04:00)": "Asia/Baku", From 699731d351b83f6c4308d39c97cb9c5ef9144657 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 12 Sep 2025 17:46:27 +0530 Subject: [PATCH 17/72] fix: `display_id` to `id` mapping not handled (#12426) The frontend filtering didn't handle the `id` to `display_id` mapping of conversations. This PR fixes it --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../dashboard/components-next/filter/ConditionRow.vue | 8 +++++++- .../dashboard/components-next/filter/provider.js | 6 +++--- .../dashboard/components-next/input/Input.vue | 9 +++++++-- .../modules/conversations/helpers/filterHelpers.js | 4 +++- .../conversations/helpers/specs/filterHelpers.spec.js | 10 +++++----- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue index 1d53a3af2..b9b2d780c 100644 --- a/app/javascript/dashboard/components-next/filter/ConditionRow.vue +++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue @@ -103,6 +103,12 @@ const validationError = computed(() => { ); }); +const inputFieldType = computed(() => { + if (inputType.value === 'date') return 'date'; + if (inputType.value === 'number') return 'number'; + return 'text'; +}); + const resetModelOnAttributeKeyChange = newAttributeKey => { /** * Resets the filter values and operator when the attribute key changes. This ensures that @@ -182,7 +188,7 @@ defineExpose({ validate }); diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js index f6d078d76..7fd600da9 100644 --- a/app/javascript/dashboard/components-next/filter/provider.js +++ b/app/javascript/dashboard/components-next/filter/provider.js @@ -164,8 +164,8 @@ export function useConversationFilterContext() { value: CONVERSATION_ATTRIBUTES.DISPLAY_ID, attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'), label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'), - inputType: 'plainText', - datatype: 'number', + inputType: 'number', + dataType: 'number', filterOperators: containmentOperators.value, attributeModel: 'standard', }, @@ -179,7 +179,7 @@ export function useConversationFilterContext() { id: campaign.id, name: campaign.title, })), - datatype: 'number', + dataType: 'number', filterOperators: presenceOperators.value, attributeModel: 'standard', }, diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index f20c43449..f4bf5a94f 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -55,7 +55,12 @@ const inputOutlineClass = computed(() => { }); const handleInput = event => { - emit('update:modelValue', event.target.value); + let value = event.target.value; + // Convert to number if type is number and value is not empty + if (props.type === 'number' && value !== '') { + value = Number(value); + } + emit('update:modelValue', value); emit('input', event); }; @@ -114,7 +119,7 @@ onMounted(() => { ? max : undefined " - class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out" + class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js index 3f1c32059..3d627e3ef 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js @@ -64,11 +64,13 @@ const getValueFromConversation = (conversation, attributeKey) => { switch (attributeKey) { case 'status': case 'priority': - case 'display_id': case 'labels': case 'created_at': case 'last_activity_at': return conversation[attributeKey]; + case 'display_id': + // Frontend uses 'id' but backend expects 'display_id' + return conversation.display_id || conversation.id; case 'assignee_id': return conversation.meta?.assignee?.id; case 'inbox_id': diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js index b36128819..096481c69 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js @@ -247,7 +247,7 @@ describe('filterHelpers', () => { // Text search tests - display_id it('should match conversation with equal_to operator for display_id', () => { - const conversation = { display_id: '12345' }; + const conversation = { id: '12345' }; const filters = [ { attribute_key: 'display_id', @@ -260,7 +260,7 @@ describe('filterHelpers', () => { }); it('should match conversation with contains operator for display_id', () => { - const conversation = { display_id: '12345' }; + const conversation = { id: '12345' }; const filters = [ { attribute_key: 'display_id', @@ -273,7 +273,7 @@ describe('filterHelpers', () => { }); it('should not match conversation with does_not_contain operator for display_id', () => { - const conversation = { display_id: '12345' }; + const conversation = { id: '12345' }; const filters = [ { attribute_key: 'display_id', @@ -286,7 +286,7 @@ describe('filterHelpers', () => { }); it('should match conversation with does_not_contain operator when value is not present', () => { - const conversation = { display_id: '12345' }; + const conversation = { id: '12345' }; const filters = [ { attribute_key: 'display_id', @@ -989,7 +989,7 @@ describe('filterHelpers', () => { it('should handle empty string values in conversation', () => { const conversation = { - display_id: '', + id: '', }; const filters = [ { From ca579bd62a3831c9813d3dd8ccc9840691cc5b68 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 12 Sep 2025 18:42:55 +0530 Subject: [PATCH 18/72] feat: Agent capacity policy Create/Edit pages (#12424) # Pull Request Template ## Description Fixes https://linear.app/chatwoot/issue/CW-5573/feat-createedit-agent-capacity-policy-page ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/8de9e3c5d8824cd998d242636540dd18?sid=1314536f-c8d6-41fd-8139-cae9bf94f942 ### Screenshots **Light mode** image image **Dark mode** image image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../components/AddDataDropdown.vue | 27 +- .../AssignmentPolicy/components/BaseInfo.vue | 3 +- .../AssignmentPolicy/components/DataTable.vue | 20 +- .../components/ExclusionRules.vue | 149 ++++++++ .../components/InboxCapacityLimits.vue | 163 +++++++++ .../story/AddDataDropdown.story.vue | 38 +- .../components/story/DataTable.story.vue | 27 +- .../components/story/ExclusionRules.story.vue | 67 ++++ .../story/InboxCapacityLimits.story.vue | 108 ++++++ .../Contacts/ContactLabels/ContactLabels.vue | 4 +- .../components-next/Label/LabelItem.vue | 3 +- .../dashboard/i18n/locale/en/settings.json | 86 +++++ .../assignmentPolicy.routes.js | 20 ++ .../pages/AgentCapacityCreatePage.vue | 87 +++++ .../pages/AgentCapacityEditPage.vue | 179 ++++++++++ .../components/AgentCapacityPolicyForm.vue | 214 ++++++++++++ .../store/modules/agentCapacityPolicies.js | 156 ++++++++- .../agentCapacityPolicies/actions.spec.js | 191 ++++++++++- .../specs/agentCapacityPolicies/fixtures.js | 126 ++++++- .../agentCapacityPolicies/mutations.spec.js | 324 +++++++++++++++++- .../dashboard/store/mutation-types.js | 6 + 21 files changed, 1965 insertions(+), 33 deletions(-) create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/ExclusionRules.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/InboxCapacityLimits.story.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityCreatePage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue index c664d8929..7c98db791 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue @@ -4,6 +4,7 @@ import { useToggle } from '@vueuse/core'; import { vOnClickOutside } from '@vueuse/components'; import { picoSearch } from '@scmmishra/pico-search'; +import Avatar from 'next/avatar/Avatar.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -36,8 +37,8 @@ const filteredItems = computed(() => { return picoSearch(props.items, query, ['name']); }); -const handleAdd = inbox => { - emit('add', inbox); +const handleAdd = item => { + emit('add', item); togglePopover(false); }; @@ -82,21 +83,35 @@ const handleClickOutside = () => {
+ +
- {{ item.name }} + {{ item.name || item.title }} { emit('validationChange', { isValid: isValid.value, + section: 'baseInfo', }); }, { immediate: true } @@ -108,7 +109,7 @@ watch(
-
+
+import Avatar from 'next/avatar/Avatar.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; @@ -32,12 +33,14 @@ const handleDelete = itemId => { >
- - {{ emptyStateMessage }} - + + {{ emptyStateMessage }} + +
{ :icon="item.icon" class="size-4 text-n-slate-12 flex-shrink-0" /> - + {{ item.name }} diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue new file mode 100644 index 000000000..5412675bd --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue @@ -0,0 +1,149 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue new file mode 100644 index 000000000..6a799005e --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue @@ -0,0 +1,163 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue index 2ac4d8854..e69aa798f 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/AddDataDropdown.story.vue @@ -34,6 +34,29 @@ const mockInboxes = [ }, ]; +const mockTags = [ + { + id: 1, + name: 'urgent', + color: '#ff4757', + }, + { + id: 2, + name: 'bug', + color: '#ff6b6b', + }, + { + id: 3, + name: 'feature-request', + color: '#4834d4', + }, + { + id: 4, + name: 'documentation', + color: '#26de81', + }, +]; + const handleAdd = item => { console.log('Add item:', item); }; @@ -42,9 +65,9 @@ const handleAdd = item => { diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue index 912b1fdfc..a81a29976 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/DataTable.story.vue @@ -22,6 +22,21 @@ const mockItems = [ }, ]; +const mockAgentList = [ + { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + avatarUrl: 'https://i.pravatar.cc/150?img=1', + }, + { + id: 2, + name: 'Jane Smith', + email: 'jane.smith@example.com', + avatarUrl: 'https://i.pravatar.cc/150?img=2', + }, +]; + const handleDelete = itemId => { console.log('Delete item:', itemId); }; @@ -30,7 +45,7 @@ const handleDelete = itemId => {